291 lines
7.9 KiB
Go
291 lines
7.9 KiB
Go
|
|
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{
|
|||
|
|
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 台灣常見地名(作前綴 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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 從意圖抽出可當搜尋主詞的 2–4 字核。
|
|||
|
|
func extractTopicCores(intent string) []string {
|
|||
|
|
intent = radarDomain.NormalizeSearchTerm(intent)
|
|||
|
|
if intent == "" {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 產詞,規則變體補足到 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 個詞(半形空格分隔);中文每詞 2–4 字;整組去掉空格後 ≤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:])
|
|||
|
|
}
|