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 }