thread-master/apps/backend/internal/module/radar/usecase/demand_map_enrich.go

115 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package usecase
import (
"context"
"encoding/json"
"fmt"
"strings"
"apps/backend/internal/module/radar/domain"
usageDomain "apps/backend/internal/module/usage/domain"
)
type demandMapEnrichment struct {
PainPhrases []domain.DemandMapPhrase `json:"pain_phrases"`
ScenarioPhrases []domain.DemandMapPhrase `json:"scenario_phrases"`
DesiredOutcomes []domain.DemandMapPhrase `json:"desired_outcomes"`
SolutionSignals []domain.DemandMapPhrase `json:"solution_signals"`
ExclusionSignals []domain.DemandMapPhrase `json:"exclusion_signals"`
CustomPhrases []domain.DemandMapPhrase `json:"custom_phrases"`
}
func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID, previewID string, expectedVersion int64, creditCeiling int) (out *domain.DemandMap, err error) {
if ownerUID <= 0 || strings.TrimSpace(productID) == "" {
return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation)
}
if expectedVersion < 1 {
return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation)
}
if creditCeiling < 1 {
return nil, fmt.Errorf("%w: credit ceiling must be positive", domain.ErrValidation)
}
current, err := s.GetDemandMap(ctx, ownerUID, productID)
if err != nil {
return nil, err
}
charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "需求地圖 AI 補全", "radar.demand_map_enrich")
if err != nil {
return nil, err
}
defer charge.Settle(ctx, &err)
if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil {
return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady)
}
prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q情境=%q結果=%q能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。include 類 text 必須是 Threads 可搜短詞:最多 2 個 token、中文每 token 24 字、去空格後 ≤12 字、禁止標點。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals))
raw, err := s.completeAI(ctx, ownerUID, prompt)
if err != nil {
return nil, err
}
var additions demandMapEnrichment
start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}")
if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &additions) != nil {
return nil, fmt.Errorf("%w: AI returned invalid demand map JSON", domain.ErrValidation)
}
mergePhrases := func(existing []domain.DemandMapPhrase, incoming []domain.DemandMapPhrase) []domain.DemandMapPhrase {
out := append([]domain.DemandMapPhrase(nil), existing...)
seen := map[string]bool{}
for _, p := range out {
seen[strings.ToLower(strings.TrimSpace(p.Text))] = true
}
for _, p := range incoming {
p.Text = strings.TrimSpace(p.Text)
p.Origin = "ai"
p.Enabled = p.Enabled && p.Text != ""
if !p.Enabled {
continue
}
texts := []string{p.Text}
if p.Kind != "exclusion" {
texts = domain.SearchableTermVariants(p.Text, true)
if len(texts) > domain.MaxSearchableVariantsPerTerm {
texts = texts[:domain.MaxSearchableVariantsPerTerm]
}
}
basis := strings.TrimSpace(p.BasisText)
if basis == "" {
basis = p.Text
}
for _, text := range texts {
key := strings.ToLower(text)
if text == "" || seen[key] {
continue
}
cp := p
cp.Text = text
cp.BasisText = basis
out = append(out, cp)
seen[key] = true
}
}
return out
}
current.PainPhrases = mergePhrases(current.PainPhrases, additions.PainPhrases)
current.ScenarioPhrases = mergePhrases(current.ScenarioPhrases, additions.ScenarioPhrases)
current.DesiredOutcomes = mergePhrases(current.DesiredOutcomes, additions.DesiredOutcomes)
current.SolutionSignals = mergePhrases(current.SolutionSignals, additions.SolutionSignals)
current.ExclusionSignals = mergePhrases(current.ExclusionSignals, additions.ExclusionSignals)
current.CustomPhrases = mergePhrases(current.CustomPhrases, additions.CustomPhrases)
current.AIEnrichedAt = domain.NowNano()
if current.State != "ready" && len(current.PainPhrases) > 0 && len(current.ScenarioPhrases) > 0 && len(current.SolutionSignals) > 0 {
current.State = "ready"
}
_ = previewID // preview is a client confirmation token; no credit is reserved by preview.
return s.UpdateDemandMap(ctx, ownerUID, current, expectedVersion)
}
func phraseTexts(in []domain.DemandMapPhrase) []string {
out := make([]string, 0, len(in))
for _, p := range in {
if p.Enabled && strings.TrimSpace(p.Text) != "" {
out = append(out, strings.TrimSpace(p.Text))
}
}
return out
}