package usecase import ( "context" "encoding/json" "errors" "fmt" "strings" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" "github.com/zeromicro/go-zero/core/logx" ) // productSuggestPrompt keeps the product path traceable: the model may suggest // wording, but every item has to point back to one concrete catalog field. func productSuggestPrompt(p *ProductContextSnapshot, limit int) string { var b strings.Builder b.WriteString("你是產品需求巡邏的關鍵字助理。請找正在描述需求、困擾或求助的人,不要找品牌宣傳或同業廣告。\n") b.WriteString("只根據以下產品上下文產出短搜尋詞,不要只用品牌名或產品名:\n") f := func(label string, values []string) { if len(values) == 0 { return } b.WriteString(label + ":" + strings.Join(values, "、") + "\n") } b.WriteString("品牌(僅供語境):「" + strings.TrimSpace(p.BrandName) + "」\n") b.WriteString("產品(僅供語境):「" + strings.TrimSpace(p.ProductLabel) + "」\n") f("目標受眾", []string{p.TargetAudience}) f("痛點", p.PainPoints) f("情境", []string{p.ProductContext}) f("標籤", p.MatchTags) f("可提供能力", p.ProviderCapabilityTerms) f("排除詞", p.ProviderExcludeTerms) b.WriteString(fmt.Sprintf("最多輸出 %d 則 JSON 陣列,每則格式:", limit)) b.WriteString(`[{"term":"短詞","reason":"為什麼能找到需求者","usage":"include 或 exclude","basis_kind":"audience/pain/context/tag/capability/exclude","basis_text":"對應的原始上下文"}]`) b.WriteString("\n規則:include 每則都必須有 basis_kind、basis_text;短詞最多兩個 token、中文每 token 2–4 字、去空格後最多 12 字;禁止標點、引號、AND/OR、emoji、#。exclude 的 basis_kind 必須是 exclude。品牌名與產品名不能是唯一理由。\n") return b.String() } /* suggestPrompt 組建議關鍵字的提示。 有服務檔案就帶服務項目、價格、案例、地區、禁語;沒有也能給通用求助短詞。 */ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string { var b strings.Builder b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字,") b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n") if p == nil { b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助/求推薦短搜尋詞。\n") } else { b.WriteString("服務項目:\n") for _, s := range p.Services { b.WriteString("- " + s.Name) if s.PriceMin > 0 || s.PriceMax > 0 { b.WriteString(fmt.Sprintf("(價格區間 %.0f–%.0f %s)", s.PriceMin, s.PriceMax, s.Currency)) } b.WriteString("\n") } if len(p.ServiceAreas) > 0 { labels := make([]string, 0, len(p.ServiceAreas)) for _, code := range p.ServiceAreas { if label := domain.ServiceAreaLabel(code); label != "" { labels = append(labels, label) } } b.WriteString("服務地區:" + strings.Join(labels, "、") + "\n") } if p.RemoteOk { b.WriteString("可遠端服務。\n") } if len(p.Cases) > 0 { b.WriteString("代表案例:\n") for _, c := range p.Cases { b.WriteString("- " + c.Title) if c.Summary != "" { b.WriteString(":" + c.Summary) } b.WriteString("\n") } } if len(p.Faq) > 0 { b.WriteString("客戶常問:\n") for _, f := range p.Faq { b.WriteString("- " + f.Question + "\n") } } if len(p.Forbidden) > 0 { // 禁語是回覆生成的硬性過濾詞,順手也不該出現在關鍵字裡。 b.WriteString("不可使用的字詞:" + strings.Join(p.Forbidden, "、") + "\n") } if p.ToneNote != "" { b.WriteString("語氣備註:" + p.ToneNote + "\n") } } if len(extra) > 0 { // 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。 b.WriteString("既有痛點關鍵字(可參考、可調整):" + strings.Join(extra, "、") + "\n") } b.WriteString(fmt.Sprintf("\n請輸出最多 %d 則建議,只輸出 JSON 陣列,不要有其他文字。每則格式:\n", limit)) b.WriteString(`[{"term":"關鍵字","reason":"為什麼這個詞能找到有需求的人(一句話)","usage":"include 或 exclude"}]`) b.WriteString("\n規則:\n") b.WriteString("1. include 是要搜尋的詞;exclude 是要排除的詞(例如同業叫賣、徵才、二手轉讓)。\n") b.WriteString("2. 用台灣的實際說法,包含口語求助句式:求推薦、有人知道、請問、怎麼辦、哪裡買。\n") b.WriteString("3. 每則都要有理由,理由講人話,不要覆述關鍵字本身。\n") b.WriteString("4. 不要輸出價格數字或聯絡方式。\n") b.WriteString("5. 【Threads 短詞硬約束|include 必守】每則 term 最多 2 個詞(半形空格分隔);") b.WriteString("中文每詞 2–4 字;整組去掉空格後 ≤12 字;禁止標點、引號、AND/OR、-、emoji、#。\n") b.WriteString("6. 每個服務意圖給 3–5 組短變體(例:「保母 求推薦」「到府保母」「台北 保母」),不要長句。\n") return b.String() } /* PainTermSource 提供既有痛點關鍵字工具的產出當 prompt 素材(可空)。 刻意做成可選:接不上時建議品質下降,但功能不會壞,也不會冒出第二套關鍵字引擎。 */ type PainTermSource interface { PainTerms(ctx context.Context, ownerUID int64) ([]string, error) } /* SuggestWatchTerms 回關鍵字建議(RW-03)。 服務檔案是加分項,不是門檻。AI 不可用時改走通用短詞,避免「建議關鍵字」變成下一扇牆。 不自動寫入任何 watch:使用者逐條採用才有意義。 計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5);fallback 不扣點。 */ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) } if limit <= 0 { limit = domain.DefaultSuggestions } if limit > domain.MaxSuggestions { limit = domain.MaxSuggestions } profile, err := s.Repo.GetServiceProfile(ctx, ownerUID) if err != nil && !errors.Is(err, domain.ErrNotFound) { return nil, err } if errors.Is(err, domain.ErrNotFound) { profile = nil } var extra []string if s.PainTerms != nil { if terms, perr := s.PainTerms.PainTerms(ctx, ownerUID); perr != nil { // 素材拿不到只影響建議品質,不該讓整個請求失敗。 logx.Errorf("radar suggest: pain terms unavailable uid=%d: %v", ownerUID, perr) } else { extra = terms } } charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "雷達關鍵字建議", "radar.suggest") if err != nil { return nil, err } defer charge.Settle(ctx, &err) raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra)) var out []domain.WatchTermSuggestion if aiErr == nil { out = domain.CleanSuggestions(parseSuggestions(raw), limit) } else { logx.Errorf("radar suggest: AI unavailable uid=%d: %v; using fallback", ownerUID, aiErr) } if len(out) == 0 { out = genericSuggestFallback(profile, extra, limit) } if len(out) == 0 { if aiErr != nil { return nil, aiErr } return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation) } if aiErr != nil { // 沒真正用到模型:退點,避免「系統自己給的詞還收一次」。 charge.Release(ctx) } return out, nil } func genericSuggestFallback(p *domain.ServiceProfile, extra []string, limit int) []domain.WatchTermSuggestion { raw := make([]domain.WatchTermSuggestion, 0, limit+8) add := func(term, reason, usage string) { term = strings.TrimSpace(term) reason = strings.TrimSpace(reason) if term == "" || reason == "" { return } raw = append(raw, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: usage}) } if p != nil { for _, item := range p.Services { name := strings.TrimSpace(item.Name) if name == "" { continue } for _, term := range domain.SearchableTermVariants(name, true) { add(term, "依你填的服務「"+name+"」找正在問的人", domain.SuggestUsageInclude) } } } for _, term := range extra { basis := strings.TrimSpace(term) if basis == "" { continue } for _, v := range domain.SearchableTermVariants(basis, true) { add(v, "依你已有的痛點詞「"+basis+"」", domain.SuggestUsageInclude) } } add("求推薦", "台灣 Threads 上找服務最常這樣問", domain.SuggestUsageInclude) add("有人知道", "口語求助句,能找到正在發問的人", domain.SuggestUsageInclude) add("求建議", "正在比較或猶豫的人常這樣寫", domain.SuggestUsageInclude) add("哪裡找", "明確在找店家或服務的人", domain.SuggestUsageInclude) add("怎麼辦", "遇到問題在求助的人常這樣寫", domain.SuggestUsageInclude) add("徵才", "招募文不是客人", domain.SuggestUsageExclude) add("接案", "同業供給文,不是需求", domain.SuggestUsageExclude) return domain.CleanSuggestions(raw, limit) } // SuggestProductWatchTerms suggests demand terms from a paired Brand/Product // snapshot. If the AI provider is unavailable, the catalog's own structured // fields are used as a deterministic, traceable fallback. func (s *Service) SuggestProductWatchTerms(ctx context.Context, ownerUID int64, brandID, productID string, limit int) (_ []domain.WatchTermSuggestion, err error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) } brandID = strings.TrimSpace(brandID) productID = strings.TrimSpace(productID) if brandID == "" || productID == "" { return nil, fmt.Errorf("%w: paired brand_id and product_id required", domain.ErrValidation) } if limit <= 0 { limit = domain.DefaultSuggestions } if limit > domain.MaxSuggestions { limit = domain.MaxSuggestions } ctxSnapshot, err := s.LoadProductContext(ctx, ownerUID, brandID, productID) if err != nil { return nil, err } charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "產品需求詞建議", "radar.suggest") if err != nil { return nil, err } defer charge.Settle(ctx, &err) var out []domain.WatchTermSuggestion if raw, aiErr := s.completeAI(ctx, ownerUID, productSuggestPrompt(ctxSnapshot, limit)); aiErr == nil { out = cleanProductSuggestions(domain.CleanSuggestions(parseSuggestions(raw), limit), limit) if len(out) == 0 { logx.Errorf("radar product suggest: AI returned no traceable terms uid=%d; using catalog fallback", ownerUID) } } else { logx.Errorf("radar product suggest: AI unavailable uid=%d: %v; using catalog fallback", ownerUID, aiErr) } if len(out) == 0 { out = productSuggestionFallback(ctxSnapshot, limit) } if len(out) == 0 { return nil, fmt.Errorf("%w: 產品上下文沒有可搜尋的需求詞,請先補充痛點、情境、標籤或能力", domain.ErrValidation) } return out, nil } func cleanProductSuggestions(in []domain.WatchTermSuggestion, limit int) []domain.WatchTermSuggestion { out := make([]domain.WatchTermSuggestion, 0, len(in)) for _, item := range in { item.BasisKind = strings.ToLower(strings.TrimSpace(item.BasisKind)) item.BasisText = strings.TrimSpace(item.BasisText) if !domain.IsSuggestionBasisKind(item.BasisKind) || item.BasisText == "" { continue } if item.Usage == domain.SuggestUsageInclude && item.BasisKind == domain.SuggestBasisExclude { continue } if item.Usage == domain.SuggestUsageExclude && item.BasisKind != domain.SuggestBasisExclude { continue } out = append(out, item) if len(out) >= limit { break } } return out } type productSuggestionField struct { kind, label string terms []string usage string } func productSuggestionFallback(p *ProductContextSnapshot, limit int) []domain.WatchTermSuggestion { fields := []productSuggestionField{ {domain.SuggestBasisPain, "痛點", p.PainPoints, domain.SuggestUsageInclude}, {domain.SuggestBasisTag, "標籤", p.MatchTags, domain.SuggestUsageInclude}, {domain.SuggestBasisCapability, "能力", p.ProviderCapabilityTerms, domain.SuggestUsageInclude}, {domain.SuggestBasisAudience, "受眾", []string{p.TargetAudience}, domain.SuggestUsageInclude}, {domain.SuggestBasisContext, "情境", []string{p.ProductContext}, domain.SuggestUsageInclude}, {domain.SuggestBasisExclude, "排除詞", p.ProviderExcludeTerms, domain.SuggestUsageExclude}, } out := make([]domain.WatchTermSuggestion, 0, limit) seen := map[string]bool{} for _, field := range fields { for _, raw := range field.terms { basis := strings.TrimSpace(raw) if basis == "" { continue } for _, term := range productSearchTermVariants(basis, field.usage == domain.SuggestUsageInclude) { key := strings.ToLower(term + "\x00" + field.usage) if seen[key] { continue } seen[key] = true reason := fmt.Sprintf("依產品設定的%s「%s」,可找相關需求貼文", field.label, basis) if field.usage == domain.SuggestUsageExclude { reason = fmt.Sprintf("產品設定將「%s」列為排除詞,避免混入非目標貼文", basis) } out = append(out, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: field.usage, BasisKind: field.kind, BasisText: basis}) if len(out) >= limit { return out } } } } return out } // productSearchTermVariants keeps fallback terms short without inventing // product names. Exact short fields win; longer CJK fields yield small windows. func productSearchTermVariants(raw string, include bool) []string { return domain.SearchableTermVariants(raw, include) } /* parseSuggestions 容忍模型在 JSON 前後多寫字或包上 code fence。 只截第一個 `[` 到最後一個 `]`:模型偶爾會加開場白,硬要求純 JSON 會讓整個功能 在那些回應上直接壞掉,而這裡的資料形狀很簡單,容忍不會引入歧義。 */ func parseSuggestions(raw string) []domain.WatchTermSuggestion { start := strings.Index(raw, "[") end := strings.LastIndex(raw, "]") if start < 0 || end <= start { return nil } var list []domain.WatchTermSuggestion if err := json.Unmarshal([]byte(raw[start:end+1]), &list); err != nil { return nil } return list } func (s *Service) completeAI(ctx context.Context, ownerUID int64, prompt string) (string, error) { if s.ResolveAI != nil && s.AIRegistry != nil { provider, model, apiKey, err := s.ResolveAI(ctx, ownerUID) if err == nil && strings.TrimSpace(apiKey) != "" && !strings.HasPrefix(strings.ToLower(apiKey), "fake") { if c, cerr := s.AIRegistry.Client(provider); cerr == nil { return c.Complete(ctx, apiKey, model, prompt) } } } if s.AI != nil { key := "test-key" if s.ResolveKey != nil { if _, k, rerr := s.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy); rerr == nil && k != "" { key = k } } return s.AI.Complete(ctx, key, "grok-3", prompt) } return "", fmt.Errorf("%w: 請到設定填寫 AI Key", domain.ErrValidation) }