add scout demand/provider modes, fix radar publishedAt timing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
15f42a0c27
commit
ef8e1c7528
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const MaxFreshnessDays = 14
|
|||
|
||||
// FreshnessScore maps age in hours to the 0–15 freshness dimension score.
|
||||
func FreshnessScore(hours int) int {
|
||||
if hours > MaxFreshnessDays*24 {
|
||||
return 0
|
||||
}
|
||||
if hours < 0 {
|
||||
hours = 0
|
||||
}
|
||||
|
|
@ -49,6 +52,9 @@ func FreshnessHoursSince(postedAt, now int64) int {
|
|||
|
||||
// IsStaleHardReject is true when the post is older than 14 days.
|
||||
func IsStaleHardReject(postedAt, now int64) bool {
|
||||
if postedAt <= 0 {
|
||||
return false
|
||||
}
|
||||
return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 補齊。
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 | 短回草稿不含產品置入;不可升級為商機 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue