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

391 lines
12 KiB
Go
Raw Permalink 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 (
"crypto/sha256"
"encoding/hex"
"net/url"
"strconv"
"strings"
"unicode/utf8"
"apps/backend/internal/module/scout/domain"
)
type classifiedPost struct {
classification string
score int
reason string
}
// 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.
func planScanTerms(brief *domain.RunBrief) []string {
if brief == nil {
return nil
}
if brief.Mode == domain.ModeActivity {
// 規則式變體(不依賴 AIAI 擴充在 PrepareBrief 另外合併。
return planActivityTerms(brief.Intent)
}
if brief.Mode == domain.ModeProvider {
return planProviderTerms(brief.Pains, brief.Tags)
}
if brief.Mode == domain.ModeDemand {
return planDemandTerms(brief.Pains)
}
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)
}
// 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]
}
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
}
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])
}
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
}
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)
for _, term := range dedupeTerms(terms) {
if normalized := normalizeSignal(term); normalized != "" && strings.Contains(text, normalized) {
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), "")
}
func hasAny(text string, signals ...string) bool {
for _, signal := range signals {
if strings.Contains(text, signal) {
return true
}
}
return false
}
func isThreadsHostname(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
return host == "threads.net" || strings.HasSuffix(host, ".threads.net") ||
host == "threads.com" || strings.HasSuffix(host, ".threads.com")
}
// threadsPostShortcode extracts the stable post identity from both profile
// permalinks (/@user/post/CODE) and short share links (/t/CODE).
func threadsPostShortcode(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || !isThreadsHostname(u.Hostname()) {
return ""
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := 0; i+1 < len(parts); i++ {
segment := strings.ToLower(strings.TrimSpace(parts[i]))
if segment != "post" && segment != "t" {
continue
}
if code := strings.TrimSpace(parts[i+1]); code != "" {
return code
}
}
return ""
}
// canonicalPostIdentity is the dedupe key. The shortcode stays case-sensitive;
// threads.net/.com, www/mobile hosts, query strings and author path changes do not.
func canonicalPostIdentity(raw string) string {
if code := threadsPostShortcode(raw); code != "" {
return "threads-post:" + code
}
if permalink := canonicalPermalink(raw); permalink != "" {
return "url:" + permalink
}
return ""
}
func canonicalPermalink(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" {
return ""
}
u.Scheme = "https"
if isThreadsHostname(u.Hostname()) {
u.Host = "www.threads.net"
} else {
u.Host = strings.ToLower(u.Host)
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
u.Path = strings.TrimRight(u.Path, "/")
u.RawPath = ""
return u.String()
}
func permalinkID(ownerUID int64, permalink string) string {
identity := canonicalPostIdentity(permalink)
if identity == "" {
identity = strings.TrimSpace(permalink)
}
sum := sha256.Sum256([]byte(identity + "|" + formatOwnerUID(ownerUID)))
return "sp_" + hex.EncodeToString(sum[:])[:20]
}
func formatOwnerUID(ownerUID int64) string {
return strconv.FormatInt(ownerUID, 10)
}