433 lines
13 KiB
Go
433 lines
13 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{
|
||
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
|
||
}
|
||
|
||
// 搜尋修飾詞不算主題核;它們用來要求 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:])
|
||
}
|