diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go index 0c8768e..34a04c5 100644 --- a/apps/backend/cmd/worker/main.go +++ b/apps/backend/cmd/worker/main.go @@ -18,22 +18,22 @@ import ( "apps/backend/internal/module/ai" appnotifRepo "apps/backend/internal/module/appnotif/repository" appnotifUC "apps/backend/internal/module/appnotif/usecase" + crmRepo "apps/backend/internal/module/crm/repository" + crmUC "apps/backend/internal/module/crm/usecase" fsDomain "apps/backend/internal/module/filestorage/domain" "apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/s3store" + growthRepo "apps/backend/internal/module/growth/repository" + growthUC "apps/backend/internal/module/growth/usecase" jobDomain "apps/backend/internal/module/job/domain" jobRepo "apps/backend/internal/module/job/repository" jobUC "apps/backend/internal/module/job/usecase" memberDomain "apps/backend/internal/module/member/domain" memberRepo "apps/backend/internal/module/member/repository" - scoutDomain "apps/backend/internal/module/scout/domain" - scoutRepo "apps/backend/internal/module/scout/repository" - growthRepo "apps/backend/internal/module/growth/repository" - growthUC "apps/backend/internal/module/growth/usecase" - crmRepo "apps/backend/internal/module/crm/repository" - crmUC "apps/backend/internal/module/crm/usecase" radarRepo "apps/backend/internal/module/radar/repository" radarUC "apps/backend/internal/module/radar/usecase" + scoutDomain "apps/backend/internal/module/scout/domain" + scoutRepo "apps/backend/internal/module/scout/repository" scoutUC "apps/backend/internal/module/scout/usecase" studioPublish "apps/backend/internal/module/studio/publish" studioRepo "apps/backend/internal/module/studio/repository" @@ -161,9 +161,13 @@ func main() { radarSvc := radarUC.New(radarRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) radarSvc.HitFetch = radarUC.HitFetcherFunc(func(ctx context.Context, ownerUID int64, terms []string, limit int) ([]radarUC.ThreadHit, string, error) { hits, path, err := scoutSvc.SearchHitsOnly(ctx, ownerUID, terms, limit) - if err != nil { return nil, path, err } + if err != nil { + return nil, path, err + } o := make([]radarUC.ThreadHit, 0, len(hits)) - for _, h := range hits { o = append(o, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet}) } + for _, h := range hits { + o = append(o, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet, PublishedAt: h.PublishedAt}) + } return o, path, nil }) radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api index a6388c7..711cc0e 100644 --- a/apps/backend/generate/api/m5.api +++ b/apps/backend/generate/api/m5.api @@ -188,6 +188,8 @@ type ( ProductContext string `json:"product_context"` MatchTags []string `json:"match_tags"` PainPoints []string `json:"pain_points"` + ProviderCapabilityTerms []string `json:"provider_capability_terms"` + ProviderExcludeTerms []string `json:"provider_exclude_terms"` PlacementUrl string `json:"placement_url,optional"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` @@ -202,6 +204,8 @@ type ( ProductContext string `json:"product_context,optional"` MatchTags []string `json:"match_tags,optional"` PainPoints []string `json:"pain_points,optional"` + ProviderCapabilityTerms []string `json:"provider_capability_terms,optional"` + ProviderExcludeTerms []string `json:"provider_exclude_terms,optional"` PlacementUrl string `json:"placement_url,optional"` } ProductIdPath { diff --git a/apps/backend/internal/logic/scout/promote_scout_post_logic.go b/apps/backend/internal/logic/scout/promote_scout_post_logic.go index 179697c..33b1388 100644 --- a/apps/backend/internal/logic/scout/promote_scout_post_logic.go +++ b/apps/backend/internal/logic/scout/promote_scout_post_logic.go @@ -2,6 +2,7 @@ package scout import ( "context" + "fmt" "apps/backend/internal/middleware" "apps/backend/internal/response" @@ -33,12 +34,15 @@ func (l *PromoteScoutPostLogic) PromoteScoutPost(req *types.ScoutPostIdPath) (*t if err != nil { return nil, err } + if p.ScoutMode == "activity" || p.ScoutMode == "provider" { + return nil, fmt.Errorf("this scout mode cannot be promoted to an opportunity") + } externalID := p.ExternalID if externalID == "" { externalID = p.ID } o, err := l.svcCtx.Radar.PromoteFromScout( - l.ctx, uid, p.ID, externalID, p.Permalink, p.Author, p.Text, p.CreatedAt, + l.ctx, uid, p.ID, externalID, p.Permalink, p.Author, p.Text, p.PostedAt, ) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/scout/save_product_logic.go b/apps/backend/internal/logic/scout/save_product_logic.go index 810d35d..f8fbc9c 100644 --- a/apps/backend/internal/logic/scout/save_product_logic.go +++ b/apps/backend/internal/logic/scout/save_product_logic.go @@ -34,7 +34,9 @@ func (l *SaveProductLogic) SaveProduct(req *types.ProductSaveReq) (*types.Produc p, err := l.svcCtx.Scout.SaveProduct(l.ctx, uid, &scoutDomain.Product{ ID: req.Id, BrandID: req.BrandId, Label: req.Label, ProductContext: req.ProductContext, - MatchTags: req.MatchTags, PainPoints: req.PainPoints, PlacementURL: req.PlacementUrl, + MatchTags: req.MatchTags, PainPoints: req.PainPoints, + ProviderCapabilityTerms: req.ProviderCapabilityTerms, ProviderExcludeTerms: req.ProviderExcludeTerms, + PlacementURL: req.PlacementUrl, }) if err != nil { return nil, err diff --git a/apps/backend/internal/module/radar/domain/opportunity_test.go b/apps/backend/internal/module/radar/domain/opportunity_test.go index b0c4f74..a0859b8 100644 --- a/apps/backend/internal/module/radar/domain/opportunity_test.go +++ b/apps/backend/internal/module/radar/domain/opportunity_test.go @@ -25,6 +25,15 @@ func TestBandFromScore_LockedThresholds(t *testing.T) { } } +func TestUnknownPublishedTimeIsNotStale(t *testing.T) { + if IsStaleHardReject(0, NowNano()) { + t.Fatal("an unknown source timestamp must not be rejected as stale") + } + if FreshnessScore(FreshnessHoursSince(0, NowNano())) != 0 { + t.Fatal("an unknown source timestamp must receive no freshness credit") + } +} + func TestValidateReasons_RequiresAllFive(t *testing.T) { full := fiveReasons() if err := ValidateReasons(full); err != nil { diff --git a/apps/backend/internal/module/radar/domain/scoring.go b/apps/backend/internal/module/radar/domain/scoring.go index 17f01cf..bed3054 100644 --- a/apps/backend/internal/module/radar/domain/scoring.go +++ b/apps/backend/internal/module/radar/domain/scoring.go @@ -16,6 +16,9 @@ const MaxFreshnessDays = 14 // FreshnessScore maps age in hours to the 0–15 freshness dimension score. func FreshnessScore(hours int) int { + if hours > MaxFreshnessDays*24 { + return 0 + } if hours < 0 { hours = 0 } @@ -49,6 +52,9 @@ func FreshnessHoursSince(postedAt, now int64) int { // IsStaleHardReject is true when the post is older than 14 days. func IsStaleHardReject(postedAt, now int64) bool { + if postedAt <= 0 { + return false + } return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24 } diff --git a/apps/backend/internal/module/radar/usecase/sweep_fetch.go b/apps/backend/internal/module/radar/usecase/sweep_fetch.go index d7040c9..e07f551 100644 --- a/apps/backend/internal/module/radar/usecase/sweep_fetch.go +++ b/apps/backend/internal/module/radar/usecase/sweep_fetch.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" @@ -12,9 +11,10 @@ import ( // ThreadHit is one raw search result from the dual-path fetch layer. type ThreadHit struct { - URL string - Title string - Snippet string + URL string + Title string + Snippet string + PublishedAt int64 } // ThreadSearcher is the API-path search (Exa etc.). @@ -102,7 +102,6 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain exclude[strings.ToLower(strings.TrimSpace(e))] = true } - now := domain.NowNano() out := make([]*domain.CandidatePost, 0, len(hits)) for _, h := range hits { text := strings.TrimSpace(h.Snippet) @@ -135,7 +134,7 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain AuthorHandle: authorFromURL(permalink), Text: text, Title: h.Title, - PostedAt: now - int64(time.Hour), // unknown recency → treat as ~1h (not hard-reject) + PostedAt: h.PublishedAt, MatchedTerm: term, Classification: class, }) diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go index 977bf21..2f56b5f 100644 --- a/apps/backend/internal/module/scout/domain/domain.go +++ b/apps/backend/internal/module/scout/domain/domain.go @@ -29,6 +29,8 @@ const ( ModeProduct = "product" ModeTheme = "theme" ModeActivity = "activity" + ModeDemand = "demand" + ModeProvider = "provider" ClassificationSeekingHelp = "seeking_help" ClassificationSeekingRecommendation = "seeking_recommendation" @@ -37,6 +39,8 @@ const ( ClassificationAsking = "asking" ClassificationAnnouncement = "announcement" ClassificationNoise = "noise" + ClassificationProviderDirect = "provider_direct" + ClassificationProviderRecommended = "provider_recommended" ) type Brand struct { @@ -56,16 +60,18 @@ type Brand struct { } type Product struct { - ID string `bson:"_id" json:"id"` - OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` - BrandID string `bson:"brand_id" json:"brand_id"` - Label string `bson:"label" json:"label"` - ProductContext string `bson:"product_context" json:"product_context"` - MatchTags []string `bson:"match_tags" json:"match_tags"` - PainPoints []string `bson:"pain_points" json:"pain_points"` - PlacementURL string `bson:"placement_url,omitempty" json:"placement_url,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + BrandID string `bson:"brand_id" json:"brand_id"` + Label string `bson:"label" json:"label"` + ProductContext string `bson:"product_context" json:"product_context"` + MatchTags []string `bson:"match_tags" json:"match_tags"` + PainPoints []string `bson:"pain_points" json:"pain_points"` + ProviderCapabilityTerms []string `bson:"provider_capability_terms" json:"provider_capability_terms"` + ProviderExcludeTerms []string `bson:"provider_exclude_terms" json:"provider_exclude_terms"` + PlacementURL string `bson:"placement_url,omitempty" json:"placement_url,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type ActiveBrand struct { diff --git a/apps/backend/internal/module/scout/repository/memory.go b/apps/backend/internal/module/scout/repository/memory.go index e1c6496..9347ab1 100644 --- a/apps/backend/internal/module/scout/repository/memory.go +++ b/apps/backend/internal/module/scout/repository/memory.go @@ -99,6 +99,12 @@ func (s *MemoryStore) SaveProduct(_ context.Context, p *domain.Product) error { if p.PainPoints != nil { cp.PainPoints = append([]string(nil), p.PainPoints...) } + if p.ProviderCapabilityTerms != nil { + cp.ProviderCapabilityTerms = append([]string(nil), p.ProviderCapabilityTerms...) + } + if p.ProviderExcludeTerms != nil { + cp.ProviderExcludeTerms = append([]string(nil), p.ProviderExcludeTerms...) + } s.products[p.ID] = &cp return nil } @@ -143,6 +149,12 @@ func copyProduct(p *domain.Product) *domain.Product { if p.PainPoints != nil { cp.PainPoints = append([]string(nil), p.PainPoints...) } + if p.ProviderCapabilityTerms != nil { + cp.ProviderCapabilityTerms = append([]string(nil), p.ProviderCapabilityTerms...) + } + if p.ProviderExcludeTerms != nil { + cp.ProviderExcludeTerms = append([]string(nil), p.ProviderExcludeTerms...) + } return &cp } diff --git a/apps/backend/internal/module/scout/usecase/planner.go b/apps/backend/internal/module/scout/usecase/planner.go index 385b949..e97413a 100644 --- a/apps/backend/internal/module/scout/usecase/planner.go +++ b/apps/backend/internal/module/scout/usecase/planner.go @@ -27,6 +27,12 @@ func planScanTerms(brief *domain.RunBrief) []string { 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) @@ -58,6 +64,55 @@ func planScanTerms(brief *domain.RunBrief) []string { 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 @@ -161,6 +216,55 @@ func classifyPost(mode, text string, terms []string) classifiedPost { 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)") @@ -188,14 +292,23 @@ func scored(classification string, base int, signals []string, label string) cla func matchedSignals(text string, terms []string) []string { var signals []string + text = normalizeSignal(text) for _, term := range dedupeTerms(terms) { - if strings.Contains(text, strings.ToLower(term)) { + 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) { diff --git a/apps/backend/internal/module/scout/usecase/planner_test.go b/apps/backend/internal/module/scout/usecase/planner_test.go index 41c4579..2f877b4 100644 --- a/apps/backend/internal/module/scout/usecase/planner_test.go +++ b/apps/backend/internal/module/scout/usecase/planner_test.go @@ -115,6 +115,132 @@ func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) { 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.ClassificationProviderDirect, posts[0].Classification) + require.Equal(t, domain.ClassificationProviderRecommended, 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.ClassificationSeekingHelp, posts[0].Classification) + require.Equal(t, domain.ClassificationSeekingRecommendation, 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{{ diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go index c7616a5..f0c30a9 100644 --- a/apps/backend/internal/module/scout/usecase/service.go +++ b/apps/backend/internal/module/scout/usecase/service.go @@ -210,7 +210,7 @@ func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.I label = host + " 商品" } return &domain.ImportDraft{ - Label: label, + Label: label, // 痛點/標籤要用「飼主正在煩惱的話」,不是產品賣點;匯入後請再改成真痛點 ProductContext: "從 " + raw + " 推估的產品情境(請改寫成實際賣點與使用場景)", PainPoints: []string{"不知道怎麼選", "用了沒感覺", "擔心不適合/有副作用"}, @@ -223,7 +223,7 @@ func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.I func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) { _ = deep intent = strings.TrimSpace(intent) - if intent == "" { + if intent == "" && purpose != "provider" && purpose != "demand" { return nil, fmt.Errorf("%w: intent required", domain.ErrValidation) } mode := domain.ModeTheme @@ -236,6 +236,9 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran } if productID != "" { p, err := s.GetProduct(ctx, ownerUID, productID) + if err != nil && purpose == "provider" { + return nil, err + } if err == nil { mode = domain.ModeProduct if purpose == "activity" { @@ -250,6 +253,51 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran brief.PlacementNote = "軟性經驗分享,避免硬廣" } } + if purpose == "provider" { + if productID == "" { + return nil, fmt.Errorf("%w: product required for provider matching", domain.ErrValidation) + } + p, err := s.GetProduct(ctx, ownerUID, productID) + if err != nil { + return nil, err + } + pains, capabilities, excludes := providerMatchingFields(p) + brief.Mode = domain.ModeProvider + brief.BrandID = p.BrandID + brief.ProductLabel = p.Label + brief.ProductContext = p.ProductContext + brief.Pains = pains + brief.Tags = capabilities + brief.Periphery = excludes + brief.Intent = p.Label + brief.ResponseStance = "找可驗證的解法提供者,不推產品" + brief.ScanTerms = planScanTerms(brief) + brief.ThemeLabel = truncate(p.Label+" 解法媒合", 36) + brief.ThemeKey = brief.Mode + "|" + productID + return brief, nil + } + if purpose == "demand" { + if productID == "" { + return nil, fmt.Errorf("%w: product required for demand matching", domain.ErrValidation) + } + p, err := s.GetProduct(ctx, ownerUID, productID) + if err != nil { + return nil, err + } + pains, excludes := demandMatchingFields(p) + brief.Mode = domain.ModeDemand + brief.BrandID = p.BrandID + brief.ProductLabel = p.Label + brief.ProductContext = p.ProductContext + brief.Pains = pains + brief.Periphery = excludes + brief.Intent = p.Label + brief.ResponseStance = "找正在求助的需求貼文,再決定如何回應" + brief.ScanTerms = planScanTerms(brief) + brief.ThemeLabel = truncate(p.Label+" 需求痛點", 36) + brief.ThemeKey = brief.Mode + "|" + productID + return brief, nil + } if len(brief.Pains) == 0 { // 預設用「求助/求推」語感,避免把產品賣點當搜尋詞 brief.Pains = []string{intent} @@ -266,6 +314,52 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran return brief, nil } +// providerMatchingFields keeps existing products usable: their established match +// tags become matching terms until the more specific provider terms are added. +func providerMatchingFields(p *domain.Product) (pains, capabilities, excludes []string) { + pains = nonEmptyTerms(p.PainPoints) + if len(pains) == 0 { + pains = nonEmptyTerms(p.MatchTags) + } + capabilities = nonEmptyTerms(p.ProviderCapabilityTerms) + if len(capabilities) == 0 { + capabilities = nonEmptyTerms(p.MatchTags) + } + if label := strings.TrimSpace(p.Label); label != "" { + if len(pains) == 0 { + pains = []string{label} + } + if len(capabilities) == 0 { + capabilities = []string{label} + } + } + excludes = nonEmptyTerms(p.ProviderExcludeTerms) + if label := strings.TrimSpace(p.Label); label != "" && !containsTerm(pains, label) && !containsTerm(capabilities, label) { + excludes = dedupeTerms(excludes, []string{label}) + } + return pains, capabilities, excludes +} + +func containsTerm(terms []string, want string) bool { + for _, term := range terms { + if strings.EqualFold(strings.TrimSpace(term), want) { + return true + } + } + return false +} + +func demandMatchingFields(p *domain.Product) (pains, excludes []string) { + pains = nonEmptyTerms(p.PainPoints) + if len(pains) == 0 { + pains = nonEmptyTerms(p.MatchTags) + } + if len(pains) == 0 && strings.TrimSpace(p.Label) != "" { + pains = []string{strings.TrimSpace(p.Label)} + } + excludes = nonEmptyTerms(p.ProviderExcludeTerms) + return pains, excludes +} // SearchHitsOnly runs the dual-path Threads search without persisting Scout posts. // Radar reuses this so the crawl split (api vs crawler via dev_mode) stays one code path (RG-01). @@ -314,6 +408,31 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d if len(terms) == 0 { return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation) } + if brief.Mode == domain.ModeProvider { + p, err := s.GetProduct(ctx, ownerUID, brief.ProductID) + if err != nil { + return nil, err + } + pains, capabilities, excludes := providerMatchingFields(p) + brief.BrandID = p.BrandID + brief.ProductLabel = p.Label + brief.Pains = pains + brief.Tags = capabilities + brief.Periphery = excludes + terms = filterProviderScanTerms(terms, p) + if len(terms) == 0 { + return nil, fmt.Errorf("%w: provider scan terms cannot be product/category exclusions", domain.ErrValidation) + } + } + if brief.Mode == domain.ModeDemand { + p, err := s.GetProduct(ctx, ownerUID, brief.ProductID) + if err != nil { + return nil, err + } + brief.BrandID = p.BrandID + brief.ProductLabel = p.Label + brief.Pains, brief.Periphery = demandMatchingFields(p) + } path := domain.PathAPI devMode := false if s.Settings != nil { @@ -356,6 +475,18 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d return s.persistSearchHits(ctx, ownerUID, brief, path, hits) } +func filterProviderScanTerms(terms []string, p *domain.Product) []string { + var out []string + for _, term := range terms { + term = strings.TrimSpace(term) + if term == "" || strings.EqualFold(term, p.Label) || hasMatchingTerm(strings.ToLower(term), p.ProviderExcludeTerms) { + continue + } + out = append(out, term) + } + return dedupeTerms(out) +} + // fanOutSearch runs one search per query term and dedupes by canonical permalink. func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func(context.Context, string, int) ([]ThreadSearchResult, error)) ([]ThreadSearchResult, error) { if perQuery < 1 { @@ -414,6 +545,12 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief * term = matchingTerm(text+" "+hit.Title, brief.ScanTerms) } classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms) + if brief.Mode == domain.ModeProvider { + classified = classifyProvider(text+" "+hit.Title, brief.Pains, brief.Tags, brief.Periphery) + } + if brief.Mode == domain.ModeDemand { + classified = classifyDemand(text+" "+hit.Title, brief.Pains, brief.Periphery) + } if classified.classification == domain.ClassificationNoise { continue } @@ -422,6 +559,9 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief * classified.classification == domain.ClassificationProviderOffer { continue } + if brief.Mode == domain.ModeProvider && (classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) { + continue + } postedAt := hit.PublishedAt // created_at:有發文時間則對齊發文序;否則用掃入時間並微調保序 createdAt := now - int64(i)*1000 @@ -513,7 +653,13 @@ func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, per if err != nil { return nil, err } + if p.ScoutMode == domain.ModeProvider { + return nil, fmt.Errorf("%w: provider matches do not support outreach drafts", domain.ErrValidation) + } draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。" + if p.ScoutMode == domain.ModeActivity { + draft = "嗨 @" + p.Author + ",這個「" + p.SearchTag + "」很有意思。你自己最在意哪一部分?" + } p.DraftText = draft p.OutreachStatus = domain.OutreachDrafted if err := s.Repo.SavePost(ctx, p); err != nil { @@ -539,6 +685,9 @@ func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID stri if err != nil { return nil, err } + if p.ScoutMode == domain.ModeProvider { + return nil, fmt.Errorf("%w: provider matches cannot be marked as outreach", domain.ErrValidation) + } p.OutreachStatus = domain.OutreachPublished if err := s.Repo.SavePost(ctx, p); err != nil { return nil, err @@ -565,6 +714,9 @@ func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text if err != nil { return nil, err } + if p.ScoutMode == domain.ModeProvider { + return nil, fmt.Errorf("%w: provider matches do not support outreach", domain.ErrValidation) + } text = strings.TrimSpace(text) if text == "" { text = p.DraftText diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index 428515c..5f1fccd 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -15,6 +15,8 @@ import ( appnotifRepo "apps/backend/internal/module/appnotif/repository" appnotifUC "apps/backend/internal/module/appnotif/usecase" billingModule "apps/backend/internal/module/billing" + crmRepo "apps/backend/internal/module/crm/repository" + crmUC "apps/backend/internal/module/crm/usecase" fsDomain "apps/backend/internal/module/filestorage/domain" "apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/s3store" @@ -32,8 +34,6 @@ import ( radarDomain "apps/backend/internal/module/radar/domain" radarRepo "apps/backend/internal/module/radar/repository" radarUC "apps/backend/internal/module/radar/usecase" - crmRepo "apps/backend/internal/module/crm/repository" - crmUC "apps/backend/internal/module/crm/usecase" scoutRepo "apps/backend/internal/module/scout/repository" scoutUC "apps/backend/internal/module/scout/usecase" "apps/backend/internal/module/search" @@ -858,7 +858,7 @@ func (a *scoutHitAdapter) SearchHits(ctx context.Context, ownerUID int64, terms } out := make([]radarUC.ThreadHit, 0, len(hits)) for _, h := range hits { - out = append(out, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet}) + out = append(out, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet, PublishedAt: h.PublishedAt}) } return out, path, nil } diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go index 6111e98..d663555 100644 --- a/apps/backend/internal/types/m5_convert.go +++ b/apps/backend/internal/types/m5_convert.go @@ -78,8 +78,10 @@ func ProductFromDomain(p *scoutDomain.Product) ProductPublic { } return ProductPublic{ Id: p.ID, BrandId: p.BrandID, Label: p.Label, ProductContext: p.ProductContext, - MatchTags: p.MatchTags, PainPoints: p.PainPoints, PlacementUrl: p.PlacementURL, - CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + MatchTags: p.MatchTags, PainPoints: p.PainPoints, + ProviderCapabilityTerms: p.ProviderCapabilityTerms, ProviderExcludeTerms: p.ProviderExcludeTerms, + PlacementUrl: p.PlacementURL, + CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, } } diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index 5aa2238..ac929ec 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -1592,25 +1592,29 @@ type ProductListReq struct { } type ProductPublic struct { - Id string `json:"id"` - BrandId string `json:"brand_id"` - Label string `json:"label"` - ProductContext string `json:"product_context"` - MatchTags []string `json:"match_tags"` - PainPoints []string `json:"pain_points"` - PlacementUrl string `json:"placement_url,optional"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + Id string `json:"id"` + BrandId string `json:"brand_id"` + Label string `json:"label"` + ProductContext string `json:"product_context"` + MatchTags []string `json:"match_tags"` + PainPoints []string `json:"pain_points"` + ProviderCapabilityTerms []string `json:"provider_capability_terms"` + ProviderExcludeTerms []string `json:"provider_exclude_terms"` + PlacementUrl string `json:"placement_url,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } type ProductSaveReq struct { - Id string `json:"id,optional"` - BrandId string `json:"brand_id"` - Label string `json:"label"` - ProductContext string `json:"product_context,optional"` - MatchTags []string `json:"match_tags,optional"` - PainPoints []string `json:"pain_points,optional"` - PlacementUrl string `json:"placement_url,optional"` + Id string `json:"id,optional"` + BrandId string `json:"brand_id"` + Label string `json:"label"` + ProductContext string `json:"product_context,optional"` + MatchTags []string `json:"match_tags,optional"` + PainPoints []string `json:"pain_points,optional"` + ProviderCapabilityTerms []string `json:"provider_capability_terms,optional"` + ProviderExcludeTerms []string `json:"provider_exclude_terms,optional"` + PlacementUrl string `json:"placement_url,optional"` } type PromoteScoutPostData struct { diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts index e5a6d9e..b5e40ae 100644 --- a/apps/web/src/data/live/m5Repos.ts +++ b/apps/web/src/data/live/m5Repos.ts @@ -120,6 +120,12 @@ function mapProduct(raw: Record): BrandProduct { product_context: String(raw.product_context ?? ""), match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [], pain_points: Array.isArray(raw.pain_points) ? (raw.pain_points as string[]) : [], + provider_capability_terms: Array.isArray(raw.provider_capability_terms) + ? (raw.provider_capability_terms as string[]) + : [], + provider_exclude_terms: Array.isArray(raw.provider_exclude_terms) + ? (raw.provider_exclude_terms as string[]) + : [], placement_url: raw.placement_url != null ? String(raw.placement_url) : undefined, created_at: Number(raw.created_at ?? 0), updated_at: Number(raw.updated_at ?? 0), @@ -610,6 +616,8 @@ export function createLiveScout(): ScoutRepo { product_context: product.product_context, match_tags: product.match_tags, pain_points: product.pain_points, + provider_capability_terms: product.provider_capability_terms, + provider_exclude_terms: product.provider_exclude_terms, placement_url: product.placement_url, }, }); diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts index 93309a2..6108542 100644 --- a/apps/web/src/data/repos.ts +++ b/apps/web/src/data/repos.ts @@ -513,8 +513,8 @@ export type ScoutRepo = { intent: string; brandId?: string | null; productId?: string | null; - /** value=痛點/置入(預設);activity=關鍵字活躍 */ - purpose?: "value" | "activity"; + /** demand=找需求痛點;provider=找解法提供者;activity=非推廣主題互動 */ + purpose?: "demand" | "provider" | "activity"; /** * 是否做完整上網功課(摘要+分層來源)。 * 預設 false:只組痛點/掃描詞,不擋海巡;背景再 deep 補齊。 diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts index 22db000..a3e542b 100644 --- a/apps/web/src/domain/types.ts +++ b/apps/web/src/domain/types.ts @@ -677,6 +677,10 @@ export type BrandProduct = { match_tags: string[]; /** 對方在煩什麼(找人、產關鍵字) */ pain_points: string[]; + /** 能解決產品痛點的專業/服務詞 */ + provider_capability_terms: string[]; + /** 不要列入解法媒合的同類產品/賣家詞 */ + provider_exclude_terms: string[]; /** 可分享連結(選填) */ placement_url?: string; created_at: number; @@ -691,10 +695,10 @@ export type ScoutOutreachStatus = "new" | "drafted" | "queued" | "published" | " * - theme:主題/痛點接話(可不帶產品) * - activity:關鍵字活躍(刷存在感、養帳號) */ -export type ScoutMode = "product" | "theme" | "activity"; +export type ScoutMode = "product" | "theme" | "activity" | "demand" | "provider"; /** 使用者選的探查目的(UI) */ -export type ScoutPurpose = "value" | "activity"; +export type ScoutPurpose = "demand" | "provider" | "activity"; export type ScoutPost = { id: string; diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts index 1636d57..2133d12 100644 --- a/apps/web/src/lib/i18n/messages.ts +++ b/apps/web/src/lib/i18n/messages.ts @@ -16,7 +16,7 @@ export const zhTW: MessageDict = { "nav.radar": "商機", "nav.crm": "名單", /** 手動掃場外展(vs 商機=訂閱後每天自動) */ - "nav.scout": "海巡", + "nav.scout": "探索", "nav.outbox": "發送", "nav.jobs": "任務", "nav.brands": "品牌", @@ -1392,9 +1392,11 @@ export const zhTW: MessageDict = { "common.listSep": "、", "common.dash": "—", - "scout.title": "海巡出擊", + "scout.title": "手動探索", "scout.today": "今日出擊", "scout.purposeValue": "痛點回覆", + "scout.purposeDemand": "找需求痛點", + "scout.purposeProvider": "解法媒合", "scout.purposeActivity": "活躍短回", "scout.goal": "今日目標(則)", "scout.progress": "進度 {done}/{goal}", @@ -1403,9 +1405,12 @@ export const zhTW: MessageDict = { "scout.intentPh": "例:換季頭皮刺癢、真的無香、週末有插座", "scout.keywordPh": "例:週末 咖啡 遠端", "scout.productOptional": "產品(選填)", + "scout.productRequired": "要解決的產品(必填)", + "scout.selectProduct": "選擇產品", "scout.noProduct": "不帶產品", "scout.brandFallback": "品牌", "scout.placement": "置入:{label}", + "scout.providerProduct": "產品:{label}", "scout.painPart": " · 痛點「{pain}」", "scout.noProductsBefore": "尚無產品,可到", "scout.noProductsAfter": "新增。", @@ -1460,6 +1465,8 @@ export const zhTW: MessageDict = { "scout.product": "產品", "scout.queue": "命中紀錄 · {n}", "scout.valueQueue": "痛點/產品接話 · {n}", + "scout.providerQueue": "解法提供者 · {n}", + "scout.demandQueue": "需求痛點 · {n}", "scout.activityQueue": "活躍短回 · {n}", "scout.noMatchesInQueue": "這個佇列暫無命中", "scout.collapseQueue": "收合佇列", @@ -1473,6 +1480,7 @@ export const zhTW: MessageDict = { "scout.needKeyword": "先填關鍵字", "scout.needIntent": "先寫這次要找什麼", "scout.productMissing": "所選產品不在列表中,請重新選擇", + "scout.providerSetupRequired": "解法媒合需要產品的痛點與至少一個標籤或解法能力詞;請到品牌頁補齊後再試。", "scout.defaultLabel": "海巡", "scout.newRunActivity": "新批次「{label}」· {n} 則待回", "scout.newRunValue": "新批次「{label}」· {n} 則 · 請處理「現在這一則」", @@ -1505,6 +1513,10 @@ export const zhTW: MessageDict = { "scout.confirmDeletePost": "刪除這則命中?", "scout.deletedPost": "已刪除這則命中", "scout.stanceActivity": "短回 · 養活躍", + "scout.stanceDemand": "需求痛點 · 可回應", + "scout.stanceProvider": "解法媒合 · 不推產品", + "scout.demandHint": "這是正在求助或比較解法的需求貼文。先閱讀原文,再以有幫助的方式回應。", + "scout.providerHint": "這是解法提供者候選名單。請先看原文與能力證據,再自行決定是否聯絡。", "scout.stanceProduct": "共感 · 可輕帶產品", "scout.stanceRelation": "接話 · 建關係", @@ -1553,6 +1565,10 @@ export const zhTW: MessageDict = { "brands.tags": "標籤", "brands.tagsPh": "逗號分隔", "brands.intro": "介紹", + "brands.providerCapabilities": "可解決痛點的能力/服務", + "brands.providerCapabilitiesPh": "例如:皮膚科、過敏原檢測、敏感肌諮詢", + "brands.providerExcludes": "同類型排除詞", + "brands.providerExcludesPh": "例如:洗髮精、護髮產品", "brands.link": "連結", "brands.update": "更新", "brands.createItem": "新增", @@ -2341,7 +2357,7 @@ export const en: MessageDict = { "nav.studio": "Studio", "nav.radar": "Demand", "nav.crm": "CRM", - "nav.scout": "Patrol", + "nav.scout": "Discover", "nav.outbox": "Outbox", "nav.jobs": "Jobs", "nav.brands": "Brands", @@ -3715,9 +3731,11 @@ export const en: MessageDict = { "common.listSep": ", ", "common.dash": "—", - "scout.title": "Patrol now", + "scout.title": "Manual discovery", "scout.today": "Today's sortie", "scout.purposeValue": "Pain-point replies", + "scout.purposeDemand": "Find demand pains", + "scout.purposeProvider": "Find solution providers", "scout.purposeActivity": "Activity short replies", "scout.goal": "Daily goal (posts)", "scout.progress": "Progress {done}/{goal}", @@ -3726,9 +3744,12 @@ export const en: MessageDict = { "scout.intentPh": "e.g. seasonal scalp itch, truly fragrance-free, outlets on weekends", "scout.keywordPh": "e.g. weekend coffee remote", "scout.productOptional": "Product (optional)", + "scout.productRequired": "Product to solve (required)", + "scout.selectProduct": "Select a product", "scout.noProduct": "No product", "scout.brandFallback": "Brand", "scout.placement": "Placement: {label}", + "scout.providerProduct": "Product: {label}", "scout.painPart": " · pain “{pain}”", "scout.noProductsBefore": "No products yet. Add some under", "scout.noProductsAfter": ".", @@ -3783,6 +3804,8 @@ export const en: MessageDict = { "scout.product": "Product", "scout.queue": "Matches · {n}", "scout.valueQueue": "Value replies · {n}", + "scout.providerQueue": "Solution providers · {n}", + "scout.demandQueue": "Demand pains · {n}", "scout.activityQueue": "Activity short replies · {n}", "scout.noMatchesInQueue": "No matches in this queue", "scout.collapseQueue": "Collapse queue", @@ -3796,6 +3819,7 @@ export const en: MessageDict = { "scout.needKeyword": "Enter keywords first", "scout.needIntent": "Write what you're looking for", "scout.productMissing": "Selected product is not in the list. Please reselect.", + "scout.providerSetupRequired": "Solution matching needs pain points and at least one tag or provider capability term. Complete them on the Brands page first.", "scout.defaultLabel": "Patrol", "scout.newRunActivity": "New batch “{label}” · {n} pending", "scout.newRunValue": "New batch “{label}” · {n} posts · handle “Now this post”", @@ -3828,6 +3852,10 @@ export const en: MessageDict = { "scout.confirmDeletePost": "Delete this hit?", "scout.deletedPost": "Deleted this hit", "scout.stanceActivity": "Short reply · activity", + "scout.stanceDemand": "Demand pain · replyable", + "scout.stanceProvider": "Solution matching · no product promotion", + "scout.demandHint": "These posts show people seeking help or comparing solutions. Read the original post before responding helpfully.", + "scout.providerHint": "These are solution-provider candidates. Review the original post and proof of capability before contacting them.", "scout.stanceProduct": "Empathy · soft product", "scout.stanceRelation": "Engage · build rapport", @@ -3876,6 +3904,10 @@ export const en: MessageDict = { "brands.tags": "Tags", "brands.tagsPh": "Comma-separated", "brands.intro": "Description", + "brands.providerCapabilities": "Capabilities / services that solve the pain", + "brands.providerCapabilitiesPh": "e.g. dermatology, allergen testing, sensitive-skin consultation", + "brands.providerExcludes": "Same-category exclusions", + "brands.providerExcludesPh": "e.g. shampoo, hair-care products", "brands.link": "Link", "brands.update": "Update", "brands.createItem": "Add", diff --git a/apps/web/src/pages/BrandsPage.tsx b/apps/web/src/pages/BrandsPage.tsx index 4ab74f6..25d100d 100644 --- a/apps/web/src/pages/BrandsPage.tsx +++ b/apps/web/src/pages/BrandsPage.tsx @@ -14,6 +14,8 @@ const emptyProductForm = { product_context: "", pain_points: "", match_tags: "", + provider_capability_terms: "", + provider_exclude_terms: "", placement_url: "", }; @@ -102,7 +104,9 @@ export function BrandsPage() { p.label.toLowerCase().includes(q) || p.product_context.toLowerCase().includes(q) || p.match_tags.some((t) => t.toLowerCase().includes(q)) || - p.pain_points.some((t) => t.toLowerCase().includes(q)), + p.pain_points.some((t) => t.toLowerCase().includes(q)) || + p.provider_capability_terms.some((t) => t.toLowerCase().includes(q)) || + p.provider_exclude_terms.some((t) => t.toLowerCase().includes(q)), ); }, [products, productQuery]); @@ -225,6 +229,8 @@ export function BrandsPage() { product_context: p.product_context, pain_points: p.pain_points.join("\n"), match_tags: p.match_tags.join(", "), + provider_capability_terms: p.provider_capability_terms.join(", "), + provider_exclude_terms: p.provider_exclude_terms.join(", "), placement_url: p.placement_url || "", }); setImportUrl(p.placement_url || ""); @@ -249,6 +255,8 @@ export function BrandsPage() { product_context: draft.product_context, pain_points: draft.pain_points.join("\n"), match_tags: draft.match_tags.join(", "), + provider_capability_terms: "", + provider_exclude_terms: "", placement_url: draft.placement_url, }); setImportUrl(draft.placement_url); @@ -275,6 +283,14 @@ export function BrandsPage() { .split(/[\n,,]+/) .map((s) => s.trim()) .filter(Boolean); + const provider_capability_terms = productForm.provider_capability_terms + .split(/[\n,,、]+/) + .map((s) => s.trim()) + .filter(Boolean); + const provider_exclude_terms = productForm.provider_exclude_terms + .split(/[\n,,、]+/) + .map((s) => s.trim()) + .filter(Boolean); const match_tags = productForm.match_tags .split(/[,\s,、]+/) .map((s) => s.trim()) @@ -289,6 +305,8 @@ export function BrandsPage() { product_context: ctx, pain_points, match_tags, + provider_capability_terms, + provider_exclude_terms, placement_url: productForm.placement_url.trim() || undefined, created_at: existing?.created_at || nowUnixNano(), updated_at: nowUnixNano(), @@ -643,6 +661,23 @@ export function BrandsPage() { value={productForm.label} onChange={(e) => setProductForm((f) => ({ ...f, label: e.target.value }))} /> +