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

433 lines
13 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{
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
}
// 搜尋修飾詞不算主題核;它們用來要求 provider 偏向近期/熱門結果,
// 但正文只要真的提到使用者的主題即可,不要求作者自己寫出「熱門」。
var activityDiscoveryModifiers = []string{"熱門", "最新", "近期"}
var activityWorkMarkers = []string{
"外包", "接案", "案件", "工作", "職缺", "徵人", "求職", "freelance", "contract",
}
var activityRoleSpecialties = []string{
"後端", "前端", "全端", "網站", "網頁", "app", "設計", "剪輯", "行銷", "文案",
}
// 台灣常見地名(作前綴 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)
}
// 先放「整體意圖」查詢,再放單核變體。舊邏輯逐核塞滿 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 從意圖抽出可當搜尋主詞的 24 字核。
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
// 已有空白:每段當候選(再壓成 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 補變體。
// 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 個詞(半形空格分隔);中文每詞 24 字;整組去掉空格後 ≤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:])
}