94 lines
4.1 KiB
Go
94 lines
4.1 KiB
Go
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
|
||
}
|