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

257 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_test
import (
"context"
"testing"
"apps/backend/internal/module/scout/domain"
"apps/backend/internal/module/scout/repository"
"apps/backend/internal/module/scout/usecase"
"github.com/stretchr/testify/require"
)
type capturedSearchProvider struct {
// queries records each fan-out call (one term per call)
queries []string
hits []usecase.ThreadSearchResult
}
func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) {
svc := usecase.New(repository.NewMemory())
brand, err := svc.CreateBrand(context.Background(), 1, "B", "")
require.NoError(t, err)
product, err := svc.SaveProduct(context.Background(), 1, &domain.Product{
BrandID: brand.ID, Label: "P", PainPoints: []string{"敏感肌", "敏感肌"}, MatchTags: []string{"保養", "敏感肌"},
})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false)
require.NoError(t, err)
// 每條是獨立 query含 intent痛點標籤與求助 variant不含 periphery
require.Equal(t, []string{"敏感肌保養", "敏感肌", "保養", "敏感肌 求推薦"}, brief.ScanTerms)
require.NotContains(t, brief.ScanTerms, "使用情境")
}
func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) {
if len(terms) > 0 {
p.queries = append(p.queries, terms[0])
}
return p.hits, nil
}
func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?", PublishedAt: 300},
{URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?", PublishedAt: 200},
{URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠", PublishedAt: 100},
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得", PublishedAt: 50},
{URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮", PublishedAt: 10},
}}
store := repository.NewMemory()
svc := usecase.New(store)
svc.Provider = provider
brief := &domain.RunBrief{
Mode: domain.ModeProduct, Intent: "敏感肌保養", Pains: []string{"敏感肌", "敏感肌"},
Tags: []string{"保養", "敏感肌"}, Periphery: []string{"使用情境", "保養"},
}
brief.ScanTerms = []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}
posts, err := svc.RunScanFromBrief(context.Background(), 42, brief)
require.NoError(t, err)
// 多 query 扇出:每條 scan_term 各搜一次
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.queries)
// product 模式略過 provider_offer硬廣noise 也略過 → 剩 3
require.Len(t, posts, 3)
// score 主排序;發文時間只在同分時決勝
require.Equal(t, "https://www.threads.net/@b/post/2", posts[0].Permalink)
require.Equal(t, int64(200), posts[0].PostedAt)
byClass := map[string]*domain.Post{}
for _, post := range posts {
byClass[post.Classification] = post
require.NotEmpty(t, post.Permalink)
require.Equal(t, post.Permalink, post.ExternalID)
require.InDelta(t, 0, post.Score, 100)
require.NotEmpty(t, post.MatchReason)
require.NotEqual(t, domain.ClassificationProviderOffer, post.Classification)
}
require.Contains(t, byClass, domain.ClassificationSeekingHelp)
require.Contains(t, byClass, domain.ClassificationSeekingRecommendation)
require.Contains(t, byClass, domain.ClassificationDiscussion)
require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌")
require.Equal(t, "https://www.threads.net/@a/post/1", byClass[domain.ClassificationSeekingHelp].Permalink)
again, err := svc.RunScanFromBrief(context.Background(), 42, brief)
require.NoError(t, err)
require.Equal(t, byClass[domain.ClassificationSeekingRecommendation].ID, again[0].ID)
persisted, err := svc.ListPosts(context.Background(), 42, "")
require.NoError(t, err)
require.Len(t, persisted, 3)
}
func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@a/post/1", Snippet: "請問這週市集怎麼報名?"},
{URL: "https://www.threads.net/@b/post/2", Snippet: "市集活動公告,週六開幕"},
{URL: "https://www.threads.net/@c/post/3", Snippet: "市集攤位服務洽詢"},
{URL: "https://www.threads.net/@d/post/4", Snippet: "分享市集逛街心得"},
{URL: "https://www.threads.net/@e/post/5", Snippet: "市集 crypto 賺錢群組"},
}}
svc := usecase.New(repository.NewMemory())
svc.Provider = provider
brief := &domain.RunBrief{Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}}
posts, err := svc.RunScanFromBrief(context.Background(), 7, brief)
require.NoError(t, err)
require.Len(t, posts, 4)
classes := map[string]bool{}
for _, post := range posts {
classes[post.Classification] = true
require.Contains(t, post.MatchReason, "recency unavailable (neutral)")
require.Greater(t, post.Score, 0)
}
require.True(t, classes[domain.ClassificationAsking])
require.True(t, classes[domain.ClassificationAnnouncement])
require.True(t, classes[domain.ClassificationProviderOffer])
require.True(t, classes[domain.ClassificationDiscussion])
}
func TestProviderScoutFindsProvenSolversAndExcludesSameCategory(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@doctor/post/1", Snippet: "刺鼻頭皮問題可由皮膚科專業諮詢協助", PublishedAt: 300},
{URL: "https://www.threads.net/@friend/post/2", Snippet: "推薦皮膚科處理刺鼻頭皮問題,有完整案例", PublishedAt: 200},
{URL: "https://www.threads.net/@seller/post/3", Snippet: "刺鼻頭皮洗髮精,皮膚科配方限時優惠", PublishedAt: 100},
{URL: "https://www.threads.net/@chat/post/4", Snippet: "刺鼻頭皮真的很困擾", PublishedAt: 50},
}}
svc := usecase.New(repository.NewMemory())
svc.Provider = provider
brand, err := svc.CreateBrand(context.Background(), 11, "B", "")
require.NoError(t, err)
p, err := svc.SaveProduct(context.Background(), 11, &domain.Product{
BrandID: brand.ID, Label: "無香洗髮", PainPoints: []string{"刺鼻頭皮"},
ProviderCapabilityTerms: []string{"皮膚科"}, ProviderExcludeTerms: []string{"洗髮精"},
})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 11, "", brand.ID, p.ID, "provider", false)
require.NoError(t, err)
require.Equal(t, domain.ModeProvider, brief.Mode)
require.NotContains(t, brief.ScanTerms, p.Label)
posts, err := svc.RunScanFromBrief(context.Background(), 11, brief)
require.NoError(t, err)
require.Len(t, posts, 2)
require.Equal(t, domain.ClassificationProviderRecommended, posts[0].Classification)
require.Equal(t, domain.ClassificationProviderDirect, posts[1].Classification)
}
func TestDemandScoutKeepsHelpSignalsWithoutProviderTerms(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@need/post/1", Snippet: "頭皮一直發癢怎麼辦?"},
{URL: "https://www.threads.net/@need/post/2", Snippet: "敏感頭皮洗髮精有沒有推薦"},
{URL: "https://www.threads.net/@seller/post/3", Snippet: "頭皮發癢洗髮精限時優惠"},
{URL: "https://www.threads.net/@chat/post/4", Snippet: "頭皮發癢的季節又到了"},
}}
svc := usecase.New(repository.NewMemory())
svc.Provider = provider
brand, err := svc.CreateBrand(context.Background(), 15, "B", "")
require.NoError(t, err)
p, err := svc.SaveProduct(context.Background(), 15, &domain.Product{
BrandID: brand.ID, Label: "舒緩洗髮", PainPoints: []string{"頭皮發癢", "敏感頭皮"},
})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 15, "", brand.ID, p.ID, "demand", false)
require.NoError(t, err)
require.Equal(t, domain.ModeDemand, brief.Mode)
require.Contains(t, brief.ScanTerms, "頭皮發癢 怎麼辦")
posts, err := svc.RunScanFromBrief(context.Background(), 15, brief)
require.NoError(t, err)
require.Len(t, posts, 2)
require.Equal(t, domain.ClassificationSeekingRecommendation, posts[0].Classification)
require.Equal(t, domain.ClassificationSeekingHelp, posts[1].Classification)
}
func TestProviderScoutPlansShortHighIntentQueries(t *testing.T) {
svc := usecase.New(repository.NewMemory())
brand, err := svc.CreateBrand(context.Background(), 14, "B", "")
require.NoError(t, err)
p, err := svc.SaveProduct(context.Background(), 14, &domain.Product{
BrandID: brand.ID,
Label: "無香洗髮",
PainPoints: []string{
"最近頭皮又乾又癢不知道怎麼辦",
},
ProviderCapabilityTerms: []string{
"皮膚科敏感頭皮專業諮詢服務",
},
})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 14, "", brand.ID, p.ID, "provider", false)
require.NoError(t, err)
require.NotEmpty(t, brief.ScanTerms)
for _, term := range brief.ScanTerms {
require.LessOrEqual(t, len([]rune(term)), 24)
require.NotContains(t, term, "不知道怎麼辦")
}
require.Contains(t, brief.ScanTerms, "最近頭皮又乾又癢 推薦")
require.Contains(t, brief.ScanTerms, "皮膚科敏感頭皮專業諮詢服務 推薦")
}
func TestProviderScoutUsesExistingMatchTagsWhenProviderFieldsAreEmpty(t *testing.T) {
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
{URL: "https://www.threads.net/@doctor/post/1", Snippet: "頭皮刺鼻問題可由皮膚科專業諮詢協助"},
{URL: "https://www.threads.net/@seller/post/2", Snippet: "頭皮刺鼻問題,皮膚科配方限時優惠"},
}}
svc := usecase.New(repository.NewMemory())
svc.Provider = provider
brand, err := svc.CreateBrand(context.Background(), 12, "B", "")
require.NoError(t, err)
p, err := svc.SaveProduct(context.Background(), 12, &domain.Product{
BrandID: brand.ID, Label: "無香洗髮", PainPoints: []string{"頭皮刺鼻"}, MatchTags: []string{"皮膚科"},
})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 12, "", brand.ID, p.ID, "provider", false)
require.NoError(t, err)
require.Equal(t, []string{"皮膚科"}, brief.Tags)
require.Contains(t, brief.Periphery, p.Label)
posts, err := svc.RunScanFromBrief(context.Background(), 12, brief)
require.NoError(t, err)
require.Len(t, posts, 1)
require.Equal(t, domain.ClassificationProviderDirect, posts[0].Classification)
}
func TestProviderScoutFallsBackToProductLabel(t *testing.T) {
svc := usecase.New(repository.NewMemory())
svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{
URL: "https://www.threads.net/@expert/post/1", Snippet: "無香洗髮專業諮詢協助",
}}}
brand, err := svc.CreateBrand(context.Background(), 13, "B", "")
require.NoError(t, err)
p, err := svc.SaveProduct(context.Background(), 13, &domain.Product{BrandID: brand.ID, Label: "無香洗髮"})
require.NoError(t, err)
brief, err := svc.PrepareBrief(context.Background(), 13, "", brand.ID, p.ID, "provider", false)
require.NoError(t, err)
require.NotEmpty(t, brief.ScanTerms)
posts, err := svc.RunScanFromBrief(context.Background(), 13, brief)
require.NoError(t, err)
require.Len(t, posts, 1)
}
func TestDraftOutreachIsDeterministicAndDoesNotNeedAI(t *testing.T) {
svc := usecase.New(repository.NewMemory())
svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{
URL: "https://www.threads.net/@author/post/1", Snippet: "請問怎麼處理頭皮困擾?",
}}}
posts, err := svc.RunScanFromBrief(context.Background(), 9, &domain.RunBrief{
Mode: domain.ModeTheme, Intent: "頭皮", ScanTerms: []string{"頭皮"},
})
require.NoError(t, err)
draft, err := svc.DraftOutreach(context.Background(), 9, posts[0].ID, "")
require.NoError(t, err)
require.Equal(t, "嗨 @author看到你提到「頭皮」我也遇過類似情況。若你願意想聽聽你後來怎麼處理。", draft.DraftText)
}