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

94 lines
4.1 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)
}
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。", 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 && !seen[strings.ToLower(p.Text)] {
out = append(out, p)
seen[strings.ToLower(p.Text)] = 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
}