thread-master/apps/backend/internal/module/scout/usecase/activity_terms.go

433 lines
13 KiB
Go
Raw Permalink Normal View History

2026-08-09 07:57:35 +00:00
package usecase
import (
"encoding/json"
"strings"
"unicode"
"unicode/utf8"
radarDomain "apps/backend/internal/module/radar/domain"
)
// 話題activity口語錨點與 Threads 上常見「可跟/可討論」貼文語感對齊。
// 每詞 24 字,方便與主題核組 2-token 短查詢。
var activityAnchors = []string{
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
}
2026-08-13 02:22:24 +00:00
// 搜尋修飾詞不算主題核;它們用來要求 provider 偏向近期/熱門結果,
// 但正文只要真的提到使用者的主題即可,不要求作者自己寫出「熱門」。
var activityDiscoveryModifiers = []string{"熱門", "最新", "近期"}
var activityWorkMarkers = []string{
"外包", "接案", "案件", "工作", "職缺", "徵人", "求職", "freelance", "contract",
}
var activityRoleSpecialties = []string{
"後端", "前端", "全端", "網站", "網頁", "app", "設計", "剪輯", "行銷", "文案",
}
2026-08-09 07:57:35 +00:00
// 台灣常見地名(作前綴 token23 字)
var activityRegions = []string{
"台北", "新北", "桃園", "台中", "台南", "高雄", "新竹", "基隆",
"嘉義", "宜蘭", "花蓮", "台東", "屏東", "彰化", "雲林", "南投",
"苗栗", "金門", "澎湖", "板橋", "中和", "三重", "淡水", "竹北",
}
/*
planActivityTerms 依使用者意圖**產生**可搜的短詞變體不是只把原文拆開
策略
2026-08-13 02:22:24 +00:00
1. 抽出主題核24 CJK 2+ 英數與地區
2. 核本身 + ×口語錨點 + 地區×
3. 一律過 Threads 短詞規則與商機 suggest/explore 同一契約
2026-08-09 07:57:35 +00:00
*/
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)
}
2026-08-13 02:22:24 +00:00
// 先放「整體意圖」查詢,再放單核變體。舊邏輯逐核塞滿 8 組,三詞輸入
// 會只剩第一詞(例:外包),後面的「工程師/後端」完全遺失。
candidates = append(candidates, inferActivityIntentTerms(intent, cores)...)
candidates = append(candidates, titleActivityTerms(intent)...)
// 先 round-robin 放每個主題核,確保每個概念至少保留一次。
2026-08-09 07:57:35 +00:00
for _, core := range cores {
if radarDomain.IsThreadsSearchable(core) {
candidates = append(candidates, core)
}
2026-08-13 02:22:24 +00:00
}
// 近期/熱門是可選搜尋修飾;先於一般口語變體,讓作品、人物、事件詞
// 能直接形成「鬼滅之刃 熱門」這類 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 {
2026-08-09 07:57:35 +00:00
pair := core + " " + anchor
if radarDomain.IsThreadsSearchable(pair) {
candidates = append(candidates, pair)
}
}
2026-08-13 02:22:24 +00:00
}
for _, core := range cores {
2026-08-09 07:57:35 +00:00
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)
}
2026-08-13 02:22:24 +00:00
// 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
}
2026-08-09 07:57:35 +00:00
// extractTopicCores 從意圖抽出可當搜尋主詞的 24 字核。
func extractTopicCores(intent string) []string {
intent = radarDomain.NormalizeSearchTerm(intent)
if intent == "" {
return nil
}
2026-08-13 02:22:24 +00:00
if !strings.Contains(intent, " ") && radarDomain.IsThreadsSearchable(intent) {
return []string{intent}
}
if titleParts := splitTitleCores(intent); len(titleParts) > 0 {
return titleParts
}
2026-08-09 07:57:35 +00:00
var cores []string
// 已有空白:每段當候選(再壓成 24 字)
if strings.Contains(intent, " ") {
for _, tok := range strings.Fields(intent) {
if c := compactCore(tok); c != "" {
cores = append(cores, c)
}
}
return dedupeTerms(cores)
}
// 連續中文:用標點/空白切完後,對每段取 24 字滑窗(優先較長有意義核)
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 24 CJK 或 212 英數才當核
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)
}
2026-08-13 02:22:24 +00:00
// mergeActivityTerms 先保留可解釋、可重現的使用者意圖查詢,再由 AI 補變體。
// AI 不得把主查詢換成另一個意思。
2026-08-09 07:57:35 +00:00
func mergeActivityTerms(aiTerms, ruleTerms []string, max int) []string {
ai := filterThreadsSearchableTerms(aiTerms)
rules := filterThreadsSearchableTerms(ruleTerms)
2026-08-13 02:22:24 +00:00
return capTerms(dedupeTerms(rules, ai), max)
2026-08-09 07:57:35 +00:00
}
func activityTermsPrompt(intent string, limit int) string {
var b strings.Builder
2026-08-13 02:22:24 +00:00
b.WriteString("你是台灣 Threads 搜尋意圖助理。使用者想找與輸入真正相關、近期且有討論動能的貼文。\n")
2026-08-09 07:57:35 +00:00
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 個詞(半形空格分隔);中文每詞 24 字;整組去掉空格後 ≤12 字禁止標點、引號、AND/OR、emoji、#。\n")
2026-08-13 02:22:24 +00:00
b.WriteString("3. 先判斷意圖,不可只拆字:工作型輸入要保留職種+外包/接案/職缺語意;作品或人物名稱要保留專名,可加熱門/最新。\n")
b.WriteString("4. 用台灣口語:求推薦、分享、心得、活動、怎麼辦、有人知道、討論。\n")
b.WriteString("5. 每個意圖給多組短變體(例:「後端 外包」「後端 接案」;「鬼滅之刃」「鬼滅之刃 最新」),不要只拆使用者原句。\n")
b.WriteString("6. 不要輸出價格、連結、帳號。\n")
2026-08-09 07:57:35 +00:00
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:])
}