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

344 lines
11 KiB
Go
Raw Normal View History

2026-08-09 07:57:35 +00:00
ㄇㄠpackage usecase
2026-07-13 08:59:13 +00:00
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"strconv"
"strings"
2026-08-03 07:58:07 +00:00
"unicode/utf8"
2026-07-13 08:59:13 +00:00
"apps/backend/internal/module/scout/domain"
)
type classifiedPost struct {
classification string
score int
reason string
}
2026-08-03 07:58:07 +00:00
// planScanTerms builds independent search queries (one query per term).
// Callers fan out: each ScanTerms[i] is searched separately then merged.
// Avoids dumping all keywords into one diluted Exa query.
2026-07-13 08:59:13 +00:00
func planScanTerms(brief *domain.RunBrief) []string {
2026-08-03 07:58:07 +00:00
if brief == nil {
return nil
}
if brief.Mode == domain.ModeActivity {
2026-08-09 07:57:35 +00:00
// 規則式變體(不依賴 AIAI 擴充在 PrepareBrief 另外合併。
return planActivityTerms(brief.Intent)
2026-08-03 07:58:07 +00:00
}
if brief.Mode == domain.ModeProvider {
return planProviderTerms(brief.Pains, brief.Tags)
}
if brief.Mode == domain.ModeDemand {
return planDemandTerms(brief.Pains)
}
2026-08-03 07:58:07 +00:00
pains := filterSeekablePains(brief.Pains)
tags := filterSeekablePains(brief.Tags)
var queries []string
intent := strings.TrimSpace(brief.Intent)
if intent != "" {
if utf8.RuneCountInString(intent) > 48 {
intent = truncateRunes(intent, 48)
}
queries = append(queries, intent)
}
for _, p := range pains {
if !strings.EqualFold(p, intent) {
queries = append(queries, p)
}
}
for _, tag := range tags {
if !strings.EqualFold(tag, intent) {
queries = append(queries, tag)
}
}
// 對前幾個短痛點加求助語感 variant整句當一 query不是單獨搜「求推薦」
for _, p := range capTerms(pains, 3) {
if utf8.RuneCountInString(p) <= 18 {
queries = append(queries, p+" 求推薦")
}
}
return capTerms(dedupeTerms(queries), 8)
}
func planDemandTerms(pains []string) []string {
pains = compactProviderTerms(pains)
var queries []string
for _, pain := range pains {
queries = append(queries, pain+" 怎麼辦", pain+" 推薦", pain+" 有人也這樣嗎")
}
return capTerms(dedupeTerms(queries), 6)
}
func planProviderTerms(pains, capabilities []string) []string {
pains = compactProviderTerms(pains)
capabilities = compactProviderTerms(capabilities)
var queries []string
for _, pain := range pains {
for _, capability := range capabilities {
if strings.EqualFold(pain, capability) {
queries = append(queries, capability+" 推薦", capability+" 專業")
continue
}
if utf8.RuneCountInString(pain)+utf8.RuneCountInString(capability)+1 <= 24 {
queries = append(queries, pain+" "+capability)
}
}
queries = append(queries, pain+" 推薦")
}
for _, capability := range capabilities {
queries = append(queries, capability+" 推薦", capability+" 專業")
}
return capTerms(dedupeTerms(queries), 8)
}
func compactProviderTerms(terms []string) []string {
var out []string
for _, term := range terms {
term = strings.Join(strings.Fields(strings.TrimSpace(term)), " ")
for _, filler := range []string{"不知道怎麼辦", "怎麼辦", "有沒有推薦", "求推薦", "請問", "想問", "我想找", "需要"} {
term = strings.ReplaceAll(term, filler, "")
}
term = strings.Trim(term, " ,,、。!?!?:")
if utf8.RuneCountInString(term) > 18 {
term = truncateRunes(term, 18)
}
if term != "" {
out = append(out, term)
}
}
return filterSeekablePains(out)
}
2026-08-03 07:58:07 +00:00
// filterSeekablePains drops marketing slogans / long product blurbs that pull competitor posts.
func filterSeekablePains(in []string) []string {
var out []string
for _, raw := range in {
term := strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
if term == "" {
continue
}
if isMarketingPhrase(term) {
continue
}
out = append(out, term)
}
return out
}
func isMarketingPhrase(term string) bool {
if utf8.RuneCountInString(term) > 28 {
return true
}
lower := strings.ToLower(term)
bad := []string{
"✔", "✓", "x1", "×1", "任選", "內含", "官網", "現折", "折扣碼",
"完整保養流程", "快速帶走", "滋潤修護", "天然植萃配方", "溫和不刺激",
"服務洽詢", "限時優惠", "立即購買", "旗艦店",
}
for _, b := range bad {
if strings.Contains(lower, strings.ToLower(b)) {
return true
}
}
return false
}
func capTerms(terms []string, max int) []string {
if max <= 0 || len(terms) <= max {
return terms
}
return terms[:max]
2026-07-13 08:59:13 +00:00
}
func dedupeTerms(groups ...[]string) []string {
seen := make(map[string]struct{})
var out []string
for _, group := range groups {
for _, term := range group {
term = strings.Join(strings.Fields(strings.TrimSpace(term)), " ")
key := strings.ToLower(term)
if term == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, term)
}
}
return out
}
2026-08-03 07:58:07 +00:00
func tokenizeIntent(intent string) []string {
// 簡單空白/標點切詞,活躍模式備援
fields := strings.FieldsFunc(intent, func(r rune) bool {
switch r {
case ' ', '\t', '\n', ',', '', '、', '/', '|', '·', '。', '', '', '!', '?':
return true
default:
return false
}
})
return fields
}
func truncateRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return string(runes[:max])
}
2026-07-13 08:59:13 +00:00
func classifyPost(mode, text string, terms []string) classifiedPost {
lower := strings.ToLower(text)
signals := matchedSignals(lower, terms)
if hasAny(lower, "giveaway", "抽獎", "follow for follow", "互追", "crypto", "賺錢") {
return classifiedPost{domain.ClassificationNoise, 0, "noise signal"}
}
if mode == domain.ModeActivity {
return classifyActivity(lower, signals)
}
if hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal")
}
if hasAny(lower, "推薦", "求推", "有沒有推薦", "any recommendation", "what do you recommend") {
return scored(domain.ClassificationSeekingRecommendation, 60, signals, "recommendation signal")
}
if strings.Contains(lower, "?") || strings.Contains(lower, "") || hasAny(lower, "怎麼", "如何", "請問", "求助", "help") {
return scored(domain.ClassificationSeekingHelp, 55, signals, "help-seeking signal")
}
return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal")
}
func classifyProvider(text string, pains, capabilities, excludes []string) classifiedPost {
lower := strings.ToLower(text)
if hasMatchingTerm(lower, excludes) {
return classifiedPost{domain.ClassificationNoise, 0, "same-category exclusion"}
}
if hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
return classifiedPost{domain.ClassificationNoise, 0, "sales signal"}
}
matchedPains := matchedSignals(lower, pains)
matchedCapabilities := matchedSignals(lower, capabilities)
if len(matchedPains) == 0 || len(matchedCapabilities) == 0 {
return classifiedPost{domain.ClassificationNoise, 0, "missing pain or capability evidence"}
}
base := 65
classification := domain.ClassificationProviderDirect
reason := "pain and capability evidence"
if hasAny(lower, "推薦", "介紹", "找", "口碑") {
base = 75
classification = domain.ClassificationProviderRecommended
reason = "recommended provider evidence"
} else if !hasAny(lower, "案例", "專業", "預約", "諮詢", "服務", "協助", "聯絡", "工作室", "診所", "顧問") {
return classifiedPost{domain.ClassificationNoise, 0, "missing provider proof"}
}
signals := append(matchedPains, matchedCapabilities...)
return scored(classification, base, signals, reason)
}
func classifyDemand(text string, pains, excludes []string) classifiedPost {
lower := strings.ToLower(text)
if hasMatchingTerm(lower, excludes) || hasAny(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
return classifiedPost{domain.ClassificationNoise, 0, "excluded sales or category signal"}
}
signals := matchedSignals(lower, pains)
if len(signals) == 0 {
return classifiedPost{domain.ClassificationNoise, 0, "missing pain evidence"}
}
if hasAny(lower, "推薦", "求推", "有沒有推薦", "有人也", "哪裡", "找不到") {
return scored(domain.ClassificationSeekingRecommendation, 75, signals, "pain and recommendation signal")
}
if strings.Contains(lower, "?") || strings.Contains(lower, "") || hasAny(lower, "怎麼", "如何", "請問", "求助", "help", "沒用", "無效", "失敗", "困擾") {
return scored(domain.ClassificationSeekingHelp, 70, signals, "pain and help signal")
}
return classifiedPost{domain.ClassificationNoise, 0, "pain mentioned without demand signal"}
}
func hasMatchingTerm(text string, terms []string) bool {
return len(matchedSignals(text, terms)) > 0
}
2026-07-13 08:59:13 +00:00
func classifyActivity(text string, signals []string) classifiedPost {
if hasAny(text, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal; recency unavailable (neutral)")
}
if strings.Contains(text, "?") || strings.Contains(text, "") || hasAny(text, "請問", "怎麼", "如何", "有人知道", "求") {
return scored(domain.ClassificationAsking, 60, signals, "asking signal; recency unavailable (neutral)")
}
if hasAny(text, "活動", "event", "開幕", "launch", "發布", "報名", "登記", "公告") {
return scored(domain.ClassificationAnnouncement, 50, signals, "announcement signal; recency unavailable (neutral)")
}
return scored(domain.ClassificationDiscussion, 40, signals, "discussion signal; recency unavailable (neutral)")
}
func scored(classification string, base int, signals []string, label string) classifiedPost {
score := base + len(signals)*15
if score > 100 {
score = 100
}
reason := label
if len(signals) > 0 {
reason += "; matched: " + strings.Join(signals, ", ")
}
return classifiedPost{classification, score, reason}
}
func matchedSignals(text string, terms []string) []string {
var signals []string
text = normalizeSignal(text)
2026-07-13 08:59:13 +00:00
for _, term := range dedupeTerms(terms) {
if normalized := normalizeSignal(term); normalized != "" && strings.Contains(text, normalized) {
2026-07-13 08:59:13 +00:00
signals = append(signals, term)
}
}
return signals
}
func normalizeSignal(text string) string {
text = strings.ToLower(text)
for _, filler := range []string{"一直", "真的", "有點", "又", "很", "都"} {
text = strings.ReplaceAll(text, filler, "")
}
return strings.Join(strings.Fields(text), "")
}
2026-07-13 08:59:13 +00:00
func hasAny(text string, signals ...string) bool {
for _, signal := range signals {
if strings.Contains(text, signal) {
return true
}
}
return false
}
func canonicalPermalink(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
u.Scheme = "https"
u.Host = strings.ToLower(u.Host)
u.RawQuery = ""
u.Fragment = ""
u.Path = strings.TrimRight(u.Path, "/")
return u.String()
}
func permalinkID(ownerUID int64, permalink string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(permalink) + "|" + formatOwnerUID(ownerUID)))
return "sp_" + hex.EncodeToString(sum[:])[:20]
}
func formatOwnerUID(ownerUID int64) string {
return strconv.FormatInt(ownerUID, 10)
}