191 lines
6.6 KiB
Go
191 lines
6.6 KiB
Go
|
|
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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
suggestPrompt 用服務檔案組建議關鍵字的提示。
|
|||
|
|
|
|||
|
|
素材全部來自使用者自己填的服務檔案:服務項目、價格區間、案例、地區、禁語。
|
|||
|
|
沒有服務檔案就不呼叫 AI(見 SuggestWatchTerms)—— 沒有依據的建議只是猜測,
|
|||
|
|
而使用者會把它當成系統的判斷。
|
|||
|
|
*/
|
|||
|
|
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
|
|||
|
|
var b strings.Builder
|
|||
|
|
b.WriteString("你是台灣本地服務業的行銷助理。根據以下服務檔案,提出可用於社群平台搜尋的關鍵字,")
|
|||
|
|
b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n")
|
|||
|
|
|
|||
|
|
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")
|
|||
|
|
return b.String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
PainTermSource 提供既有痛點關鍵字工具的產出當 prompt 素材(可空)。
|
|||
|
|
|
|||
|
|
刻意做成可選:接不上時建議品質下降,但功能不會壞,也不會冒出第二套關鍵字引擎。
|
|||
|
|
*/
|
|||
|
|
type PainTermSource interface {
|
|||
|
|
PainTerms(ctx context.Context, ownerUID int64) ([]string, error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
SuggestWatchTerms 依服務檔案回關鍵字建議(RW-03)。
|
|||
|
|
|
|||
|
|
不自動寫入任何 watch:使用者逐條採用才有意義,也才看得懂每個詞是為什麼在那裡。
|
|||
|
|
計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5),不新增第五個 meter。
|
|||
|
|
*/
|
|||
|
|
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 {
|
|||
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|||
|
|
return nil, fmt.Errorf(
|
|||
|
|
"%w: service profile required before suggesting keywords; fill in /api/v1/radar/service-profile first",
|
|||
|
|
domain.ErrValidation,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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, err := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
out := domain.CleanSuggestions(parseSuggestions(raw), limit)
|
|||
|
|
if len(out) == 0 {
|
|||
|
|
// 空清單會被讀成「你的服務沒有關鍵字可監控」,那是錯的訊息。
|
|||
|
|
return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/*
|
|||
|
|
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)
|
|||
|
|
}
|