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

229 lines
6.5 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 (
"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 {
return capTerms(dedupeTerms([]string{brief.Intent}, tokenizeIntent(brief.Intent)), 6)
}
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)
}
// 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 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
for _, term := range dedupeTerms(terms) {
if strings.Contains(text, strings.ToLower(term)) {
signals = append(signals, term)
}
}
return signals
}
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)
}