package usecase import ( "encoding/json" "strings" "unicode" "unicode/utf8" radarDomain "apps/backend/internal/module/radar/domain" ) // 話題(activity)口語錨點:與 Threads 上常見「可跟/可討論」貼文語感對齊。 // 每詞 2–4 字,方便與主題核組 2-token 短查詢。 var activityAnchors = []string{ "求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論", } // 搜尋修飾詞不算主題核;它們用來要求 provider 偏向近期/熱門結果, // 但正文只要真的提到使用者的主題即可,不要求作者自己寫出「熱門」。 var activityDiscoveryModifiers = []string{"熱門", "最新", "近期"} var activityWorkMarkers = []string{ "外包", "接案", "案件", "工作", "職缺", "徵人", "求職", "freelance", "contract", } var activityRoleSpecialties = []string{ "後端", "前端", "全端", "網站", "網頁", "app", "設計", "剪輯", "行銷", "文案", } // 台灣常見地名(作前綴 token,2–3 字) var activityRegions = []string{ "台北", "新北", "桃園", "台中", "台南", "高雄", "新竹", "基隆", "嘉義", "宜蘭", "花蓮", "台東", "屏東", "彰化", "雲林", "南投", "苗栗", "金門", "澎湖", "板橋", "中和", "三重", "淡水", "竹北", } /* planActivityTerms 依使用者意圖**產生**可搜的短詞變體,不是只把原文拆開。 策略: 1. 抽出主題核(2–4 字 CJK 或 2+ 英數)與地區 2. 核本身 + 核×口語錨點 + 地區×核 3. 一律過 Threads 短詞規則(與商機 suggest/explore 同一契約) */ func planActivityTerms(intent string) []string { intent = radarDomain.NormalizeSearchTerm(intent) if intent == "" { return nil } cores := extractTopicCores(intent) regions := extractRegions(intent) var candidates []string // 原句若已合規,保留(使用者自己打的短詞) if radarDomain.IsThreadsSearchable(intent) { candidates = append(candidates, intent) } // 先放「整體意圖」查詢,再放單核變體。舊邏輯逐核塞滿 8 組,三詞輸入 // 會只剩第一詞(例:外包),後面的「工程師/後端」完全遺失。 candidates = append(candidates, inferActivityIntentTerms(intent, cores)...) candidates = append(candidates, titleActivityTerms(intent)...) // 先 round-robin 放每個主題核,確保每個概念至少保留一次。 for _, core := range cores { if radarDomain.IsThreadsSearchable(core) { candidates = append(candidates, core) } } // 近期/熱門是可選搜尋修飾;先於一般口語變體,讓作品、人物、事件詞 // 能直接形成「鬼滅之刃 熱門」這類 discovery query。 for _, modifier := range activityDiscoveryModifiers { for _, core := range cores { candidates = appendSearchable(candidates, core+" "+modifier) } } // 再按錨點 round-robin 擴充,避免第一個 core 的變體吃完整個上限。 for _, anchor := range activityAnchors { for _, core := range cores { pair := core + " " + anchor if radarDomain.IsThreadsSearchable(pair) { candidates = append(candidates, pair) } } } for _, core := range cores { for _, region := range regions { pair := region + " " + core if radarDomain.IsThreadsSearchable(pair) { candidates = append(candidates, pair) } } } // 地區 + 錨點(例:台北 活動)— 主題核不足時的備援 if len(cores) == 0 { for _, region := range regions { for _, anchor := range []string{"活動", "推薦", "分享"} { pair := region + " " + anchor if radarDomain.IsThreadsSearchable(pair) { candidates = append(candidates, pair) } } } } return capTerms(dedupeTerms(candidates), 8) } // inferActivityIntentTerms 把使用者的「幾個概念」組回真正想找的 query。 // activity 頁仍是話題探索,但工作/推薦類輸入也應找得到相關討論與公開需求。 func inferActivityIntentTerms(intent string, cores []string) []string { if !containsAnyFold(intent, activityWorkMarkers) { return adjacentCorePairs(cores) } var specialties []string for _, specialty := range activityRoleSpecialties { if strings.Contains(strings.ToLower(intent), specialty) { specialties = append(specialties, specialty) } } if len(specialties) == 0 { for _, core := range cores { if !containsAnyFold(core, activityWorkMarkers) && core != "工程師" { specialties = append(specialties, core) } } } var out []string for _, specialty := range specialties { out = appendSearchable(out, specialty+" 外包", specialty+" 接案", specialty+" 工程師", specialty+" 推薦", ) } out = appendSearchable(out, "外包 案件", "工程師 外包") if containsAnyFold(intent, []string{"後端"}) { out = appendSearchable(out, "徵人 後端", "尋找 後端") } return dedupeTerms(out, adjacentCorePairs(cores)) } func adjacentCorePairs(cores []string) []string { var out []string for i := 0; i+1 < len(cores); i++ { out = appendSearchable(out, cores[i]+" "+cores[i+1]) } return out } func titleActivityTerms(intent string) []string { parts := splitTitleCores(intent) if len(parts) != 2 { return nil } var out []string out = appendSearchable(out, parts[0]+" "+parts[1], parts[0]) for _, modifier := range activityDiscoveryModifiers { out = appendSearchable(out, parts[0]+" "+modifier) } return out } // splitTitleCores 保留 5+ 字作品/專名的連接語意,例如: // 「進擊的巨人」→「進擊 的巨人」。四字內專名則保留原名。 func splitTitleCores(intent string) []string { intent = radarDomain.NormalizeSearchTerm(intent) if intent == "" || strings.Contains(intent, " ") { return nil } runes := []rune(intent) if len(runes) < 5 || len(runes) > 12 { return nil } for i, r := range runes { if i < 2 || i >= len(runes)-1 || (r != '之' && r != '的' && r != '與') { continue } left, right := string(runes[:i]), string(runes[i:]) if radarDomain.IsThreadsSearchable(left) && radarDomain.IsThreadsSearchable(right) && radarDomain.IsThreadsSearchable(left+" "+right) { return []string{left, right} } } return nil } func appendSearchable(out []string, terms ...string) []string { for _, term := range terms { if radarDomain.IsThreadsSearchable(term) { out = append(out, radarDomain.NormalizeSearchTerm(term)) } } return out } func containsAnyFold(text string, terms []string) bool { text = strings.ToLower(text) for _, term := range terms { if strings.Contains(text, strings.ToLower(term)) { return true } } return false } // extractTopicCores 從意圖抽出可當搜尋主詞的 2–4 字核。 func extractTopicCores(intent string) []string { intent = radarDomain.NormalizeSearchTerm(intent) if intent == "" { return nil } if !strings.Contains(intent, " ") && radarDomain.IsThreadsSearchable(intent) { return []string{intent} } if titleParts := splitTitleCores(intent); len(titleParts) > 0 { return titleParts } var cores []string // 已有空白:每段當候選(再壓成 2–4 字) if strings.Contains(intent, " ") { for _, tok := range strings.Fields(intent) { if c := compactCore(tok); c != "" { cores = append(cores, c) } } return dedupeTerms(cores) } // 連續中文:用標點/空白切完後,對每段取 2–4 字滑窗(優先較長有意義核) segments := tokenizeIntent(intent) if len(segments) == 0 { segments = []string{intent} } for _, seg := range segments { seg = strings.TrimSpace(seg) if seg == "" { continue } // 去掉地區再抽核,避免「台北」佔掉唯一核 stripped := stripRegions(seg) if stripped == "" { stripped = seg } if c := compactCore(stripped); c != "" { cores = append(cores, c) } // 滑窗 4→3→2,取不重疊優先 cores = append(cores, slidingCJKCores(stripped)...) } return dedupeTerms(cores) } func compactCore(tok string) string { tok = strings.TrimSpace(tok) if tok == "" { return "" } // 去掉常見口語虛詞尾巴 for _, filler := range []string{ "怎麼辦", "求推薦", "有沒有人", "有人知道", "請問一下", "請問", "想問", "想找", "有沒有", "可以嗎", "好不好", } { tok = strings.ReplaceAll(tok, filler, "") } tok = strings.Trim(tok, " ,,、。!?!?::的了嗎呢啊喔唷") if tok == "" { return "" } n := utf8.RuneCountInString(tok) // 單 token 2–4 CJK 或 2–12 英數才當核 if radarDomain.IsThreadsSearchable(tok) { return radarDomain.NormalizeSearchTerm(tok) } // 過長:取前 4 字當核 if n > 4 { r := []rune(tok) cand := string(r[:4]) if radarDomain.IsThreadsSearchable(cand) { return cand } cand = string(r[:3]) if radarDomain.IsThreadsSearchable(cand) { return cand } cand = string(r[:2]) if radarDomain.IsThreadsSearchable(cand) { return cand } } return "" } func slidingCJKCores(seg string) []string { runes := []rune(seg) // 只保留 CJK/字母數字 var cleaned []rune for _, r := range runes { if unicode.Is(unicode.Han, r) || unicode.IsLetter(r) || unicode.IsDigit(r) { cleaned = append(cleaned, r) } } if len(cleaned) < 2 { return nil } var out []string // 優先較長窗 for size := 4; size >= 2; size-- { if len(cleaned) < size { continue } for i := 0; i+size <= len(cleaned); i++ { cand := string(cleaned[i : i+size]) if radarDomain.IsThreadsSearchable(cand) { out = append(out, cand) } // 限制數量,避免長句爆炸 if len(out) >= 6 { return dedupeTerms(out) } } } return dedupeTerms(out) } func extractRegions(intent string) []string { var out []string for _, r := range activityRegions { if strings.Contains(intent, r) { out = append(out, r) } } return out } func stripRegions(s string) string { for _, r := range activityRegions { s = strings.ReplaceAll(s, r, "") } return strings.TrimSpace(s) } // parseActivityAITerms 解析模型回傳的 JSON 字串陣列或 {term} 物件陣列。 func parseActivityAITerms(raw string) []string { start := strings.Index(raw, "[") end := strings.LastIndex(raw, "]") if start < 0 || end <= start { return nil } chunk := raw[start : end+1] var asStrings []string if err := json.Unmarshal([]byte(chunk), &asStrings); err == nil { return asStrings } var asObjs []struct { Term string `json:"term"` } if err := json.Unmarshal([]byte(chunk), &asObjs); err == nil { out := make([]string, 0, len(asObjs)) for _, o := range asObjs { if t := strings.TrimSpace(o.Term); t != "" { out = append(out, t) } } return out } return nil } func filterThreadsSearchableTerms(in []string) []string { var out []string for _, t := range in { t = radarDomain.NormalizeSearchTerm(t) if radarDomain.IsThreadsSearchable(t) { out = append(out, t) } } return dedupeTerms(out) } // mergeActivityTerms 先保留可解釋、可重現的使用者意圖查詢,再由 AI 補變體。 // AI 不得把主查詢換成另一個意思。 func mergeActivityTerms(aiTerms, ruleTerms []string, max int) []string { ai := filterThreadsSearchableTerms(aiTerms) rules := filterThreadsSearchableTerms(ruleTerms) return capTerms(dedupeTerms(rules, ai), max) } func activityTermsPrompt(intent string, limit int) string { var b strings.Builder b.WriteString("你是台灣 Threads 搜尋意圖助理。使用者想找與輸入真正相關、近期且有討論動能的貼文。\n") b.WriteString("主題意圖:") b.WriteString(intent) b.WriteString("\n\n") b.WriteString("請產出搜尋關鍵字,幫助在 Threads 找到正在討論這個主題的貼文。\n") b.WriteString("只輸出 JSON 字串陣列,不要其他文字。格式:\n") b.WriteString(`["關鍵字1","關鍵字2"]`) b.WriteString("\n規則:\n") b.WriteString("1. 最多 ") b.WriteString(itoaASCII(limit)) b.WriteString(" 組。\n") b.WriteString("2. 【Threads 短詞硬約束】每組最多 2 個詞(半形空格分隔);中文每詞 2–4 字;整組去掉空格後 ≤12 字;禁止標點、引號、AND/OR、emoji、#。\n") b.WriteString("3. 先判斷意圖,不可只拆字:工作型輸入要保留職種+外包/接案/職缺語意;作品或人物名稱要保留專名,可加熱門/最新。\n") b.WriteString("4. 用台灣口語:求推薦、分享、心得、活動、怎麼辦、有人知道、討論。\n") b.WriteString("5. 每個意圖給多組短變體(例:「後端 外包」「後端 接案」;「鬼滅之刃」「鬼滅之刃 最新」),不要只拆使用者原句。\n") b.WriteString("6. 不要輸出價格、連結、帳號。\n") return b.String() } func itoaASCII(n int) string { if n <= 0 { return "0" } var b [12]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } return string(b[i:]) }