342 lines
11 KiB
Go
342 lines
11 KiB
Go
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)
|
||
}
|
||
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 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)
|
||
}
|