add scout demand/provider modes, fix radar publishedAt timing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-08-06 14:16:49 +00:00
parent 15f42a0c27
commit ef8e1c7528
23 changed files with 694 additions and 120 deletions

View File

@ -18,22 +18,22 @@ import (
"apps/backend/internal/module/ai" "apps/backend/internal/module/ai"
appnotifRepo "apps/backend/internal/module/appnotif/repository" appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase" 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" fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store" "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" jobDomain "apps/backend/internal/module/job/domain"
jobRepo "apps/backend/internal/module/job/repository" jobRepo "apps/backend/internal/module/job/repository"
jobUC "apps/backend/internal/module/job/usecase" jobUC "apps/backend/internal/module/job/usecase"
memberDomain "apps/backend/internal/module/member/domain" memberDomain "apps/backend/internal/module/member/domain"
memberRepo "apps/backend/internal/module/member/repository" 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" radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase" 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" scoutUC "apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish" studioPublish "apps/backend/internal/module/studio/publish"
studioRepo "apps/backend/internal/module/studio/repository" 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 := 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) { 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) 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)) 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 return o, path, nil
}) })
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {

View File

@ -188,6 +188,8 @@ type (
ProductContext string `json:"product_context"` ProductContext string `json:"product_context"`
MatchTags []string `json:"match_tags"` MatchTags []string `json:"match_tags"`
PainPoints []string `json:"pain_points"` PainPoints []string `json:"pain_points"`
ProviderCapabilityTerms []string `json:"provider_capability_terms"`
ProviderExcludeTerms []string `json:"provider_exclude_terms"`
PlacementUrl string `json:"placement_url,optional"` PlacementUrl string `json:"placement_url,optional"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
@ -202,6 +204,8 @@ type (
ProductContext string `json:"product_context,optional"` ProductContext string `json:"product_context,optional"`
MatchTags []string `json:"match_tags,optional"` MatchTags []string `json:"match_tags,optional"`
PainPoints []string `json:"pain_points,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"` PlacementUrl string `json:"placement_url,optional"`
} }
ProductIdPath { ProductIdPath {

View File

@ -2,6 +2,7 @@ package scout
import ( import (
"context" "context"
"fmt"
"apps/backend/internal/middleware" "apps/backend/internal/middleware"
"apps/backend/internal/response" "apps/backend/internal/response"
@ -33,12 +34,15 @@ func (l *PromoteScoutPostLogic) PromoteScoutPost(req *types.ScoutPostIdPath) (*t
if err != nil { if err != nil {
return nil, err 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 externalID := p.ExternalID
if externalID == "" { if externalID == "" {
externalID = p.ID externalID = p.ID
} }
o, err := l.svcCtx.Radar.PromoteFromScout( 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 { if err != nil {
return nil, err return nil, err

View File

@ -34,7 +34,9 @@ func (l *SaveProductLogic) SaveProduct(req *types.ProductSaveReq) (*types.Produc
p, err := l.svcCtx.Scout.SaveProduct(l.ctx, uid, &scoutDomain.Product{ p, err := l.svcCtx.Scout.SaveProduct(l.ctx, uid, &scoutDomain.Product{
ID: req.Id, BrandID: req.BrandId, Label: req.Label, ProductContext: req.ProductContext, 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 { if err != nil {
return nil, err return nil, err

View File

@ -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) { func TestValidateReasons_RequiresAllFive(t *testing.T) {
full := fiveReasons() full := fiveReasons()
if err := ValidateReasons(full); err != nil { if err := ValidateReasons(full); err != nil {

View File

@ -16,6 +16,9 @@ const MaxFreshnessDays = 14
// FreshnessScore maps age in hours to the 015 freshness dimension score. // FreshnessScore maps age in hours to the 015 freshness dimension score.
func FreshnessScore(hours int) int { func FreshnessScore(hours int) int {
if hours > MaxFreshnessDays*24 {
return 0
}
if hours < 0 { if hours < 0 {
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. // IsStaleHardReject is true when the post is older than 14 days.
func IsStaleHardReject(postedAt, now int64) bool { func IsStaleHardReject(postedAt, now int64) bool {
if postedAt <= 0 {
return false
}
return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24 return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24
} }

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/domain"
usageDomain "apps/backend/internal/module/usage/domain" usageDomain "apps/backend/internal/module/usage/domain"
@ -15,6 +14,7 @@ type ThreadHit struct {
URL string URL string
Title string Title string
Snippet string Snippet string
PublishedAt int64
} }
// ThreadSearcher is the API-path search (Exa etc.). // 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 exclude[strings.ToLower(strings.TrimSpace(e))] = true
} }
now := domain.NowNano()
out := make([]*domain.CandidatePost, 0, len(hits)) out := make([]*domain.CandidatePost, 0, len(hits))
for _, h := range hits { for _, h := range hits {
text := strings.TrimSpace(h.Snippet) text := strings.TrimSpace(h.Snippet)
@ -135,7 +134,7 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
AuthorHandle: authorFromURL(permalink), AuthorHandle: authorFromURL(permalink),
Text: text, Text: text,
Title: h.Title, Title: h.Title,
PostedAt: now - int64(time.Hour), // unknown recency → treat as ~1h (not hard-reject) PostedAt: h.PublishedAt,
MatchedTerm: term, MatchedTerm: term,
Classification: class, Classification: class,
}) })

View File

@ -29,6 +29,8 @@ const (
ModeProduct = "product" ModeProduct = "product"
ModeTheme = "theme" ModeTheme = "theme"
ModeActivity = "activity" ModeActivity = "activity"
ModeDemand = "demand"
ModeProvider = "provider"
ClassificationSeekingHelp = "seeking_help" ClassificationSeekingHelp = "seeking_help"
ClassificationSeekingRecommendation = "seeking_recommendation" ClassificationSeekingRecommendation = "seeking_recommendation"
@ -37,6 +39,8 @@ const (
ClassificationAsking = "asking" ClassificationAsking = "asking"
ClassificationAnnouncement = "announcement" ClassificationAnnouncement = "announcement"
ClassificationNoise = "noise" ClassificationNoise = "noise"
ClassificationProviderDirect = "provider_direct"
ClassificationProviderRecommended = "provider_recommended"
) )
type Brand struct { type Brand struct {
@ -63,6 +67,8 @@ type Product struct {
ProductContext string `bson:"product_context" json:"product_context"` ProductContext string `bson:"product_context" json:"product_context"`
MatchTags []string `bson:"match_tags" json:"match_tags"` MatchTags []string `bson:"match_tags" json:"match_tags"`
PainPoints []string `bson:"pain_points" json:"pain_points"` 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"` PlacementURL string `bson:"placement_url,omitempty" json:"placement_url,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"` CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"`

View File

@ -99,6 +99,12 @@ func (s *MemoryStore) SaveProduct(_ context.Context, p *domain.Product) error {
if p.PainPoints != nil { if p.PainPoints != nil {
cp.PainPoints = append([]string(nil), p.PainPoints...) 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 s.products[p.ID] = &cp
return nil return nil
} }
@ -143,6 +149,12 @@ func copyProduct(p *domain.Product) *domain.Product {
if p.PainPoints != nil { if p.PainPoints != nil {
cp.PainPoints = append([]string(nil), p.PainPoints...) 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 return &cp
} }

View File

@ -27,6 +27,12 @@ func planScanTerms(brief *domain.RunBrief) []string {
if brief.Mode == domain.ModeActivity { if brief.Mode == domain.ModeActivity {
return capTerms(dedupeTerms([]string{brief.Intent}, tokenizeIntent(brief.Intent)), 6) 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) pains := filterSeekablePains(brief.Pains)
tags := filterSeekablePains(brief.Tags) tags := filterSeekablePains(brief.Tags)
@ -58,6 +64,55 @@ func planScanTerms(brief *domain.RunBrief) []string {
return capTerms(dedupeTerms(queries), 8) 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. // filterSeekablePains drops marketing slogans / long product blurbs that pull competitor posts.
func filterSeekablePains(in []string) []string { func filterSeekablePains(in []string) []string {
var out []string var out []string
@ -161,6 +216,55 @@ func classifyPost(mode, text string, terms []string) classifiedPost {
return scored(domain.ClassificationDiscussion, 40, signals, "discussion 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 { func classifyActivity(text string, signals []string) classifiedPost {
if hasAny(text, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") { if hasAny(text, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "團購", "業配") {
return scored(domain.ClassificationProviderOffer, 35, signals, "provider-offer signal; recency unavailable (neutral)") 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 { func matchedSignals(text string, terms []string) []string {
var signals []string var signals []string
text = normalizeSignal(text)
for _, term := range dedupeTerms(terms) { 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) signals = append(signals, term)
} }
} }
return signals 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 { func hasAny(text string, signals ...string) bool {
for _, signal := range signals { for _, signal := range signals {
if strings.Contains(text, signal) { if strings.Contains(text, signal) {

View File

@ -115,6 +115,132 @@ func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) {
require.True(t, classes[domain.ClassificationDiscussion]) 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) { func TestDraftOutreachIsDeterministicAndDoesNotNeedAI(t *testing.T) {
svc := usecase.New(repository.NewMemory()) svc := usecase.New(repository.NewMemory())
svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{ svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{

View File

@ -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) { func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) {
_ = deep _ = deep
intent = strings.TrimSpace(intent) intent = strings.TrimSpace(intent)
if intent == "" { if intent == "" && purpose != "provider" && purpose != "demand" {
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation) return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
} }
mode := domain.ModeTheme mode := domain.ModeTheme
@ -236,6 +236,9 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
} }
if productID != "" { if productID != "" {
p, err := s.GetProduct(ctx, ownerUID, productID) p, err := s.GetProduct(ctx, ownerUID, productID)
if err != nil && purpose == "provider" {
return nil, err
}
if err == nil { if err == nil {
mode = domain.ModeProduct mode = domain.ModeProduct
if purpose == "activity" { if purpose == "activity" {
@ -250,6 +253,51 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
brief.PlacementNote = "軟性經驗分享,避免硬廣" 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 { if len(brief.Pains) == 0 {
// 預設用「求助/求推」語感,避免把產品賣點當搜尋詞 // 預設用「求助/求推」語感,避免把產品賣點當搜尋詞
brief.Pains = []string{intent} brief.Pains = []string{intent}
@ -266,6 +314,52 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
return brief, nil 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. // 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). // 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 { if len(terms) == 0 {
return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation) 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 path := domain.PathAPI
devMode := false devMode := false
if s.Settings != nil { 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) 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. // 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) { func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func(context.Context, string, int) ([]ThreadSearchResult, error)) ([]ThreadSearchResult, error) {
if perQuery < 1 { if perQuery < 1 {
@ -414,6 +545,12 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
term = matchingTerm(text+" "+hit.Title, brief.ScanTerms) term = matchingTerm(text+" "+hit.Title, brief.ScanTerms)
} }
classified := classifyPost(brief.Mode, 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 { if classified.classification == domain.ClassificationNoise {
continue continue
} }
@ -422,6 +559,9 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
classified.classification == domain.ClassificationProviderOffer { classified.classification == domain.ClassificationProviderOffer {
continue continue
} }
if brief.Mode == domain.ModeProvider && (classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) {
continue
}
postedAt := hit.PublishedAt postedAt := hit.PublishedAt
// created_at有發文時間則對齊發文序否則用掃入時間並微調保序 // created_at有發文時間則對齊發文序否則用掃入時間並微調保序
createdAt := now - int64(i)*1000 createdAt := now - int64(i)*1000
@ -513,7 +653,13 @@ func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, per
if err != nil { if err != nil {
return nil, err 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 + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。" draft := "嗨 @" + p.Author + ",看到你提到「" + p.SearchTag + "」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。"
if p.ScoutMode == domain.ModeActivity {
draft = "嗨 @" + p.Author + ",這個「" + p.SearchTag + "」很有意思。你自己最在意哪一部分?"
}
p.DraftText = draft p.DraftText = draft
p.OutreachStatus = domain.OutreachDrafted p.OutreachStatus = domain.OutreachDrafted
if err := s.Repo.SavePost(ctx, p); err != nil { 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 { if err != nil {
return nil, err 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 p.OutreachStatus = domain.OutreachPublished
if err := s.Repo.SavePost(ctx, p); err != nil { if err := s.Repo.SavePost(ctx, p); err != nil {
return nil, err return nil, err
@ -565,6 +714,9 @@ func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text
if err != nil { if err != nil {
return nil, err 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) text = strings.TrimSpace(text)
if text == "" { if text == "" {
text = p.DraftText text = p.DraftText

View File

@ -15,6 +15,8 @@ import (
appnotifRepo "apps/backend/internal/module/appnotif/repository" appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase" appnotifUC "apps/backend/internal/module/appnotif/usecase"
billingModule "apps/backend/internal/module/billing" 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" fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop" "apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store" "apps/backend/internal/module/filestorage/s3store"
@ -32,8 +34,6 @@ import (
radarDomain "apps/backend/internal/module/radar/domain" radarDomain "apps/backend/internal/module/radar/domain"
radarRepo "apps/backend/internal/module/radar/repository" radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase" 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" scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase" scoutUC "apps/backend/internal/module/scout/usecase"
"apps/backend/internal/module/search" "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)) out := make([]radarUC.ThreadHit, 0, len(hits))
for _, h := range 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 return out, path, nil
} }

View File

@ -78,7 +78,9 @@ func ProductFromDomain(p *scoutDomain.Product) ProductPublic {
} }
return ProductPublic{ return ProductPublic{
Id: p.ID, BrandId: p.BrandID, Label: p.Label, ProductContext: p.ProductContext, Id: p.ID, BrandId: p.BrandID, Label: p.Label, ProductContext: p.ProductContext,
MatchTags: p.MatchTags, PainPoints: p.PainPoints, PlacementUrl: p.PlacementURL, MatchTags: p.MatchTags, PainPoints: p.PainPoints,
ProviderCapabilityTerms: p.ProviderCapabilityTerms, ProviderExcludeTerms: p.ProviderExcludeTerms,
PlacementUrl: p.PlacementURL,
CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
} }
} }

View File

@ -1598,6 +1598,8 @@ type ProductPublic struct {
ProductContext string `json:"product_context"` ProductContext string `json:"product_context"`
MatchTags []string `json:"match_tags"` MatchTags []string `json:"match_tags"`
PainPoints []string `json:"pain_points"` PainPoints []string `json:"pain_points"`
ProviderCapabilityTerms []string `json:"provider_capability_terms"`
ProviderExcludeTerms []string `json:"provider_exclude_terms"`
PlacementUrl string `json:"placement_url,optional"` PlacementUrl string `json:"placement_url,optional"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
@ -1610,6 +1612,8 @@ type ProductSaveReq struct {
ProductContext string `json:"product_context,optional"` ProductContext string `json:"product_context,optional"`
MatchTags []string `json:"match_tags,optional"` MatchTags []string `json:"match_tags,optional"`
PainPoints []string `json:"pain_points,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"` PlacementUrl string `json:"placement_url,optional"`
} }

View File

@ -120,6 +120,12 @@ function mapProduct(raw: Record<string, unknown>): BrandProduct {
product_context: String(raw.product_context ?? ""), product_context: String(raw.product_context ?? ""),
match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [], match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [],
pain_points: Array.isArray(raw.pain_points) ? (raw.pain_points 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, placement_url: raw.placement_url != null ? String(raw.placement_url) : undefined,
created_at: Number(raw.created_at ?? 0), created_at: Number(raw.created_at ?? 0),
updated_at: Number(raw.updated_at ?? 0), updated_at: Number(raw.updated_at ?? 0),
@ -610,6 +616,8 @@ export function createLiveScout(): ScoutRepo {
product_context: product.product_context, product_context: product.product_context,
match_tags: product.match_tags, match_tags: product.match_tags,
pain_points: product.pain_points, pain_points: product.pain_points,
provider_capability_terms: product.provider_capability_terms,
provider_exclude_terms: product.provider_exclude_terms,
placement_url: product.placement_url, placement_url: product.placement_url,
}, },
}); });

View File

@ -513,8 +513,8 @@ export type ScoutRepo = {
intent: string; intent: string;
brandId?: string | null; brandId?: string | null;
productId?: string | null; productId?: string | null;
/** value痛點置入預設activity關鍵字活躍 */ /** demand找需求痛點provider找解法提供者activity非推廣主題互動 */
purpose?: "value" | "activity"; purpose?: "demand" | "provider" | "activity";
/** /**
* *
* false deep * false deep

View File

@ -677,6 +677,10 @@ export type BrandProduct = {
match_tags: string[]; match_tags: string[];
/** 對方在煩什麼(找人、產關鍵字) */ /** 對方在煩什麼(找人、產關鍵字) */
pain_points: string[]; pain_points: string[];
/** 能解決產品痛點的專業/服務詞 */
provider_capability_terms: string[];
/** 不要列入解法媒合的同類產品/賣家詞 */
provider_exclude_terms: string[];
/** 可分享連結(選填) */ /** 可分享連結(選填) */
placement_url?: string; placement_url?: string;
created_at: number; created_at: number;
@ -691,10 +695,10 @@ export type ScoutOutreachStatus = "new" | "drafted" | "queued" | "published" | "
* - theme * - theme
* - activity * - activity
*/ */
export type ScoutMode = "product" | "theme" | "activity"; export type ScoutMode = "product" | "theme" | "activity" | "demand" | "provider";
/** 使用者選的探查目的UI */ /** 使用者選的探查目的UI */
export type ScoutPurpose = "value" | "activity"; export type ScoutPurpose = "demand" | "provider" | "activity";
export type ScoutPost = { export type ScoutPost = {
id: string; id: string;

View File

@ -16,7 +16,7 @@ export const zhTW: MessageDict = {
"nav.radar": "商機", "nav.radar": "商機",
"nav.crm": "名單", "nav.crm": "名單",
/** 手動掃場外展vs 商機=訂閱後每天自動) */ /** 手動掃場外展vs 商機=訂閱後每天自動) */
"nav.scout": "海巡", "nav.scout": "探索",
"nav.outbox": "發送", "nav.outbox": "發送",
"nav.jobs": "任務", "nav.jobs": "任務",
"nav.brands": "品牌", "nav.brands": "品牌",
@ -1392,9 +1392,11 @@ export const zhTW: MessageDict = {
"common.listSep": "、", "common.listSep": "、",
"common.dash": "—", "common.dash": "—",
"scout.title": "海巡出擊", "scout.title": "手動探索",
"scout.today": "今日出擊", "scout.today": "今日出擊",
"scout.purposeValue": "痛點回覆", "scout.purposeValue": "痛點回覆",
"scout.purposeDemand": "找需求痛點",
"scout.purposeProvider": "解法媒合",
"scout.purposeActivity": "活躍短回", "scout.purposeActivity": "活躍短回",
"scout.goal": "今日目標(則)", "scout.goal": "今日目標(則)",
"scout.progress": "進度 {done}/{goal}", "scout.progress": "進度 {done}/{goal}",
@ -1403,9 +1405,12 @@ export const zhTW: MessageDict = {
"scout.intentPh": "例:換季頭皮刺癢、真的無香、週末有插座", "scout.intentPh": "例:換季頭皮刺癢、真的無香、週末有插座",
"scout.keywordPh": "例:週末 咖啡 遠端", "scout.keywordPh": "例:週末 咖啡 遠端",
"scout.productOptional": "產品(選填)", "scout.productOptional": "產品(選填)",
"scout.productRequired": "要解決的產品(必填)",
"scout.selectProduct": "選擇產品",
"scout.noProduct": "不帶產品", "scout.noProduct": "不帶產品",
"scout.brandFallback": "品牌", "scout.brandFallback": "品牌",
"scout.placement": "置入:{label}", "scout.placement": "置入:{label}",
"scout.providerProduct": "產品:{label}",
"scout.painPart": " · 痛點「{pain}」", "scout.painPart": " · 痛點「{pain}」",
"scout.noProductsBefore": "尚無產品,可到", "scout.noProductsBefore": "尚無產品,可到",
"scout.noProductsAfter": "新增。", "scout.noProductsAfter": "新增。",
@ -1460,6 +1465,8 @@ export const zhTW: MessageDict = {
"scout.product": "產品", "scout.product": "產品",
"scout.queue": "命中紀錄 · {n}", "scout.queue": "命中紀錄 · {n}",
"scout.valueQueue": "痛點/產品接話 · {n}", "scout.valueQueue": "痛點/產品接話 · {n}",
"scout.providerQueue": "解法提供者 · {n}",
"scout.demandQueue": "需求痛點 · {n}",
"scout.activityQueue": "活躍短回 · {n}", "scout.activityQueue": "活躍短回 · {n}",
"scout.noMatchesInQueue": "這個佇列暫無命中", "scout.noMatchesInQueue": "這個佇列暫無命中",
"scout.collapseQueue": "收合佇列", "scout.collapseQueue": "收合佇列",
@ -1473,6 +1480,7 @@ export const zhTW: MessageDict = {
"scout.needKeyword": "先填關鍵字", "scout.needKeyword": "先填關鍵字",
"scout.needIntent": "先寫這次要找什麼", "scout.needIntent": "先寫這次要找什麼",
"scout.productMissing": "所選產品不在列表中,請重新選擇", "scout.productMissing": "所選產品不在列表中,請重新選擇",
"scout.providerSetupRequired": "解法媒合需要產品的痛點與至少一個標籤或解法能力詞;請到品牌頁補齊後再試。",
"scout.defaultLabel": "海巡", "scout.defaultLabel": "海巡",
"scout.newRunActivity": "新批次「{label}」· {n} 則待回", "scout.newRunActivity": "新批次「{label}」· {n} 則待回",
"scout.newRunValue": "新批次「{label}」· {n} 則 · 請處理「現在這一則」", "scout.newRunValue": "新批次「{label}」· {n} 則 · 請處理「現在這一則」",
@ -1505,6 +1513,10 @@ export const zhTW: MessageDict = {
"scout.confirmDeletePost": "刪除這則命中?", "scout.confirmDeletePost": "刪除這則命中?",
"scout.deletedPost": "已刪除這則命中", "scout.deletedPost": "已刪除這則命中",
"scout.stanceActivity": "短回 · 養活躍", "scout.stanceActivity": "短回 · 養活躍",
"scout.stanceDemand": "需求痛點 · 可回應",
"scout.stanceProvider": "解法媒合 · 不推產品",
"scout.demandHint": "這是正在求助或比較解法的需求貼文。先閱讀原文,再以有幫助的方式回應。",
"scout.providerHint": "這是解法提供者候選名單。請先看原文與能力證據,再自行決定是否聯絡。",
"scout.stanceProduct": "共感 · 可輕帶產品", "scout.stanceProduct": "共感 · 可輕帶產品",
"scout.stanceRelation": "接話 · 建關係", "scout.stanceRelation": "接話 · 建關係",
@ -1553,6 +1565,10 @@ export const zhTW: MessageDict = {
"brands.tags": "標籤", "brands.tags": "標籤",
"brands.tagsPh": "逗號分隔", "brands.tagsPh": "逗號分隔",
"brands.intro": "介紹", "brands.intro": "介紹",
"brands.providerCapabilities": "可解決痛點的能力/服務",
"brands.providerCapabilitiesPh": "例如:皮膚科、過敏原檢測、敏感肌諮詢",
"brands.providerExcludes": "同類型排除詞",
"brands.providerExcludesPh": "例如:洗髮精、護髮產品",
"brands.link": "連結", "brands.link": "連結",
"brands.update": "更新", "brands.update": "更新",
"brands.createItem": "新增", "brands.createItem": "新增",
@ -2341,7 +2357,7 @@ export const en: MessageDict = {
"nav.studio": "Studio", "nav.studio": "Studio",
"nav.radar": "Demand", "nav.radar": "Demand",
"nav.crm": "CRM", "nav.crm": "CRM",
"nav.scout": "Patrol", "nav.scout": "Discover",
"nav.outbox": "Outbox", "nav.outbox": "Outbox",
"nav.jobs": "Jobs", "nav.jobs": "Jobs",
"nav.brands": "Brands", "nav.brands": "Brands",
@ -3715,9 +3731,11 @@ export const en: MessageDict = {
"common.listSep": ", ", "common.listSep": ", ",
"common.dash": "—", "common.dash": "—",
"scout.title": "Patrol now", "scout.title": "Manual discovery",
"scout.today": "Today's sortie", "scout.today": "Today's sortie",
"scout.purposeValue": "Pain-point replies", "scout.purposeValue": "Pain-point replies",
"scout.purposeDemand": "Find demand pains",
"scout.purposeProvider": "Find solution providers",
"scout.purposeActivity": "Activity short replies", "scout.purposeActivity": "Activity short replies",
"scout.goal": "Daily goal (posts)", "scout.goal": "Daily goal (posts)",
"scout.progress": "Progress {done}/{goal}", "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.intentPh": "e.g. seasonal scalp itch, truly fragrance-free, outlets on weekends",
"scout.keywordPh": "e.g. weekend coffee remote", "scout.keywordPh": "e.g. weekend coffee remote",
"scout.productOptional": "Product (optional)", "scout.productOptional": "Product (optional)",
"scout.productRequired": "Product to solve (required)",
"scout.selectProduct": "Select a product",
"scout.noProduct": "No product", "scout.noProduct": "No product",
"scout.brandFallback": "Brand", "scout.brandFallback": "Brand",
"scout.placement": "Placement: {label}", "scout.placement": "Placement: {label}",
"scout.providerProduct": "Product: {label}",
"scout.painPart": " · pain “{pain}”", "scout.painPart": " · pain “{pain}”",
"scout.noProductsBefore": "No products yet. Add some under", "scout.noProductsBefore": "No products yet. Add some under",
"scout.noProductsAfter": ".", "scout.noProductsAfter": ".",
@ -3783,6 +3804,8 @@ export const en: MessageDict = {
"scout.product": "Product", "scout.product": "Product",
"scout.queue": "Matches · {n}", "scout.queue": "Matches · {n}",
"scout.valueQueue": "Value replies · {n}", "scout.valueQueue": "Value replies · {n}",
"scout.providerQueue": "Solution providers · {n}",
"scout.demandQueue": "Demand pains · {n}",
"scout.activityQueue": "Activity short replies · {n}", "scout.activityQueue": "Activity short replies · {n}",
"scout.noMatchesInQueue": "No matches in this queue", "scout.noMatchesInQueue": "No matches in this queue",
"scout.collapseQueue": "Collapse queue", "scout.collapseQueue": "Collapse queue",
@ -3796,6 +3819,7 @@ export const en: MessageDict = {
"scout.needKeyword": "Enter keywords first", "scout.needKeyword": "Enter keywords first",
"scout.needIntent": "Write what you're looking for", "scout.needIntent": "Write what you're looking for",
"scout.productMissing": "Selected product is not in the list. Please reselect.", "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.defaultLabel": "Patrol",
"scout.newRunActivity": "New batch “{label}” · {n} pending", "scout.newRunActivity": "New batch “{label}” · {n} pending",
"scout.newRunValue": "New batch “{label}” · {n} posts · handle “Now this post”", "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.confirmDeletePost": "Delete this hit?",
"scout.deletedPost": "Deleted this hit", "scout.deletedPost": "Deleted this hit",
"scout.stanceActivity": "Short reply · activity", "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.stanceProduct": "Empathy · soft product",
"scout.stanceRelation": "Engage · build rapport", "scout.stanceRelation": "Engage · build rapport",
@ -3876,6 +3904,10 @@ export const en: MessageDict = {
"brands.tags": "Tags", "brands.tags": "Tags",
"brands.tagsPh": "Comma-separated", "brands.tagsPh": "Comma-separated",
"brands.intro": "Description", "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.link": "Link",
"brands.update": "Update", "brands.update": "Update",
"brands.createItem": "Add", "brands.createItem": "Add",

View File

@ -14,6 +14,8 @@ const emptyProductForm = {
product_context: "", product_context: "",
pain_points: "", pain_points: "",
match_tags: "", match_tags: "",
provider_capability_terms: "",
provider_exclude_terms: "",
placement_url: "", placement_url: "",
}; };
@ -102,7 +104,9 @@ export function BrandsPage() {
p.label.toLowerCase().includes(q) || p.label.toLowerCase().includes(q) ||
p.product_context.toLowerCase().includes(q) || p.product_context.toLowerCase().includes(q) ||
p.match_tags.some((t) => t.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]); }, [products, productQuery]);
@ -225,6 +229,8 @@ export function BrandsPage() {
product_context: p.product_context, product_context: p.product_context,
pain_points: p.pain_points.join("\n"), pain_points: p.pain_points.join("\n"),
match_tags: p.match_tags.join(", "), 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 || "", placement_url: p.placement_url || "",
}); });
setImportUrl(p.placement_url || ""); setImportUrl(p.placement_url || "");
@ -249,6 +255,8 @@ export function BrandsPage() {
product_context: draft.product_context, product_context: draft.product_context,
pain_points: draft.pain_points.join("\n"), pain_points: draft.pain_points.join("\n"),
match_tags: draft.match_tags.join(", "), match_tags: draft.match_tags.join(", "),
provider_capability_terms: "",
provider_exclude_terms: "",
placement_url: draft.placement_url, placement_url: draft.placement_url,
}); });
setImportUrl(draft.placement_url); setImportUrl(draft.placement_url);
@ -275,6 +283,14 @@ export function BrandsPage() {
.split(/[\n,]+/) .split(/[\n,]+/)
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .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 const match_tags = productForm.match_tags
.split(/[,\s、]+/) .split(/[,\s、]+/)
.map((s) => s.trim()) .map((s) => s.trim())
@ -289,6 +305,8 @@ export function BrandsPage() {
product_context: ctx, product_context: ctx,
pain_points, pain_points,
match_tags, match_tags,
provider_capability_terms,
provider_exclude_terms,
placement_url: productForm.placement_url.trim() || undefined, placement_url: productForm.placement_url.trim() || undefined,
created_at: existing?.created_at || nowUnixNano(), created_at: existing?.created_at || nowUnixNano(),
updated_at: nowUnixNano(), updated_at: nowUnixNano(),
@ -643,6 +661,23 @@ export function BrandsPage() {
value={productForm.label} value={productForm.label}
onChange={(e) => setProductForm((f) => ({ ...f, label: e.target.value }))} onChange={(e) => setProductForm((f) => ({ ...f, label: e.target.value }))}
/> />
<Textarea
label={t("brands.providerCapabilities")}
value={productForm.provider_capability_terms}
onChange={(e) =>
setProductForm((f) => ({ ...f, provider_capability_terms: e.target.value }))
}
placeholder={t("brands.providerCapabilitiesPh")}
rows={2}
/>
<Input
label={t("brands.providerExcludes")}
value={productForm.provider_exclude_terms}
onChange={(e) =>
setProductForm((f) => ({ ...f, provider_exclude_terms: e.target.value }))
}
placeholder={t("brands.providerExcludesPh")}
/>
<Textarea <Textarea
label={t("brands.pains")} label={t("brands.pains")}
value={productForm.pain_points} value={productForm.pain_points}

View File

@ -26,6 +26,10 @@ function isPending(p: ScoutPost): boolean {
return p.outreach_status === "new" || p.outreach_status === "drafted"; return p.outreach_status === "new" || p.outreach_status === "drafted";
} }
function isProviderReady(product: BrandProduct | null): boolean {
return product !== null;
}
function outreachStatusLabel( function outreachStatusLabel(
p: ScoutPost, p: ScoutPost,
t: (k: string, p?: Record<string, string | number>) => string, t: (k: string, p?: Record<string, string | number>) => string,
@ -83,6 +87,8 @@ function MatchQueue({
function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string { function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string {
if (p.scout_mode === "activity") return t("scout.stanceActivity"); if (p.scout_mode === "activity") return t("scout.stanceActivity");
if (p.scout_mode === "demand") return t("scout.stanceDemand");
if (p.scout_mode === "provider") return t("scout.stanceProvider");
if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct"); if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct");
return t("scout.stanceRelation"); return t("scout.stanceRelation");
} }
@ -146,7 +152,7 @@ export function ScoutPage() {
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]); const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
const [posts, setPosts] = useState<ScoutPost[]>([]); const [posts, setPosts] = useState<ScoutPost[]>([]);
const [purpose, setPurpose] = useState<ScoutPurpose>("value"); const [purpose, setPurpose] = useState<ScoutPurpose>("demand");
const [intent, setIntent] = useState(""); const [intent, setIntent] = useState("");
const [productId, setProductId] = useState(""); const [productId, setProductId] = useState("");
@ -194,6 +200,7 @@ export function ScoutPage() {
setBrands(b); setBrands(b);
setHomeworkList(hw); setHomeworkList(hw);
setAllProducts(prods); setAllProducts(prods);
setProductId((current) => current || prods[0]?.id || "");
setPosts(postList); setPosts(postList);
const pending = postList.filter(isPending).sort(compareQueuePosts); const pending = postList.filter(isPending).sort(compareQueuePosts);
@ -273,7 +280,8 @@ export function ScoutPage() {
}, [posts, activeRunKey]); }, [posts, activeRunKey]);
const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]); const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]);
const valueQueue = useMemo(() => runQueue.filter((p) => p.scout_mode !== "activity"), [runQueue]); const demandQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "demand"), [runQueue]);
const providerQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "provider"), [runQueue]);
const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]); const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]);
const current = useMemo( const current = useMemo(
@ -431,7 +439,7 @@ export function ScoutPage() {
/** ① 產出可審關鍵字(不搜尋) */ /** ① 產出可審關鍵字(不搜尋) */
async function planKeywords() { async function planKeywords() {
const text = intent.trim(); const text = intent.trim();
if (!text) { if (purpose === "activity" && !text) {
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent"));
return; return;
} }
@ -441,11 +449,18 @@ export function ScoutPage() {
try { try {
const freshProducts = await repos.scout.listAllProducts(); const freshProducts = await repos.scout.listAllProducts();
setAllProducts(freshProducts); setAllProducts(freshProducts);
const selectedProductID = productId || freshProducts[0]?.id || "";
if (purpose !== "activity" && selectedProductID) {
setProductId(selectedProductID);
}
const selected = const selected =
purpose === "activity" ? null : freshProducts.find((p) => p.id === productId) || null; purpose === "activity" ? null : freshProducts.find((p) => p.id === selectedProductID) || null;
if (purpose === "value" && productId && !selected) { if (purpose !== "activity" && !selected) {
throw new Error(t("scout.productMissing")); throw new Error(t("scout.productMissing"));
} }
if (purpose === "provider" && !isProviderReady(selected)) {
throw new Error(t("scout.providerSetupRequired"));
}
const brief = await repos.scout.prepareBrief({ const brief = await repos.scout.prepareBrief({
intent: text, intent: text,
brandId: purpose === "activity" ? null : selected?.brand_id || null, brandId: purpose === "activity" ? null : selected?.brand_id || null,
@ -492,10 +507,21 @@ export function ScoutPage() {
/** ② 確認關鍵字後才 enqueue scan */ /** ② 確認關鍵字後才 enqueue scan */
async function confirmScan() { async function confirmScan() {
const text = intent.trim(); const text = intent.trim();
if (!text) { if (purpose === "activity" && !text) {
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent"));
return; return;
} }
if (purpose !== "activity" && !productId) {
setMessage(t("scout.productMissing"));
return;
}
if (
purpose === "provider" &&
!isProviderReady(allProducts.find((p) => p.id === productId) || null)
) {
setMessage(t("scout.providerSetupRequired"));
return;
}
const terms = workshopTerms.map((s) => s.trim()).filter(Boolean); const terms = workshopTerms.map((s) => s.trim()).filter(Boolean);
if (!terms.length) { if (!terms.length) {
setMessage(t("scout.workshopEmpty")); setMessage(t("scout.workshopEmpty"));
@ -727,14 +753,27 @@ export function ScoutPage() {
<div className="hb-tabs hb-tabs--sm" role="tablist"> <div className="hb-tabs hb-tabs--sm" role="tablist">
<button <button
type="button" type="button"
className={`hb-tab ${purpose === "value" ? "is-active" : ""}`} className={`hb-tab ${purpose === "demand" ? "is-active" : ""}`}
onClick={() => { onClick={() => {
setPurpose("value"); setPurpose("demand");
setProductId((current) => current || allProducts[0]?.id || "");
clearWorkshop();
const t = loadScoutToday(); const t = loadScoutToday();
setGoal(t.goalValue); setGoal(t.goalValue);
}} }}
> >
{t("scout.purposeValue")} {t("scout.purposeDemand")}
</button>
<button
type="button"
className={`hb-tab ${purpose === "provider" ? "is-active" : ""}`}
onClick={() => {
setPurpose("provider");
setProductId((current) => current || allProducts[0]?.id || "");
clearWorkshop();
}}
>
{t("scout.purposeProvider")}
</button> </button>
<button <button
type="button" type="button"
@ -742,6 +781,7 @@ export function ScoutPage() {
onClick={() => { onClick={() => {
setPurpose("activity"); setPurpose("activity");
setProductId(""); setProductId("");
clearWorkshop();
const t = loadScoutToday(); const t = loadScoutToday();
setGoal(t.goalActivity); setGoal(t.goalActivity);
}} }}
@ -769,28 +809,26 @@ export function ScoutPage() {
</div> </div>
</div> </div>
{purpose === "activity" ? (
<Textarea <Textarea
label={purpose === "activity" ? t("scout.keyword") : t("scout.intent")} label={t("scout.keyword")}
value={intent} value={intent}
onChange={(e) => setIntent(e.target.value)} onChange={(e) => setIntent(e.target.value)}
rows={purpose === "activity" ? 2 : 3} rows={2}
placeholder={ placeholder={t("scout.keywordPh")}
purpose === "activity"
? t("scout.keywordPh")
: t("scout.intentPh")
}
/> />
) : null}
{purpose === "value" ? ( {purpose !== "activity" ? (
<> <>
<Select <Select
label={t("scout.productOptional")} label={t("scout.productRequired")}
value={ value={
productId && productOptions.some((o) => o.id === productId) ? productId : "" productId && productOptions.some((o) => o.id === productId) ? productId : ""
} }
onChange={(e) => setProductId(e.target.value)} onChange={(e) => setProductId(e.target.value)}
> >
<option value="">{t("scout.noProduct")}</option> <option value="">{t("scout.selectProduct")}</option>
{productOptions.map((o) => ( {productOptions.map((o) => (
<option key={o.id} value={o.id}> <option key={o.id} value={o.id}>
{o.label} {o.label}
@ -799,11 +837,16 @@ export function ScoutPage() {
</Select> </Select>
{selectedProduct ? ( {selectedProduct ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("scout.placement", { label: selectedProduct.label })} {t("scout.providerProduct", { label: selectedProduct.label })}
{selectedProduct.pain_points?.[0] {selectedProduct.pain_points?.[0]
? t("scout.painPart", { pain: selectedProduct.pain_points[0] }) ? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
: ""} : ""}
</p> </p>
) : null}
{purpose === "provider" && selectedProduct && !isProviderReady(selectedProduct) ? (
<p className="hb-banner-ok" role="alert">
{t("scout.providerSetupRequired")} <Link to="/app/brands">{t("nav.brands")}</Link>
</p>
) : null} ) : null}
{allProducts.length === 0 ? ( {allProducts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
@ -819,7 +862,7 @@ export function ScoutPage() {
<Button <Button
type="button" type="button"
onClick={() => void planKeywords()} onClick={() => void planKeywords()}
disabled={busy === "plan" || busy === "run" || !intent.trim()} disabled={busy === "plan" || busy === "run" || (purpose === "activity" ? !intent.trim() : !productId)}
> >
{busy === "plan" {busy === "plan"
? t("scout.planning") ? t("scout.planning")
@ -1002,27 +1045,27 @@ export function ScoutPage() {
</p> </p>
) : null} ) : null}
<Textarea {current.scout_mode !== "provider" ? <Textarea
label={t("scout.draft")} label={t("scout.draft")}
value={draftText} value={draftText}
onChange={(e) => setDraftText(e.target.value)} onChange={(e) => setDraftText(e.target.value)}
rows={current.scout_mode === "activity" ? 3 : 5} rows={current.scout_mode === "activity" ? 3 : 5}
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")} placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
/> /> : null}
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{t("scout.manualReplyHint")} {current.scout_mode === "provider" ? t("scout.providerHint") : current.scout_mode === "demand" ? t("scout.demandHint") : t("scout.manualReplyHint")}
</p> </p>
<div className="hb-wizard-actions"> <div className="hb-wizard-actions">
<Button {current.scout_mode !== "activity" && current.scout_mode !== "provider" ? <Button
type="button" type="button"
variant="secondary" variant="secondary"
disabled={Boolean(busy)} disabled={Boolean(busy)}
onClick={() => void promoteCurrent()} onClick={() => void promoteCurrent()}
> >
{busy === "promote" ? "…" : t("scout.promote")} {busy === "promote" ? "…" : t("scout.promote")}
</Button> </Button> : null}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -1031,30 +1074,30 @@ export function ScoutPage() {
> >
{busy === "skip" ? "…" : t("scout.skip")} {busy === "skip" ? "…" : t("scout.skip")}
</Button> </Button>
<Button {current.scout_mode !== "provider" ? <Button
type="button" type="button"
variant="ghost" variant="ghost"
disabled={Boolean(busy) || !isPending(current)} disabled={Boolean(busy) || !isPending(current)}
onClick={() => void regenDraft()} onClick={() => void regenDraft()}
> >
{busy === "draft" ? "…" : t("scout.regen")} {busy === "draft" ? "…" : t("scout.regen")}
</Button> </Button> : null}
<Button <Button
type="button" type="button"
disabled={Boolean(busy) || !allowHttpUrl(current.permalink)} disabled={Boolean(busy) || !allowHttpUrl(current.permalink)}
onClick={openThreadsReply} onClick={openThreadsReply}
> >
{t("scout.openThreadsReply")} {current.scout_mode === "provider" ? t("scout.openPermalink") : t("scout.openThreadsReply")}
</Button> </Button>
<Button {current.scout_mode !== "provider" ? <Button
type="button" type="button"
variant="ghost" variant="ghost"
disabled={Boolean(busy) || !isPending(current)} disabled={Boolean(busy) || !isPending(current)}
onClick={() => void markManualDone()} onClick={() => void markManualDone()}
> >
{busy === "manual-done" ? "…" : t("scout.markManualDone")} {busy === "manual-done" ? "…" : t("scout.markManualDone")}
</Button> </Button> : null}
{current.outreach_status === "published" ? ( {current.scout_mode !== "provider" && current.outreach_status === "published" ? (
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@ -1080,8 +1123,17 @@ export function ScoutPage() {
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */} {/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */}
<MatchQueue <MatchQueue
title={t("scout.valueQueue", { n: valueQueue.length })} title={t("scout.demandQueue", { n: demandQueue.length })}
posts={valueQueue} posts={demandQueue}
page={valueQueuePage}
onPageChange={setValueQueuePage}
currentId={currentId}
onSelect={setCurrentId}
t={t}
/>
<MatchQueue
title={t("scout.providerQueue", { n: providerQueue.length })}
posts={providerQueue}
page={valueQueuePage} page={valueQueuePage}
onPageChange={setValueQueuePage} onPageChange={setValueQueuePage}
currentId={currentId} currentId={currentId}

View File

@ -9,12 +9,11 @@ import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
const ComposerPanel = lazy(() => import("./studio/ComposerPanel").then((m) => ({ default: m.ComposerPanel }))); const ComposerPanel = lazy(() => import("./studio/ComposerPanel").then((m) => ({ default: m.ComposerPanel })));
const InspirePanel = lazy(() => import("./studio/InspirePanel").then((m) => ({ default: m.InspirePanel }))); const InspirePanel = lazy(() => import("./studio/InspirePanel").then((m) => ({ default: m.InspirePanel })));
const InsightsPanel = lazy(() => import("./studio/InsightsPanel").then((m) => ({ default: m.InsightsPanel })));
const MentionsPanel = lazy(() => import("./studio/MentionsPanel").then((m) => ({ default: m.MentionsPanel }))); const MentionsPanel = lazy(() => import("./studio/MentionsPanel").then((m) => ({ default: m.MentionsPanel })));
const OwnPostsPanel = lazy(() => import("./studio/OwnPostsPanel").then((m) => ({ default: m.OwnPostsPanel }))); const OwnPostsPanel = lazy(() => import("./studio/OwnPostsPanel").then((m) => ({ default: m.OwnPostsPanel })));
const PlaysPanel = lazy(() => import("./studio/PlaysPanel").then((m) => ({ default: m.PlaysPanel }))); const PlaysPanel = lazy(() => import("./studio/PlaysPanel").then((m) => ({ default: m.PlaysPanel })));
export type StudioTab = "posts" | "mentions" | "compose" | "plays" | "inspire" | "insights"; export type StudioTab = "posts" | "mentions" | "compose" | "plays" | "inspire";
const TAB_KEYS: { key: StudioTab; labelKey: string }[] = [ const TAB_KEYS: { key: StudioTab; labelKey: string }[] = [
{ key: "posts", labelKey: "studio.tab.posts" }, { key: "posts", labelKey: "studio.tab.posts" },
@ -22,7 +21,6 @@ const TAB_KEYS: { key: StudioTab; labelKey: string }[] = [
{ key: "compose", labelKey: "studio.tab.compose" }, { key: "compose", labelKey: "studio.tab.compose" },
{ key: "plays", labelKey: "studio.tab.plays" }, { key: "plays", labelKey: "studio.tab.plays" },
{ key: "inspire", labelKey: "studio.tab.inspire" }, { key: "inspire", labelKey: "studio.tab.inspire" },
{ key: "insights", labelKey: "studio.tab.insights" },
]; ];
export function StudioPage() { export function StudioPage() {
@ -145,7 +143,6 @@ export function StudioPage() {
{tab === "inspire" ? ( {tab === "inspire" ? (
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} /> <InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
) : null} ) : null}
{tab === "insights" ? <InsightsPanel accountId={accountId} hideAccountSelect /> : null}
</Suspense> </Suspense>
</> </>
); );

View File

@ -646,6 +646,9 @@ else:
| SC-12 | removePost / removeTheme | — | 列表消失 | | SC-12 | removePost / removeTheme | — | 列表消失 |
| SC-13 | homework save/get/list/remove | — | CRUD 正確 | | SC-13 | homework save/get/list/remove | — | CRUD 正確 |
| SC-14 | importProductFromUrl 合法 | — | 回填草稿欄位;非法 URL 失敗 | | SC-14 | importProductFromUrl 合法 | — | 回填草稿欄位;非法 URL 失敗 |
| SC-15 | provider purpose + 已填產品痛點、解法能力、同類排除詞 | prepareBrief / runScan | 只以痛點+解法能力產詞;產品名與同類排除詞不可作為 query |
| SC-16 | provider 掃描命中 | 貼文同時有痛點、解法能力與案例/專業/推薦證據 | 留存為解法提供者;同類產品銷售與證據不足者不留存 |
| SC-17 | activity 掃描命中 | draft / promote | 短回草稿不含產品置入;不可升級為商機 |
--- ---