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

291 lines
7.9 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 (
"encoding/json"
"strings"
"unicode"
"unicode/utf8"
radarDomain "apps/backend/internal/module/radar/domain"
)
// 話題activity口語錨點與 Threads 上常見「可跟/可討論」貼文語感對齊。
// 每詞 24 字,方便與主題核組 2-token 短查詢。
var activityAnchors = []string{
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
}
// 台灣常見地名(作前綴 token23 字)
var activityRegions = []string{
"台北", "新北", "桃園", "台中", "台南", "高雄", "新竹", "基隆",
"嘉義", "宜蘭", "花蓮", "台東", "屏東", "彰化", "雲林", "南投",
"苗栗", "金門", "澎湖", "板橋", "中和", "三重", "淡水", "竹北",
}
/*
planActivityTerms 依使用者意圖**產生**可搜的短詞變體,不是只把原文拆開。
策略:
1. 抽出主題核24 字 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)
}
for _, core := range cores {
if radarDomain.IsThreadsSearchable(core) {
candidates = append(candidates, core)
}
for _, anchor := range activityAnchors {
pair := core + " " + anchor
if radarDomain.IsThreadsSearchable(pair) {
candidates = append(candidates, pair)
}
}
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)
}
// extractTopicCores 從意圖抽出可當搜尋主詞的 24 字核。
func extractTopicCores(intent string) []string {
intent = radarDomain.NormalizeSearchTerm(intent)
if intent == "" {
return nil
}
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)
}
// mergeActivityTerms 優先 AI 產詞,規則變體補足到 max。
func mergeActivityTerms(aiTerms, ruleTerms []string, max int) []string {
ai := filterThreadsSearchableTerms(aiTerms)
rules := filterThreadsSearchableTerms(ruleTerms)
return capTerms(dedupeTerms(ai, rules), 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 個詞(半形空格分隔);中文每詞 24 字;整組去掉空格後 ≤12 字禁止標點、引號、AND/OR、emoji、#。\n")
b.WriteString("3. 用台灣口語:求推薦、分享、心得、活動、怎麼辦、有人知道、討論。\n")
b.WriteString("4. 每個意圖給多組短變體(例:「市集 分享」「週末 市集」「文青 市集」),不要只拆使用者原句。\n")
b.WriteString("5. 不要輸出價格、連結、帳號。\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:])
}