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

115 lines
4.7 KiB
Go
Raw Normal View History

2026-08-13 02:22:24 +00:00
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)
}
2026-08-13 07:54:25 +00:00
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))
2026-08-13 02:22:24 +00:00
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 != ""
2026-08-13 07:54:25 +00:00
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
2026-08-13 02:22:24 +00:00
}
}
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
}