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"
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) {

View File

@ -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 {

View File

@ -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

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{
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

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) {
full := fiveReasons()
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.
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
}

View File

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strings"
"time"
"apps/backend/internal/module/radar/domain"
usageDomain "apps/backend/internal/module/usage/domain"
@ -15,6 +14,7 @@ type ThreadHit struct {
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,
})

View File

@ -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 {
@ -63,6 +67,8 @@ type Product struct {
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"`

View File

@ -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
}

View File

@ -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) {

View File

@ -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{{

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) {
_ = 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

View File

@ -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
}

View File

@ -78,7 +78,9 @@ 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,
MatchTags: p.MatchTags, PainPoints: p.PainPoints,
ProviderCapabilityTerms: p.ProviderCapabilityTerms, ProviderExcludeTerms: p.ProviderExcludeTerms,
PlacementUrl: p.PlacementURL,
CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
}
}

View File

@ -1598,6 +1598,8 @@ type ProductPublic struct {
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"`
@ -1610,6 +1612,8 @@ type ProductSaveReq struct {
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"`
}

View File

@ -120,6 +120,12 @@ function mapProduct(raw: Record<string, unknown>): 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,
},
});

View File

@ -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

View File

@ -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;

View File

@ -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",

View File

@ -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 }))}
/>
<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
label={t("brands.pains")}
value={productForm.pain_points}

View File

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

View File

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