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

388 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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":"audiencepaincontexttagcapabilityexclude","basis_text":"對應的原始上下文"}]`)
b.WriteString("\n規則include 每則都必須有 basis_kind、basis_text短詞最多兩個 token、中文每 token 24 字、去空格後最多 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("中文每詞 24 字;整組去掉空格後 ≤12 字禁止標點、引號、AND/OR、-、emoji、#。\n")
b.WriteString("6. 每個服務意圖給 35 組短變體(例:「保母 求推薦」「到府保母」「台北 保母」),不要長句。\n")
return b.String()
}
/*
PainTermSource 提供既有痛點關鍵字工具的產出當 prompt 素材(可空)。
刻意做成可選:接不上時建議品質下降,但功能不會壞,也不會冒出第二套關鍵字引擎。
*/
type PainTermSource interface {
PainTerms(ctx context.Context, ownerUID int64) ([]string, error)
}
/*
SuggestWatchTerms 回關鍵字建議RW-03
服務檔案是加分項不是門檻。AI 不可用時改走通用短詞,避免「建議關鍵字」變成下一扇牆。
不自動寫入任何 watch使用者逐條採用才有意義。
計費走既有 ai_copy metersource 標 radar.suggestspec §5.5fallback 不扣點。
*/
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)
}