diff --git a/apps/backend/cmd/init/main.go b/apps/backend/cmd/init/main.go index 4dfe67c..d68a72f 100644 --- a/apps/backend/cmd/init/main.go +++ b/apps/backend/cmd/init/main.go @@ -7,6 +7,7 @@ import ( "time" "apps/backend/internal/config" + scoutRepo "apps/backend/internal/module/scout/repository" "github.com/zeromicro/go-zero/core/conf" "go.mongodb.org/mongo-driver/bson" @@ -86,6 +87,8 @@ func indexModels() map[string][]mongo.IndexModel { // The jobs list is polled continuously by the UI and runs a count plus a find per poll. ownerIndex("updated_at", "owner_jobs_updated"), }, + "scout_runs": scoutRepo.RunIndexModels(), + "scout_seen_identities": scoutRepo.SeenIdentityIndexModels(), // One notification is written per job state change and nothing purges them, so this // collection grows without bound and its scan cost grows with it. "notifications": { @@ -112,10 +115,10 @@ func indexModels() map[string][]mongo.IndexModel { }, "scout_brands": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_brands")}}, "scout_products": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_products")}}, - "scout_posts": { + "scout_posts": append([]mongo.IndexModel{ ownerIndex("created_at", "owner_posts_created"), {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_brand_posts")}, - }, + }, scoutRepo.RunPostIndexModels()...), "scout_homework": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_homework")}}, "inspire_elements": {ownerIndex("updated_at", "owner_elements_updated")}, "inspire_sessions": {ownerIndex("updated_at", "owner_sessions_updated")}, @@ -180,6 +183,7 @@ func indexModels() map[string][]mongo.IndexModel { "crm_contacts": { {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "stage", Value: 1}, {Key: "last_touch_at", Value: -1}}, Options: options.Index().SetName("owner_contacts_stage_touch")}, {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "needs_follow_up", Value: 1}}, Options: options.Index().SetName("owner_contacts_follow_up")}, + {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "removed_at", Value: 1}, {Key: "stage", Value: 1}, {Key: "last_touch_at", Value: -1}}, Options: options.Index().SetName("owner_contacts_active_stage_touch")}, }, "crm_touches": { {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "contact_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_touches_created")}, diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go index 34a04c5..d1eb070 100644 --- a/apps/backend/cmd/worker/main.go +++ b/apps/backend/cmd/worker/main.go @@ -159,6 +159,11 @@ func main() { } radarSvc := radarUC.New(radarRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) + // Product watches are validated again inside the worker at execution time. + // Keep the worker on the same read-only Scout catalog bridge as the gateway; + // without it queued product sweeps fail with "product catalog unavailable". + radarSvc.ProductSource = &workerRadarProductContextBridge{Scout: scoutSvc} + radarSvc.Usage = usageSvc 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 { @@ -354,6 +359,35 @@ func (b *workerCrmFollowUpNotif) NotifyFollowUp(ctx context.Context, ownerUID in "待追蹤到期:請回訪聯絡人", 100) } +type workerRadarProductContextBridge struct { + Scout *scoutUC.Service +} + +func (b *workerRadarProductContextBridge) GetBrand(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) { + brand, err := b.Scout.GetBrand(ctx, ownerUID, id) + if err != nil { + return nil, err + } + return &radarUC.ProductBrand{ + ID: brand.ID, OwnerUID: brand.OwnerUID, DisplayName: brand.DisplayName, + TargetAudience: brand.TargetAudience, UpdatedAt: brand.UpdatedAt, + }, nil +} + +func (b *workerRadarProductContextBridge) GetProduct(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) { + product, err := b.Scout.GetProduct(ctx, ownerUID, id) + if err != nil { + return nil, err + } + return &radarUC.ProductCatalogProduct{ + ID: product.ID, OwnerUID: product.OwnerUID, BrandID: product.BrandID, + Label: product.Label, ProductContext: product.ProductContext, + PainPoints: product.PainPoints, MatchTags: product.MatchTags, + ProviderCapabilityTerms: product.ProviderCapabilityTerms, + ProviderExcludeTerms: product.ProviderExcludeTerms, UpdatedAt: product.UpdatedAt, + }, nil +} + // runRadarSweep executes fetch → judge → persist for one watch. func runRadarSweep(ctx context.Context, jobs *jobUC.Service, radar *radarUC.Service, j *jobDomain.Job) error { var payload jobUC.RadarSweepPayload @@ -448,18 +482,50 @@ func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redisloc } func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error { - var brief scoutDomain.RunBrief - if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil { + var payload struct { + RunID string `json:"run_id"` + scoutDomain.RunBrief + } + if err := json.Unmarshal([]byte(j.Payload), &payload); err != nil { return fmt.Errorf("invalid scout scan payload: %w", err) } - if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil { - return err + if payload.RunID == "" || payload.RunID != j.RefID { + return fmt.Errorf("scout run/job reference mismatch") } - posts, err := scout.RunScanFromBrief(ctx, j.OwnerUID, &brief) + run, err := scout.GetRun(ctx, j.OwnerUID, payload.RunID) if err != nil { return err } - if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已寫入 %d 筆候選", len(posts))); err != nil { + if run.JobID != j.ID { + return fmt.Errorf("scout run job binding mismatch") + } + // The run owns the normalized target (20 by default). Older payloads may + // omit target_count, so make the worker honor the run contract rather than + // accidentally stopping after the small legacy fan-out default. + if payload.TargetCount <= 0 { + payload.TargetCount = run.TargetCount + } + if _, err := scout.StartRun(ctx, j.OwnerUID, payload.RunID, j.ID); err != nil { + return err + } + if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil { + _ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, "scout progress update failed") + return err + } + posts, err := scout.RunScanForRun(ctx, j.OwnerUID, payload.RunID, &payload.RunBrief) + if err != nil { + _ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error()) + return err + } + if _, err := jobs.MarkRunningProgress(ctx, j.ID, 75, fmt.Sprintf("海巡 · 篩選完成 · 合格 %d / 目標 %d", len(posts), run.TargetCount)); err != nil { + _ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, "scout progress update failed") + return err + } + if err := scout.PublishRun(ctx, j.OwnerUID, payload.RunID, posts); err != nil { + _ = scout.FailRun(ctx, j.OwnerUID, payload.RunID, err.Error()) + return err + } + if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已發佈 %d 筆候選", len(posts))); err != nil { return err } _, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts))) diff --git a/apps/backend/cmd/worker/scout_scan_test.go b/apps/backend/cmd/worker/scout_scan_test.go new file mode 100644 index 0000000..23ed134 --- /dev/null +++ b/apps/backend/cmd/worker/scout_scan_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "testing" + + jobDomain "apps/backend/internal/module/job/domain" + jobRepo "apps/backend/internal/module/job/repository" + jobUC "apps/backend/internal/module/job/usecase" + scoutDomain "apps/backend/internal/module/scout/domain" + scoutRepo "apps/backend/internal/module/scout/repository" + scoutUC "apps/backend/internal/module/scout/usecase" +) + +type workerScoutProvider struct{ err error } + +func (p workerScoutProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]scoutUC.ThreadSearchResult, error) { + if p.err != nil { + return nil, p.err + } + term := "市集" + if len(terms) > 0 && terms[0] != "" { + term = terms[0] + } + if limit < 1 { + limit = 1 + } + return []scoutUC.ThreadSearchResult{{ + URL: "https://www.threads.net/@worker/post/ok", Snippet: term + "討論內容", + }}, nil +} + +type failingPublishScoutRepo struct{ scoutDomain.Repository } + +func (failingPublishScoutRepo) PublishRunPosts(context.Context, int64, string, []*scoutDomain.Post) error { + return errors.New("publish storage unavailable") +} + +func workerPayload(runID string, brief scoutDomain.RunBrief) string { + raw, _ := json.Marshal(struct { + RunID string `json:"run_id"` + scoutDomain.RunBrief + }{RunID: runID, RunBrief: brief}) + return string(raw) +} + +func workerRunAndJob(t *testing.T, scoutStore scoutDomain.Repository, jobStore jobDomain.Repository, status string) (*scoutUC.Service, *jobUC.Service, *jobDomain.Job, *scoutDomain.Run) { + t.Helper() + brief := scoutDomain.RunBrief{Intent: "市集", Mode: scoutDomain.ModeActivity, ScanTerms: []string{"市集"}} + run := scoutDomain.NewRun("worker-run", "worker-job", 7, brief, 1) + if err := scoutStore.CreateRun(context.Background(), run); err != nil { + t.Fatal(err) + } + job := &jobDomain.Job{ID: run.JobID, OwnerUID: 7, TemplateType: jobDomain.TemplateScoutScan, + Status: jobDomain.StatusRunning, RefID: run.ID, Payload: workerPayload(run.ID, brief), CreatedAt: 1, UpdatedAt: 1} + if err := jobStore.Insert(context.Background(), job); err != nil { + t.Fatal(err) + } + if status != scoutDomain.RunQueued { + if status == scoutDomain.RunCancelled { + run.Status = scoutDomain.RunCancelled + } else { + run.Status = status + } + if err := scoutStore.ReplaceRunGuarded(context.Background(), 7, run.ID, []string{scoutDomain.RunQueued}, run); err != nil { + t.Fatal(err) + } + } + scout := scoutUC.New(scoutStore) + scout.Provider = workerScoutProvider{} + return scout, jobUC.New(jobStore), job, run +} + +func TestWorkerScoutScanSuccessPublishesExactlyOnce(t *testing.T) { + ctx := context.Background() + scoutStore := scoutRepo.NewMemory() + jobStore := jobRepo.NewMemory() + scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) + if err := runScoutScan(ctx, jobs, scout, job); err != nil { + t.Fatal(err) + } + gotRun, err := scoutStore.GetRun(ctx, 7, run.ID) + if err != nil || gotRun.Status != scoutDomain.RunSucceeded || gotRun.EligibleCount != 1 { + t.Fatalf("run=%+v err=%v", gotRun, err) + } + posts, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10) + if err != nil || len(posts.Items) != 1 { + t.Fatalf("published posts=%+v err=%v", posts, err) + } + jobAfter, err := jobStore.FindByID(ctx, job.ID) + if err != nil || jobAfter.Status != jobDomain.StatusSucceeded { + t.Fatalf("job=%+v err=%v", jobAfter, err) + } + if err := runScoutScan(ctx, jobs, scout, job); err == nil { + t.Fatal("terminal run retry should not search/publish again") + } + postsAgain, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10) + if err != nil || len(postsAgain.Items) != 1 { + t.Fatalf("retry changed result membership: %+v %v", postsAgain, err) + } +} + +func TestWorkerScoutScanMismatchFailsBeforeSearch(t *testing.T) { + ctx := context.Background() + scoutStore := scoutRepo.NewMemory() + jobStore := jobRepo.NewMemory() + scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) + job.RefID = "wrong-run" + if err := runScoutScan(ctx, jobs, scout, job); err == nil { + t.Fatal("reference mismatch should fail") + } + got, err := scoutStore.GetRun(ctx, 7, run.ID) + if err != nil || got.Status != scoutDomain.RunQueued { + t.Fatalf("mismatch changed run=%+v err=%v", got, err) + } +} + +func TestWorkerScoutScanProviderFailureFailsRunAndHidesResults(t *testing.T) { + ctx := context.Background() + scoutStore := scoutRepo.NewMemory() + jobStore := jobRepo.NewMemory() + scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) + scout.Provider = workerScoutProvider{err: errors.New("provider timeout: token redacted")} + if err := runScoutScan(ctx, jobs, scout, job); err == nil { + t.Fatal("provider failure should fail") + } + got, err := scoutStore.GetRun(ctx, 7, run.ID) + if err != nil || got.Status != scoutDomain.RunFailed || got.Error == "" { + t.Fatalf("failed run=%+v err=%v", got, err) + } + posts, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10) + if err != nil || len(posts.Items) != 0 { + t.Fatalf("failed run exposed posts=%+v err=%v", posts, err) + } +} + +func TestWorkerScoutScanPublishFailureFailsRun(t *testing.T) { + ctx := context.Background() + base := scoutRepo.NewMemory() + scoutStore := failingPublishScoutRepo{Repository: base} + jobStore := jobRepo.NewMemory() + scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) + if err := runScoutScan(ctx, jobs, scout, job); err == nil { + t.Fatal("publish failure should fail") + } + got, err := base.GetRun(ctx, 7, run.ID) + if err != nil || got.Status != scoutDomain.RunFailed { + t.Fatalf("publish failed run=%+v err=%v", got, err) + } +} + +func TestWorkerScoutScanCancelledRunDoesNotRestart(t *testing.T) { + ctx := context.Background() + scoutStore := scoutRepo.NewMemory() + jobStore := jobRepo.NewMemory() + scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunCancelled) + if err := runScoutScan(ctx, jobs, scout, job); err == nil { + t.Fatal("cancelled run should not restart") + } + got, err := scoutStore.GetRun(ctx, 7, run.ID) + if err != nil || got.Status != scoutDomain.RunCancelled { + t.Fatalf("cancelled run changed=%+v err=%v", got, err) + } +} diff --git a/apps/backend/crawler/README.md b/apps/backend/crawler/README.md index 35cf6f0..d73a667 100644 --- a/apps/backend/crawler/README.md +++ b/apps/backend/crawler/README.md @@ -39,7 +39,7 @@ Optional: `SCOUT_CRAWLER_PORT=8891` changes the local port. - `dev_mode=false` does not use this service; it uses the configured API provider. - `dev_mode=true` requires a freshly synchronized Chrome session. Missing or expired sessions fail with a user-actionable error. - The service supports `/v1/threads/search` and `/v1/threads/resolve`; both require `Authorization: Bearer `. -- **Dual-track search** (熱門 + 最新): each query hits Top and Recent in parallel; merge is **Recent-primary order + Top fill** (both is a badge only — does not reshuffle relevance). Hard-drops only when age > `SCOUT_MAX_AGE_DAYS` (default **180**). Response: `track`, `serp_rank`, `published_at`. +- **Dual-track search** (熱門 + 最新): each query hits Top and Recent in parallel; merge is **Recent-primary order + Top fill** (both is a badge only — does not reshuffle relevance). Published age is returned as metadata and is not hard-dropped; backend score may down-rank older candidates. Response: `track`, `serp_rank`, `published_at`. ## Operations diff --git a/apps/backend/crawler/src/server.ts b/apps/backend/crawler/src/server.ts index c34f906..e4f27b5 100644 --- a/apps/backend/crawler/src/server.ts +++ b/apps/backend/crawler/src/server.ts @@ -17,13 +17,6 @@ type Post = { const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891); const token = process.env.SCOUT_CRAWLER_TOKEN || ""; -/** 硬擋:可解析且超過此天數才丟。預設 180。 */ -const hardMaxAgeDays = (() => { - const n = Number(process.env.SCOUT_MAX_AGE_DAYS); - if (Number.isFinite(n) && n >= 0) return Math.floor(n); - return 180; -})(); - function isThreadsURL(value: string): boolean { try { const host = new URL(value).hostname.toLowerCase(); @@ -101,13 +94,6 @@ function parsePublishedFromCardText(text: string): { iso?: string; label?: strin return {}; } -function isHardTooOld(iso?: string): boolean { - if (!iso || hardMaxAgeDays <= 0) return false; - const t = Date.parse(iso); - if (!Number.isFinite(t)) return false; - return Date.now() - t > hardMaxAgeDays * 86_400_000; -} - /** * 用 page.evaluate 抽 SERP 貼文:比 locator 祖先更穩,並用 query 過濾無關卡。 * 只掃 document 出現順序中的 /post/ 連結,略過明顯非結果區(nav 等難以 100% 排除時靠 query 過濾)。 @@ -162,7 +148,6 @@ async function readPosts(page: Page, query: string, limit: number): Promise + +package crm + +import ( + "net/http" + + "apps/backend/internal/logic/crm" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteContactHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ContactIdReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := crm.NewDeleteContactLogic(r.Context(), svcCtx) + data, err := l.DeleteContact(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/assign_watch_product_handler.go b/apps/backend/internal/handler/radar/assign_watch_product_handler.go new file mode 100644 index 0000000..a779e07 --- /dev/null +++ b/apps/backend/internal/handler/radar/assign_watch_product_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AssignWatchProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AssignProductReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewAssignWatchProductLogic(r.Context(), svcCtx) + data, err := l.AssignWatchProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/delete_archived_watch_handler.go b/apps/backend/internal/handler/radar/delete_archived_watch_handler.go new file mode 100644 index 0000000..350d237 --- /dev/null +++ b/apps/backend/internal/handler/radar/delete_archived_watch_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func DeleteArchivedWatchHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.WatchIdReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewDeleteArchivedWatchLogic(r.Context(), svcCtx) + data, err := l.DeleteArchivedWatch(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/enrich_demand_map_handler.go b/apps/backend/internal/handler/radar/enrich_demand_map_handler.go new file mode 100644 index 0000000..d5ad962 --- /dev/null +++ b/apps/backend/internal/handler/radar/enrich_demand_map_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func EnrichDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.EnrichDemandMapReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewEnrichDemandMapLogic(r.Context(), svcCtx) + data, err := l.EnrichDemandMap(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/get_demand_map_handler.go b/apps/backend/internal/handler/radar/get_demand_map_handler.go new file mode 100644 index 0000000..e4d577b --- /dev/null +++ b/apps/backend/internal/handler/radar/get_demand_map_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.DemandMapProductReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewGetDemandMapLogic(r.Context(), svcCtx) + data, err := l.GetDemandMap(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/get_radar_cost_preview_handler.go b/apps/backend/internal/handler/radar/get_radar_cost_preview_handler.go new file mode 100644 index 0000000..881d81d --- /dev/null +++ b/apps/backend/internal/handler/radar/get_radar_cost_preview_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetRadarCostPreviewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CostPreviewReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewGetRadarCostPreviewLogic(r.Context(), svcCtx) + data, err := l.GetRadarCostPreview(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/get_radar_today_handler.go b/apps/backend/internal/handler/radar/get_radar_today_handler.go index 586200c..0e838c3 100644 --- a/apps/backend/internal/handler/radar/get_radar_today_handler.go +++ b/apps/backend/internal/handler/radar/get_radar_today_handler.go @@ -9,12 +9,20 @@ import ( "apps/backend/internal/logic/radar" "apps/backend/internal/response" "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" ) func GetRadarTodayHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + var req types.RadarTodayReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + l := radar.NewGetRadarTodayLogic(r.Context(), svcCtx) - data, err := l.GetRadarToday() + data, err := l.GetRadarToday(&req) response.Write(r.Context(), w, data, err) } } diff --git a/apps/backend/internal/handler/radar/set_primary_product_handler.go b/apps/backend/internal/handler/radar/set_primary_product_handler.go new file mode 100644 index 0000000..7f26ac0 --- /dev/null +++ b/apps/backend/internal/handler/radar/set_primary_product_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetPrimaryProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SetPrimaryProductReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewSetPrimaryProductLogic(r.Context(), svcCtx) + data, err := l.SetPrimaryProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/update_demand_map_handler.go b/apps/backend/internal/handler/radar/update_demand_map_handler.go new file mode 100644 index 0000000..142c440 --- /dev/null +++ b/apps/backend/internal/handler/radar/update_demand_map_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateDemandMapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateDemandMapReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewUpdateDemandMapLogic(r.Context(), svcCtx) + data, err := l.UpdateDemandMap(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/radar/update_opportunity_review_state_handler.go b/apps/backend/internal/handler/radar/update_opportunity_review_state_handler.go new file mode 100644 index 0000000..9986f42 --- /dev/null +++ b/apps/backend/internal/handler/radar/update_opportunity_review_state_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package radar + +import ( + "net/http" + + "apps/backend/internal/logic/radar" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateOpportunityReviewStateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ReviewStateReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := radar.NewUpdateOpportunityReviewStateLogic(r.Context(), svcCtx) + data, err := l.UpdateOpportunityReviewState(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go index 3528dc3..1f7726c 100644 --- a/apps/backend/internal/handler/routes.go +++ b/apps/backend/internal/handler/routes.go @@ -331,6 +331,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/contacts/:id", Handler: crm.GetContactHandler(serverCtx), }, + { + Method: http.MethodDelete, + Path: "/contacts/:id", + Handler: crm.DeleteContactHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/contacts/:id/conversion", @@ -1031,6 +1036,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, []rest.Route{ + { + Method: http.MethodGet, + Path: "/cost-preview", + Handler: radar.GetRadarCostPreviewHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/explore", @@ -1066,6 +1076,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/opportunities/:id/override", Handler: radar.OverrideOpportunityHandler(serverCtx), }, + { + Method: http.MethodPut, + Path: "/opportunities/:id/primary-product", + Handler: radar.SetPrimaryProductHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/opportunities/:id/replies", @@ -1081,6 +1096,26 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/opportunities/:id/replies/:replyId/mark-used", Handler: radar.MarkOpportunityReplyUsedHandler(serverCtx), }, + { + Method: http.MethodPut, + Path: "/opportunities/:id/review-state", + Handler: radar.UpdateOpportunityReviewStateHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/products/:productId/demand-map", + Handler: radar.GetDemandMapHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/products/:productId/demand-map", + Handler: radar.UpdateDemandMapHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/products/:productId/demand-map/enrich", + Handler: radar.EnrichDemandMapHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/service-profile", @@ -1126,11 +1161,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/watches/:id", Handler: radar.ArchiveWatchHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/watches/:id/assign-product", + Handler: radar.AssignWatchProductHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/watches/:id/pause", Handler: radar.PauseWatchHandler(serverCtx), }, + { + Method: http.MethodDelete, + Path: "/watches/:id/purge", + Handler: radar.DeleteArchivedWatchHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/watches/:id/resume", @@ -1300,6 +1345,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/products/import", Handler: scout.ImportProductHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/runs", + Handler: scout.ListScoutRunsHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/runs/:runId", + Handler: scout.RemoveScoutRunHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/runs/:runId/posts", + Handler: scout.ListScoutRunPostsHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/scan", diff --git a/apps/backend/internal/handler/scout/list_scout_run_posts_handler.go b/apps/backend/internal/handler/scout/list_scout_run_posts_handler.go new file mode 100644 index 0000000..3037ce3 --- /dev/null +++ b/apps/backend/internal/handler/scout/list_scout_run_posts_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListScoutRunPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutRunPostsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewListScoutRunPostsLogic(r.Context(), svcCtx) + data, err := l.ListScoutRunPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/list_scout_runs_handler.go b/apps/backend/internal/handler/scout/list_scout_runs_handler.go new file mode 100644 index 0000000..58ec82e --- /dev/null +++ b/apps/backend/internal/handler/scout/list_scout_runs_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListScoutRunsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutRunListReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewListScoutRunsLogic(r.Context(), svcCtx) + data, err := l.ListScoutRuns(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/scout/remove_scout_run_handler.go b/apps/backend/internal/handler/scout/remove_scout_run_handler.go new file mode 100644 index 0000000..1349b5d --- /dev/null +++ b/apps/backend/internal/handler/scout/remove_scout_run_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package scout + +import ( + "net/http" + + "apps/backend/internal/logic/scout" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RemoveScoutRunHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ScoutRunPostsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := scout.NewRemoveScoutRunLogic(r.Context(), svcCtx) + data, err := l.RemoveScoutRun(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/logic/crm/delete_contact_logic.go b/apps/backend/internal/logic/crm/delete_contact_logic.go new file mode 100644 index 0000000..335a8a6 --- /dev/null +++ b/apps/backend/internal/logic/crm/delete_contact_logic.go @@ -0,0 +1,33 @@ +package crm + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteContactLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteContactLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContactLogic { + return &DeleteContactLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// DeleteContact removes the contact from the working list while retaining +// opportunity, touch, and conversion history for audit and attribution. +func (l *DeleteContactLogic) DeleteContact(req *types.ContactIdReq) (*types.OkData, error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + if err := l.svcCtx.Crm.RemoveContact(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true, Message: "contact removed from list"}, nil +} diff --git a/apps/backend/internal/logic/crm/list_contacts_logic.go b/apps/backend/internal/logic/crm/list_contacts_logic.go index 91d97fc..c850b37 100644 --- a/apps/backend/internal/logic/crm/list_contacts_logic.go +++ b/apps/backend/internal/logic/crm/list_contacts_logic.go @@ -27,7 +27,7 @@ func (l *ListContactsLogic) ListContacts(req *types.ListContactsReq) (*types.Con if err != nil { return nil, err } - f := domain.ContactListFilter{Stage: req.Stage, Band: req.Band, Sort: req.Sort, Page: req.Page, PageSize: req.PageSize} + f := domain.ContactListFilter{Query: req.Query, Stage: req.Stage, Band: req.Band, Sort: req.Sort, Page: req.Page, PageSize: req.PageSize} switch strings.ToLower(req.FollowUp) { case "true", "1": v := true diff --git a/apps/backend/internal/logic/radar/assign_watch_product_logic.go b/apps/backend/internal/logic/radar/assign_watch_product_logic.go new file mode 100644 index 0000000..8ed16d4 --- /dev/null +++ b/apps/backend/internal/logic/radar/assign_watch_product_logic.go @@ -0,0 +1,37 @@ +package radar + +import ( + "context" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AssignWatchProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAssignWatchProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AssignWatchProductLogic { + return &AssignWatchProductLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *AssignWatchProductLogic) AssignWatchProduct(req *types.AssignProductReq) (resp *types.RadarWatchPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + w, err := l.svcCtx.Radar.AssignWatchProduct(l.ctx, uid, req.Id, req.BrandId, req.ProductId) + if err != nil { + return nil, err + } + return radarmap.Watch(w), nil +} diff --git a/apps/backend/internal/logic/radar/brand_product_integration_test.go b/apps/backend/internal/logic/radar/brand_product_integration_test.go new file mode 100644 index 0000000..44fe7f3 --- /dev/null +++ b/apps/backend/internal/logic/radar/brand_product_integration_test.go @@ -0,0 +1,157 @@ +package radar + +import ( + "context" + "testing" + "time" + + radarDomain "apps/backend/internal/module/radar/domain" + radarRepo "apps/backend/internal/module/radar/repository" + radarUC "apps/backend/internal/module/radar/usecase" +) + +// p0ProductSource is the same read-only boundary used by the real Scout→Radar +// bridge. Keeping it local makes this gate deterministic and network-free. +type p0ProductSource struct { + brand *radarUC.ProductBrand + products map[string]*radarUC.ProductCatalogProduct +} + +func (s *p0ProductSource) GetBrand(_ context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) { + if s.brand == nil || s.brand.ID != id { + return nil, radarDomain.ErrNotFound + } + if s.brand.OwnerUID != ownerUID { + return nil, radarDomain.ErrForbidden + } + return s.brand, nil +} + +func (s *p0ProductSource) GetProduct(_ context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) { + p, ok := s.products[id] + if !ok { + return nil, radarDomain.ErrNotFound + } + if p.OwnerUID != ownerUID { + return nil, radarDomain.ErrForbidden + } + return p, nil +} + +type p0ContactBinder struct{ calls int } + +func (b *p0ContactBinder) BindOpportunity(_ context.Context, _ int64, o *radarDomain.Opportunity) (string, error) { + b.calls++ + return "contact:" + o.AuthorHandle, nil +} + +func p0Reasons() []radarDomain.OpportunityReason { + return []radarDomain.OpportunityReason{ + {Dimension: radarDomain.DimAuthenticity, Score: 30, Reason: "公開貼文是使用者求助"}, + {Dimension: radarDomain.DimIntent, Score: 30, Reason: "明確詢問推薦與價格"}, + {Dimension: radarDomain.DimRegion, Score: 10, Reason: "未猜測地區"}, + {Dimension: radarDomain.DimFreshness, Score: 15, Reason: "剛發布"}, + {Dimension: radarDomain.DimFit, Score: 5, Reason: "產品上下文仍保留"}, + } +} + +func TestBrandProductP0FlowCreateSweepMergePrimaryQueryAndPause(t *testing.T) { + const owner = int64(42) + ctx := context.Background() + now := time.Now().UnixNano() + source := &p0ProductSource{ + brand: &radarUC.ProductBrand{ID: "b1", OwnerUID: owner, DisplayName: "澄光品牌", TargetAudience: "敏感肌使用者", UpdatedAt: 10}, + products: map[string]*radarUC.ProductCatalogProduct{ + "p1": {ID: "p1", BrandID: "b1", OwnerUID: owner, Label: "舒緩精華", ProductContext: "日常修護", PainPoints: []string{"泛紅不適"}, MatchTags: []string{"舒緩保濕"}, ProviderCapabilityTerms: []string{"修護"}, UpdatedAt: 11}, + "p2": {ID: "p2", BrandID: "b1", OwnerUID: owner, Label: "修護乳霜", ProductContext: "日常修護", PainPoints: []string{"泛紅不適"}, MatchTags: []string{"舒緩保濕"}, ProviderCapabilityTerms: []string{"修護"}, UpdatedAt: 12}, + }, + } + svc := radarUC.New(radarRepo.NewMemory()) + svc.ProductSource = source + svc.Quota = radarUC.FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 20} + svc.CRM = &p0ContactBinder{} + svc.HitFetch = radarUC.HitFetcherFunc(func(context.Context, int64, []string, int) ([]radarUC.ThreadHit, string, error) { + return []radarUC.ThreadHit{{ + URL: "https://www.threads.net/@seeker/post/shared-1", + Snippet: "敏感肌使用者求推薦:泛紅不適,想了解舒緩保濕日常修護與修護,請問價格?", + PublishedAt: now, + }}, "crawler", nil + }) + + w1, err := svc.CreateWatch(ctx, owner, radarUC.WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.RunSweep(ctx, owner, w1.ID, "p0-job-p1"); err != nil { + t.Fatal(err) + } + w2, err := svc.CreateWatch(ctx, owner, radarUC.WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p2"}) + if err != nil { + t.Fatal(err) + } + second, err := svc.RunSweep(ctx, owner, w2.ID, "p0-job-p2") + if err != nil { + t.Fatal(err) + } + if second.Created != 0 || second.Sweep.MatchMergedCount != 1 { + t.Fatalf("same post was not merged: created=%d sweep=%+v", second.Created, second.Sweep) + } + + list, total, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{ProductID: "p2", MatchState: "eligible", Sort: "score"}) + if err != nil || total != 1 || len(list) != 1 || len(list[0].ProductMatches) != 2 { + t.Fatalf("expected one opportunity with two product matches: total=%d list=%+v err=%v", total, list, err) + } + opportunityID := list[0].ID + if list[0].PrimaryProductID != "p1" { + t.Fatalf("automatic primary arbitration selected %q, want p1", list[0].PrimaryProductID) + } + selected, err := svc.SetPrimaryProduct(ctx, owner, opportunityID, "p1", "人工確認目前主推舒緩精華") + if err != nil { + t.Fatal(err) + } + if selected.PrimaryProductID != "p1" || !selected.PrimaryProductOverridden { + t.Fatalf("primary override not persisted: %+v", selected) + } + + today, err := svc.GetTodayFiltered(ctx, owner, radarDomain.OpportunityListFilter{ProductID: "p2"}) + if err != nil || today.Stats.Total != 1 || len(today.High)+len(today.Mid)+len(today.Low) != 1 { + t.Fatalf("today product filter lost eligible match: today=%+v err=%v", today, err) + } + accepted, contactID, err := svc.AcceptOpportunity(ctx, owner, opportunityID) + if err != nil || contactID != "contact:seeker" || accepted.ContactID != contactID { + t.Fatalf("contact identity was not reused: opp=%+v contact=%q err=%v", accepted, contactID, err) + } + if accepted.ReviewState != radarDomain.ReviewCompleted { + t.Fatalf("accepted opportunity should leave pending inbox: opp=%+v", accepted) + } + pending, pendingTotal, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{ReviewState: radarDomain.ReviewPending}) + if err != nil || pendingTotal != 0 || len(pending) != 0 { + t.Fatalf("accepted opportunity remained in pending inbox: total=%d list=%+v err=%v", pendingTotal, pending, err) + } + completed, completedTotal, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{ReviewState: radarDomain.ReviewCompleted}) + if err != nil || completedTotal != 1 || len(completed) != 1 || completed[0].ID != opportunityID { + t.Fatalf("accepted opportunity missing from completed inbox: total=%d list=%+v err=%v", completedTotal, completed, err) + } + if binder := svc.CRM.(*p0ContactBinder); binder.calls != 1 { + t.Fatalf("expected one CRM contact bind, calls=%d", binder.calls) + } + + // A legacy generic row remains queryable without inventing a product match. + legacy := &radarDomain.Opportunity{OwnerUID: owner, ExternalID: "legacy-1", Permalink: "https://threads.net/legacy-1", Text: "舊資料", PostedAt: now, Status: radarDomain.OppQualified, IntentScore: 60, IntentBand: radarDomain.BandMid, Reasons: p0Reasons()} + if _, err := svc.Repo.UpsertByExternalID(ctx, legacy); err != nil { + t.Fatal(err) + } + legacyList, legacyTotal, err := svc.ListOpportunities(ctx, owner, radarDomain.OpportunityListFilter{MatchState: "generic"}) + if err != nil || legacyTotal != 1 || len(legacyList) != 1 || len(legacyList[0].ProductMatches) != 0 { + t.Fatalf("legacy generic query changed: total=%d list=%+v err=%v", legacyTotal, legacyList, err) + } + + paused, err := svc.PauseProductWatches(ctx, owner, "p1") + if err != nil || paused != 1 { + t.Fatalf("product delete lifecycle did not pause p1 watch: count=%d err=%v", paused, err) + } + pausedWatch, err := svc.GetWatch(ctx, owner, w1.ID) + if err != nil || pausedWatch.Status != radarDomain.WatchPaused || pausedWatch.PauseReason != radarDomain.PauseReasonProductUnavailable { + t.Fatalf("paused watch lost explicit reason: %+v err=%v", pausedWatch, err) + } +} diff --git a/apps/backend/internal/logic/radar/create_watch_logic.go b/apps/backend/internal/logic/radar/create_watch_logic.go index 44feb56..ee51fc2 100644 --- a/apps/backend/internal/logic/radar/create_watch_logic.go +++ b/apps/backend/internal/logic/radar/create_watch_logic.go @@ -34,6 +34,8 @@ func (l *CreateWatchLogic) CreateWatch(req *types.CreateWatchReq) (resp *types.R Terms: req.Terms, ExcludeTerms: req.ExcludeTerms, Regions: req.Regions, + BrandID: req.BrandId, + ProductID: req.ProductId, Enabled: req.Enabled, }) if err != nil { diff --git a/apps/backend/internal/logic/radar/delete_archived_watch_logic.go b/apps/backend/internal/logic/radar/delete_archived_watch_logic.go new file mode 100644 index 0000000..95fd9c6 --- /dev/null +++ b/apps/backend/internal/logic/radar/delete_archived_watch_logic.go @@ -0,0 +1,37 @@ +package radar + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteArchivedWatchLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteArchivedWatchLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteArchivedWatchLogic { + return &DeleteArchivedWatchLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +// DeleteArchivedWatch permanently removes only an already archived watch. +// Its sweeps and opportunities remain available as historical records. +func (l *DeleteArchivedWatchLogic) DeleteArchivedWatch(req *types.WatchIdReq) (*types.OkData, error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + if err := l.svcCtx.Radar.DeleteArchivedWatch(l.ctx, uid, req.Id); err != nil { + return nil, err + } + return &types.OkData{Ok: true, Message: "archived watch deleted"}, nil +} diff --git a/apps/backend/internal/logic/radar/enrich_demand_map_logic.go b/apps/backend/internal/logic/radar/enrich_demand_map_logic.go new file mode 100644 index 0000000..c5b4e43 --- /dev/null +++ b/apps/backend/internal/logic/radar/enrich_demand_map_logic.go @@ -0,0 +1,42 @@ +package radar + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type EnrichDemandMapLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewEnrichDemandMapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *EnrichDemandMapLogic { + return &EnrichDemandMapLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *EnrichDemandMapLogic) EnrichDemandMap(req *types.EnrichDemandMapReq) (resp *types.DemandMapPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + if req == nil || req.ProductId == "" { + return nil, fmt.Errorf("%w: product id required", domain.ErrValidation) + } + m, err := l.svcCtx.Radar.EnrichDemandMap(l.ctx, uid, req.ProductId, req.PreviewId, req.ExpectedMapVersion, req.CreditCeiling) + if err != nil { + return nil, err + } + return radarmap.DemandMap(m), nil +} diff --git a/apps/backend/internal/logic/radar/explore_opportunities_logic.go b/apps/backend/internal/logic/radar/explore_opportunities_logic.go index 0c84f82..e76ed82 100644 --- a/apps/backend/internal/logic/radar/explore_opportunities_logic.go +++ b/apps/backend/internal/logic/radar/explore_opportunities_logic.go @@ -2,7 +2,10 @@ package radar import ( "context" + "fmt" + "strings" + usecase "apps/backend/internal/module/radar/usecase" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -28,7 +31,16 @@ func (l *ExploreOpportunitiesLogic) ExploreOpportunities(req *types.ExploreOppor if err != nil { return nil, err } - res, err := l.svcCtx.Radar.ExploreOpportunities(l.ctx, uid, req.Terms) + brandID, productID := strings.TrimSpace(req.BrandId), strings.TrimSpace(req.ProductId) + if (brandID == "") != (productID == "") { + return nil, fmt.Errorf("brand_id and product_id must be provided together") + } + var res *usecase.ExploreResult + if brandID != "" { + res, err = l.svcCtx.Radar.ExploreProductOpportunities(l.ctx, uid, req.Terms, brandID, productID) + } else { + res, err = l.svcCtx.Radar.ExploreOpportunities(l.ctx, uid, req.Terms) + } if err != nil { return nil, err } @@ -37,6 +49,8 @@ func (l *ExploreOpportunitiesLogic) ExploreOpportunities(req *types.ExploreOppor HitCount: res.HitCount, JudgedCount: res.JudgedCount, CreatedCount: res.CreatedCount, + MatchedCount: res.MatchedCount, + MergedCount: res.MergedCount, TruncatedCount: res.TruncatedCount, CreditsUsed: res.CreditsUsed, }, nil diff --git a/apps/backend/internal/logic/radar/get_demand_map_logic.go b/apps/backend/internal/logic/radar/get_demand_map_logic.go new file mode 100644 index 0000000..d90db86 --- /dev/null +++ b/apps/backend/internal/logic/radar/get_demand_map_logic.go @@ -0,0 +1,42 @@ +package radar + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetDemandMapLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetDemandMapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetDemandMapLogic { + return &GetDemandMapLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetDemandMapLogic) GetDemandMap(req *types.DemandMapProductReq) (resp *types.DemandMapPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + if req == nil { + return nil, fmt.Errorf("%w: product id required", domain.ErrValidation) + } + m, err := l.svcCtx.Radar.GetDemandMap(l.ctx, uid, req.ProductId) + if err != nil { + return nil, err + } + return radarmap.DemandMap(m), nil +} diff --git a/apps/backend/internal/logic/radar/get_radar_cost_preview_logic.go b/apps/backend/internal/logic/radar/get_radar_cost_preview_logic.go new file mode 100644 index 0000000..1d1dacd --- /dev/null +++ b/apps/backend/internal/logic/radar/get_radar_cost_preview_logic.go @@ -0,0 +1,42 @@ +package radar + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetRadarCostPreviewLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetRadarCostPreviewLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRadarCostPreviewLogic { + return &GetRadarCostPreviewLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRadarCostPreviewLogic) GetRadarCostPreview(req *types.CostPreviewReq) (resp *types.CostPreviewPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + if req == nil { + return nil, fmt.Errorf("%w: preview request required", domain.ErrValidation) + } + p, err := l.svcCtx.Radar.GetCostPreview(l.ctx, uid, req.Action, req.WatchId, req.ProductId, req.CandidateLimit) + if err != nil { + return nil, err + } + return radarmap.CostPreview(p), nil +} diff --git a/apps/backend/internal/logic/radar/get_radar_today_logic.go b/apps/backend/internal/logic/radar/get_radar_today_logic.go index 7177187..79b6ec3 100644 --- a/apps/backend/internal/logic/radar/get_radar_today_logic.go +++ b/apps/backend/internal/logic/radar/get_radar_today_logic.go @@ -4,6 +4,7 @@ import ( "context" "apps/backend/internal/logic/radarmap" + "apps/backend/internal/module/radar/domain" radaruc "apps/backend/internal/module/radar/usecase" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -21,12 +22,16 @@ func NewGetRadarTodayLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Get return &GetRadarTodayLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } -func (l *GetRadarTodayLogic) GetRadarToday() (*types.RadarTodayData, error) { +func (l *GetRadarTodayLogic) GetRadarToday(req *types.RadarTodayReq) (*types.RadarTodayData, error) { uid, err := ownerUID(l.ctx) if err != nil { return nil, err } - t, err := l.svcCtx.Radar.GetToday(l.ctx, uid) + filter := domain.OpportunityListFilter{} + if req != nil { + filter.BrandID, filter.ProductID, filter.FitBand = req.BrandId, req.ProductId, req.FitBand + } + t, err := l.svcCtx.Radar.GetTodayFiltered(l.ctx, uid, filter) if err != nil { return nil, err } @@ -59,4 +64,3 @@ func mapTodayCards(cards []radaruc.TodayOpportunity) []types.OpportunityPublic { } return out } - diff --git a/apps/backend/internal/logic/radar/import_opportunities_logic.go b/apps/backend/internal/logic/radar/import_opportunities_logic.go index 1ed465e..fa7c07c 100644 --- a/apps/backend/internal/logic/radar/import_opportunities_logic.go +++ b/apps/backend/internal/logic/radar/import_opportunities_logic.go @@ -35,7 +35,13 @@ func (l *ImportOpportunitiesLogic) ImportOpportunities(req *types.ImportOpportun URL: it.Url, Text: it.Text, Author: it.Author, PostedAt: it.PostedAt, }) } - results, err := l.svcCtx.Radar.ImportManualOpportunities(l.ctx, uid, items) + brandID, productID := req.BrandId, req.ProductId + var results []usecase.ManualImportResult + if brandID != "" || productID != "" { + results, err = l.svcCtx.Radar.ImportManualProductOpportunities(l.ctx, uid, items, brandID, productID) + } else { + results, err = l.svcCtx.Radar.ImportManualOpportunities(l.ctx, uid, items) + } if err != nil { return nil, err } diff --git a/apps/backend/internal/logic/radar/list_opportunities_logic.go b/apps/backend/internal/logic/radar/list_opportunities_logic.go index f52a42b..19bbc29 100644 --- a/apps/backend/internal/logic/radar/list_opportunities_logic.go +++ b/apps/backend/internal/logic/radar/list_opportunities_logic.go @@ -8,6 +8,8 @@ import ( "apps/backend/internal/svc" "apps/backend/internal/types" + "time" + "github.com/zeromicro/go-zero/core/logx" ) @@ -26,9 +28,18 @@ func (l *ListOpportunitiesLogic) ListOpportunities(req *types.ListOpportunitiesR if err != nil { return nil, err } + postedFrom, postedTo := req.From, req.To + now := domain.NowNano() + if req.TimeScope == "today" { + postedFrom, postedTo = domain.UTCDayBounds(now) + } else if req.TimeScope == "7d" { + postedFrom, postedTo = now-7*int64(24*time.Hour), 0 + } list, total, err := l.svcCtx.Radar.ListOpportunities(l.ctx, uid, domain.OpportunityListFilter{ Band: req.Band, Status: req.Status, WatchID: req.WatchId, - CreatedFrom: req.From, CreatedTo: req.To, Page: req.Page, PageSize: req.PageSize, + BrandID: req.BrandId, ProductID: req.ProductId, FitBand: req.FitBand, MatchState: req.MatchState, Sort: req.Sort, + CreatedFrom: req.From, CreatedTo: req.To, PostedFrom: postedFrom, PostedTo: postedTo, Page: req.Page, PageSize: req.PageSize, + ReviewState: req.ReviewState, TimeScope: req.TimeScope, PriorityBand: req.PriorityBand, }) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/radar/list_watches_logic.go b/apps/backend/internal/logic/radar/list_watches_logic.go index 80371e3..bf5eb21 100644 --- a/apps/backend/internal/logic/radar/list_watches_logic.go +++ b/apps/backend/internal/logic/radar/list_watches_logic.go @@ -30,7 +30,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W if err != nil { return nil, err } - filter := radarDomain.WatchListFilter{Status: req.Status, Page: req.Page, PageSize: req.PageSize} + filter := radarDomain.WatchListFilter{Status: req.Status, ContextMode: req.ContextMode, BrandID: req.BrandId, ProductID: req.ProductId, Page: req.Page, PageSize: req.PageSize} list, total, err := l.svcCtx.Radar.ListWatches(l.ctx, uid, filter) if err != nil { return nil, err diff --git a/apps/backend/internal/logic/radar/product_contract_test.go b/apps/backend/internal/logic/radar/product_contract_test.go new file mode 100644 index 0000000..3088e2c --- /dev/null +++ b/apps/backend/internal/logic/radar/product_contract_test.go @@ -0,0 +1,19 @@ +package radar + +import ( + "context" + "testing" +) + +func TestPrimaryRouteRequiresAuthenticatedServiceContext(t *testing.T) { + ctx := context.Background() + assign := NewAssignWatchProductLogic(ctx, nil) + primary := NewSetPrimaryProductLogic(ctx, nil) + + if _, err := assign.AssignWatchProduct(nil); err == nil { + t.Fatal("assign product unexpectedly succeeded without an authenticated service context") + } + if _, err := primary.SetPrimaryProduct(nil); err == nil { + t.Fatal("set primary product unexpectedly succeeded without an authenticated service context") + } +} diff --git a/apps/backend/internal/logic/radar/set_primary_product_logic.go b/apps/backend/internal/logic/radar/set_primary_product_logic.go new file mode 100644 index 0000000..ad63ef9 --- /dev/null +++ b/apps/backend/internal/logic/radar/set_primary_product_logic.go @@ -0,0 +1,37 @@ +package radar + +import ( + "context" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetPrimaryProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetPrimaryProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetPrimaryProductLogic { + return &SetPrimaryProductLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SetPrimaryProductLogic) SetPrimaryProduct(req *types.SetPrimaryProductReq) (resp *types.OpportunityPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + o, err := l.svcCtx.Radar.SetPrimaryProduct(l.ctx, uid, req.Id, req.ProductId, req.Reason) + if err != nil { + return nil, err + } + return radarmap.Opportunity(o), nil +} diff --git a/apps/backend/internal/logic/radar/suggest_watch_terms_logic.go b/apps/backend/internal/logic/radar/suggest_watch_terms_logic.go index 4b4b2bd..3741c47 100644 --- a/apps/backend/internal/logic/radar/suggest_watch_terms_logic.go +++ b/apps/backend/internal/logic/radar/suggest_watch_terms_logic.go @@ -2,7 +2,10 @@ package radar import ( "context" + "fmt" + "strings" + radardomain "apps/backend/internal/module/radar/domain" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -29,13 +32,23 @@ func (l *SuggestWatchTermsLogic) SuggestWatchTerms(req *types.SuggestWatchTermsR if err != nil { return nil, err } - list, err := l.svcCtx.Radar.SuggestWatchTerms(l.ctx, uid, req.Limit) + brandID := strings.TrimSpace(req.BrandId) + productID := strings.TrimSpace(req.ProductId) + if (brandID == "") != (productID == "") { + return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", radardomain.ErrValidation) + } + var list []radardomain.WatchTermSuggestion + if brandID != "" { + list, err = l.svcCtx.Radar.SuggestProductWatchTerms(l.ctx, uid, brandID, productID, req.Limit) + } else { + list, err = l.svcCtx.Radar.SuggestWatchTerms(l.ctx, uid, req.Limit) + } if err != nil { return nil, err } out := make([]types.WatchTermSuggestion, 0, len(list)) for _, s := range list { - out = append(out, types.WatchTermSuggestion{Term: s.Term, Reason: s.Reason, Usage: s.Usage}) + out = append(out, types.WatchTermSuggestion{Term: s.Term, Reason: s.Reason, Usage: s.Usage, BasisKind: s.BasisKind, BasisText: s.BasisText}) } return &types.WatchSuggestData{List: out}, nil } diff --git a/apps/backend/internal/logic/radar/update_demand_map_logic.go b/apps/backend/internal/logic/radar/update_demand_map_logic.go new file mode 100644 index 0000000..1ca926f --- /dev/null +++ b/apps/backend/internal/logic/radar/update_demand_map_logic.go @@ -0,0 +1,70 @@ +package radar + +import ( + "context" + "fmt" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateDemandMapLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateDemandMapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateDemandMapLogic { + return &UpdateDemandMapLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateDemandMapLogic) UpdateDemandMap(req *types.UpdateDemandMapReq) (resp *types.DemandMapPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + if req == nil || req.ProductId == "" { + return nil, fmt.Errorf("%w: product id required", domain.ErrValidation) + } + if req.ExpectedMapVersion < 1 { + return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation) + } + current, err := l.svcCtx.Radar.GetDemandMap(l.ctx, uid, req.ProductId) + if err != nil { + return nil, err + } + phrase := func(in []types.DemandMapPhrase) []domain.DemandMapPhrase { + out := make([]domain.DemandMapPhrase, 0, len(in)) + for _, p := range in { + out = append(out, domain.DemandMapPhrase{Text: p.Text, Kind: p.Kind, BasisKind: p.BasisKind, BasisText: p.BasisText, Origin: p.Origin, Enabled: p.Enabled}) + } + return out + } + value := &domain.DemandMap{ + OwnerUID: uid, ProductID: req.ProductId, DemandInputVersion: current.DemandInputVersion, + MapVersion: current.MapVersion, State: demandMapState(req), + PainPhrases: phrase(req.PainPhrases), ScenarioPhrases: phrase(req.ScenarioPhrases), DesiredOutcomes: phrase(req.DesiredOutcomes), + SolutionSignals: phrase(req.SolutionSignals), ExclusionSignals: phrase(req.ExclusionSignals), CustomPhrases: phrase(req.CustomPhrases), + SourceBasis: append([]string(nil), current.SourceBasis...), AIEnrichedAt: current.AIEnrichedAt, + } + updated, err := l.svcCtx.Radar.UpdateDemandMap(l.ctx, uid, value, req.ExpectedMapVersion) + if err != nil { + return nil, err + } + return radarmap.DemandMap(updated), nil +} + +func demandMapState(req *types.UpdateDemandMapReq) string { + if len(req.PainPhrases) > 0 && len(req.ScenarioPhrases) > 0 && len(req.SolutionSignals) > 0 { + return "ready" + } + return "incomplete" +} diff --git a/apps/backend/internal/logic/radar/update_opportunity_review_state_logic.go b/apps/backend/internal/logic/radar/update_opportunity_review_state_logic.go new file mode 100644 index 0000000..6ec8af8 --- /dev/null +++ b/apps/backend/internal/logic/radar/update_opportunity_review_state_logic.go @@ -0,0 +1,41 @@ +package radar + +import ( + "context" + + "apps/backend/internal/logic/radarmap" + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateOpportunityReviewStateLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateOpportunityReviewStateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOpportunityReviewStateLogic { + return &UpdateOpportunityReviewStateLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateOpportunityReviewStateLogic) UpdateOpportunityReviewState(req *types.ReviewStateReq) (resp *types.OpportunityPublic, err error) { + uid, err := ownerUID(l.ctx) + if err != nil { + return nil, err + } + o, err := l.svcCtx.Radar.UpdateOpportunityReviewState(l.ctx, uid, req.Id, domain.ReviewStatePatch{ + State: req.State, RemovalReason: req.RemovalReason, RemovalNote: req.RemovalNote, + DuplicateOpportunityID: req.DuplicateOpportunityId, RemovedBy: uid, + }) + if err != nil { + return nil, err + } + return radarmap.Opportunity(o), nil +} diff --git a/apps/backend/internal/logic/radarmap/map.go b/apps/backend/internal/logic/radarmap/map.go index 8f5baab..d47b310 100644 --- a/apps/backend/internal/logic/radarmap/map.go +++ b/apps/backend/internal/logic/radarmap/map.go @@ -5,6 +5,37 @@ import ( "apps/backend/internal/types" ) +func DemandMap(m *domain.DemandMap) *types.DemandMapPublic { + if m == nil { + return nil + } + phrases := func(in []domain.DemandMapPhrase) []types.DemandMapPhrase { + out := make([]types.DemandMapPhrase, 0, len(in)) + for _, p := range in { + out = append(out, types.DemandMapPhrase{Text: p.Text, Kind: p.Kind, BasisKind: p.BasisKind, BasisText: p.BasisText, Origin: p.Origin, Enabled: p.Enabled}) + } + return out + } + return &types.DemandMapPublic{ + ProductId: m.ProductID, DemandInputVersion: m.DemandInputVersion, MapVersion: m.MapVersion, State: m.State, + PainPhrases: phrases(m.PainPhrases), ScenarioPhrases: phrases(m.ScenarioPhrases), DesiredOutcomes: phrases(m.DesiredOutcomes), + SolutionSignals: phrases(m.SolutionSignals), ExclusionSignals: phrases(m.ExclusionSignals), SourceBasis: append([]string(nil), m.SourceBasis...), + CustomPhrases: phrases(m.CustomPhrases), AiEnrichedAt: m.AIEnrichedAt, UpdatedAt: m.UpdatedAt, + } +} + +func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic { + if p == nil { + return nil + } + return &types.CostPreviewPublic{ + PreviewId: p.PreviewID, Action: p.Action, KeyMode: p.KeyMode, + FixedCredits: p.FixedCredits, MinCredits: p.MinCredits, MaxCredits: p.MaxCredits, + SearchCalls: p.SearchCalls, MaxAiCandidates: p.MaxAICandidates, + RemainingCredits: p.RemainingCredits, EstimateBasis: p.EstimateBasis, ExpiresAt: p.ExpiresAt, + } +} + /* ServiceProfile 把 domain 檔案轉成 API 形狀。 @@ -76,11 +107,20 @@ func Watch(w *domain.RadarWatch) *types.RadarWatchPublic { regions = []string{} } return &types.RadarWatchPublic{ - Id: w.ID, - Terms: terms, - ExcludeTerms: excludes, - Regions: regions, - Status: w.Status, + Id: w.ID, + Terms: terms, + ExcludeTerms: excludes, + Regions: regions, + Status: w.Status, + ContextMode: func() string { + if w.ContextMode == "" { + return domain.WatchContextGeneric + } + return w.ContextMode + }(), + BrandId: w.BrandID, ProductId: w.ProductID, + BrandNameSnapshot: w.BrandNameSnapshot, ProductLabelSnapshot: w.ProductLabelSnapshot, + ContextBoundAt: w.ContextBoundAt, PauseReason: w.PauseReason, LastSweptAt: w.LastSweptAt, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, @@ -165,6 +205,28 @@ func Opportunity(o *domain.Opportunity) *types.OpportunityPublic { Reasons: reasons, RegionDetected: o.RegionDetected, RegionMatch: o.RegionMatch, FreshnessHours: o.FreshnessHours, MatchedService: o.MatchedService, MatchedTerms: terms, RejectReason: o.RejectReason, ContactId: o.ContactID, CreatedAt: o.CreatedAt, + PrimaryBrandId: o.PrimaryBrandID, PrimaryProductId: o.PrimaryProductID, + PrimaryBrandName: o.PrimaryBrandName, PrimaryProductLabel: o.PrimaryProductLabel, + PrimaryProductFitScore: o.PrimaryProductFitScore, PrimaryProductFitBand: o.PrimaryProductFitBand, + PrimaryProductOverridden: o.PrimaryProductOverridden, + ReviewState: reviewState(o), PreviousReviewState: o.PreviousReviewState, + RemovalReason: o.RemovalReason, RemovalNote: o.RemovalNote, RemovedAt: o.RemovedAt, RemovedBy: o.RemovedBy, + LastMatchedAt: o.LastMatchedAt, + PriorityScore: o.PriorityScore, PriorityBand: o.PriorityBand, PainFitScore: o.PainFitScore, + DemandIntentScore: o.DemandIntentScore, EvidenceQualityScore: o.EvidenceQualityScore, + FreshnessScore: o.FreshnessScore, DemandEvidence: nonNilStrings(o.DemandEvidence), + DemandInputVersion: o.DemandInputVersion, DemandMapVersion: o.DemandMapVersion, + } + out.ProductMatches = make([]types.ProductMatchPublic, 0, len(o.ProductMatches)) + for _, match := range o.ProductMatches { + if match == nil { + continue + } + reasons := make([]types.ProductFitReasonPublic, 0, len(match.Reasons)) + for _, reason := range match.Reasons { + reasons = append(reasons, types.ProductFitReasonPublic{Dimension: reason.Dimension, Score: reason.Score, Reason: reason.Reason, CandidateExcerpt: reason.CandidateExcerpt, ProductBasis: reason.ProductBasis}) + } + out.ProductMatches = append(out.ProductMatches, types.ProductMatchPublic{BrandId: match.BrandID, ProductId: match.ProductID, BrandNameSnapshot: match.BrandNameSnapshot, ProductLabelSnapshot: match.ProductLabelSnapshot, BrandUpdatedAt: match.BrandUpdatedAt, ProductUpdatedAt: match.ProductUpdatedAt, ProductFitScore: match.ProductFitScore, ProductFitBand: match.ProductFitBand, Eligible: match.Eligible, Excluded: match.Excluded, ExcludeReason: match.ExcludeReason, Reasons: reasons, Risks: nonNilStrings(match.Risks), WatchIds: nonNilStrings(match.WatchIDs), MatchedTerms: nonNilStrings(match.MatchedTerms), MatchedAt: match.MatchedAt}) } if o.Override != nil { out.Override = &types.OpportunityOverride{ @@ -176,6 +238,27 @@ func Opportunity(o *domain.Opportunity) *types.OpportunityPublic { return out } +func reviewState(o *domain.Opportunity) string { + if o.ReviewState != "" { + return o.ReviewState + } + switch o.Status { + case domain.OppAccepted: + return domain.ReviewCompleted + case domain.OppDismissed: + return domain.ReviewRemoved + default: + return domain.ReviewPending + } +} + +func nonNilStrings(in []string) []string { + if in == nil { + return []string{} + } + return in +} + func OpportunityList(list []*domain.Opportunity) []types.OpportunityPublic { out := make([]types.OpportunityPublic, 0, len(list)) for _, o := range list { @@ -190,11 +273,24 @@ func Sweep(s *domain.RadarSweep) *types.RadarSweepPublic { if s == nil { return nil } + status := "complete" + if s.FailedReason != "" { + status = "failed" + } else if s.TruncatedCount > 0 || s.BudgetDeferredCount > 0 { + status = "partial_budget" + } return &types.RadarSweepPublic{ Id: s.ID, WatchId: s.WatchID, JobId: s.JobID, Path: s.Path, HitCount: s.HitCount, JudgedCount: s.JudgedCount, CreatedCount: s.CreatedCount, TruncatedCount: s.TruncatedCount, FailedReason: s.FailedReason, CreditsUsed: s.CreditsUsed, - StartedAt: s.StartedAt, EndedAt: s.EndedAt, + MatchEvaluatedCount: s.MatchEvaluatedCount, MatchMergedCount: s.MatchMergedCount, FitRejectedCount: s.FitRejectedCount, + DedupedCount: s.DedupedCount, PrefilterPassCount: s.PrefilterPassCount, PrefilterReviewCount: s.PrefilterReviewCount, + PrefilterRejectedCount: s.PrefilterRejectedCount, CachedJudgmentCount: s.CachedJudgmentCount, + TombstoneMatchedCount: s.TombstoneMatchedCount, BudgetDeferredCount: s.BudgetDeferredCount, + DemandInputVersion: s.DemandInputVersion, DemandMapVersion: s.DemandMapVersion, + CreditSearch: s.CreditSearch, CreditDemandMap: s.CreditDemandMap, CreditJudge: s.CreditJudge, CreditReply: s.CreditReply, + SweepStatus: status, + StartedAt: s.StartedAt, EndedAt: s.EndedAt, } } diff --git a/apps/backend/internal/logic/radarmap/map_product_test.go b/apps/backend/internal/logic/radarmap/map_product_test.go new file mode 100644 index 0000000..c3f1763 --- /dev/null +++ b/apps/backend/internal/logic/radarmap/map_product_test.go @@ -0,0 +1,18 @@ +package radarmap + +import ( + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestProductMapperKeepsLegacyAndNormalizesSlices(t *testing.T) { + legacy := Watch(&domain.RadarWatch{ID: "legacy", Status: domain.WatchActive}) + if legacy.ContextMode != domain.WatchContextGeneric || legacy.Terms == nil || legacy.Regions == nil { + t.Fatalf("legacy watch mapping: %#v", legacy) + } + o := Opportunity(&domain.Opportunity{ID: "o", ProductMatches: []*domain.ProductMatch{nil}}) + if o.ProductMatches == nil || len(o.ProductMatches) != 0 { + t.Fatalf("nil product match must not leak: %#v", o.ProductMatches) + } +} diff --git a/apps/backend/internal/logic/scout/list_scout_run_posts_logic.go b/apps/backend/internal/logic/scout/list_scout_run_posts_logic.go new file mode 100644 index 0000000..18be71d --- /dev/null +++ b/apps/backend/internal/logic/scout/list_scout_run_posts_logic.go @@ -0,0 +1,57 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListScoutRunPostsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListScoutRunPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListScoutRunPostsLogic { + return &ListScoutRunPostsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListScoutRunPostsLogic) ListScoutRunPosts(req *types.ScoutRunPostsReq) (resp *types.ScoutRunPostsData, err error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if req == nil { + return nil, response.Biz(400, 400020, "invalid run posts request") + } + if err := validateRunID(req.RunId); err != nil { + return nil, err + } + if err := validateRunPage(req.Page, req.PageSize); err != nil { + return nil, err + } + page, err := l.svcCtx.Scout.ListRunPosts(l.ctx, uid, req.RunId, req.Page, req.PageSize) + if err != nil { + return nil, err + } + posts := make([]types.ScoutPostPublic, 0, len(page.Items)) + for _, post := range page.Items { + posts = append(posts, types.ScoutPostFromDomain(post)) + } + return &types.ScoutRunPostsData{ + Run: types.ScoutRunFromDomain(page.Run), List: posts, + Pagination: types.PaginationFromDomain(page.Pagination), + }, nil +} diff --git a/apps/backend/internal/logic/scout/list_scout_runs_logic.go b/apps/backend/internal/logic/scout/list_scout_runs_logic.go new file mode 100644 index 0000000..1feb7af --- /dev/null +++ b/apps/backend/internal/logic/scout/list_scout_runs_logic.go @@ -0,0 +1,51 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListScoutRunsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListScoutRunsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListScoutRunsLogic { + return &ListScoutRunsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListScoutRunsLogic) ListScoutRuns(req *types.ScoutRunListReq) (resp *types.ScoutRunListData, err error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if req == nil { + return nil, response.Biz(400, 400020, "invalid run list request") + } + if err := validateRunPage(req.Page, req.PageSize); err != nil { + return nil, err + } + page, err := l.svcCtx.Scout.ListRuns(l.ctx, uid, req.BrandId, req.Mode, req.Page, req.PageSize) + if err != nil { + return nil, err + } + out := make([]types.ScoutRunPublic, 0, len(page.Items)) + for _, run := range page.Items { + out = append(out, types.ScoutRunFromDomain(run)) + } + return &types.ScoutRunListData{List: out, Pagination: types.PaginationFromDomain(page.Pagination)}, nil +} diff --git a/apps/backend/internal/logic/scout/remove_scout_run_logic.go b/apps/backend/internal/logic/scout/remove_scout_run_logic.go new file mode 100644 index 0000000..4f50334 --- /dev/null +++ b/apps/backend/internal/logic/scout/remove_scout_run_logic.go @@ -0,0 +1,46 @@ +package scout + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RemoveScoutRunLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRemoveScoutRunLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveScoutRunLogic { + return &RemoveScoutRunLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RemoveScoutRunLogic) RemoveScoutRun(req *types.ScoutRunPostsReq) (resp *types.OkData, err error) { + if l.svcCtx.Scout == nil { + return nil, response.Biz(503, 503001, "scout not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok { + return nil, response.Biz(401, 401001, "missing authorization") + } + if req == nil { + return nil, response.Biz(400, 400020, "invalid run request") + } + if err := validateRunID(req.RunId); err != nil { + return nil, err + } + if err := l.svcCtx.Scout.RemoveRun(l.ctx, uid, req.RunId); err != nil { + return nil, err + } + return &types.OkData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/scout/run_contract_guard.go b/apps/backend/internal/logic/scout/run_contract_guard.go new file mode 100644 index 0000000..b61e1ab --- /dev/null +++ b/apps/backend/internal/logic/scout/run_contract_guard.go @@ -0,0 +1,17 @@ +package scout + +import "apps/backend/internal/response" + +func validateRunPage(page, pageSize int) error { + if page < 0 || pageSize < 0 || pageSize > 100 { + return response.Biz(400, 400020, "invalid page or pageSize") + } + return nil +} + +func validateRunID(id string) error { + if id == "" { + return response.Biz(400, 400021, "run id is required") + } + return nil +} diff --git a/apps/backend/internal/logic/scout/run_logic_test.go b/apps/backend/internal/logic/scout/run_logic_test.go new file mode 100644 index 0000000..e726480 --- /dev/null +++ b/apps/backend/internal/logic/scout/run_logic_test.go @@ -0,0 +1,111 @@ +package scout + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/scout/domain" + scoutRepo "apps/backend/internal/module/scout/repository" + scoutUC "apps/backend/internal/module/scout/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" +) + +func testRun(id string, owner int64, created int64, status string) *domain.Run { + r := domain.NewRun(id, "job-"+id, owner, domain.RunBrief{ + Intent: "敏感肌保養", Mode: domain.ModeTheme, ThemeKey: "skin", ThemeLabel: "敏感肌保養", + }, created) + r.Status = status + if status == domain.RunSucceeded { + r.EligibleCount = 1 + } + return r +} + +func testScoutContext(uid int64, repo *scoutRepo.MemoryStore) (*svc.ServiceContext, context.Context) { + service := scoutUC.New(repo) + return &svc.ServiceContext{Scout: service}, middleware.WithUID(context.Background(), uid) +} + +func TestRunListLogicOwnerScopedPagination(t *testing.T) { + repo := scoutRepo.NewMemory() + for _, run := range []*domain.Run{ + testRun("run-old", 7, 100, domain.RunSucceeded), + testRun("run-new", 7, 200, domain.RunSucceeded), + testRun("run-other", 8, 300, domain.RunSucceeded), + } { + if err := repo.CreateRun(context.Background(), run); err != nil { + t.Fatal(err) + } + } + svcCtx, ctx := testScoutContext(7, repo) + data, err := NewListScoutRunsLogic(ctx, svcCtx).ListScoutRuns(&types.ScoutRunListReq{Page: 1, PageSize: 1}) + if err != nil { + t.Fatal(err) + } + if data.Pagination.Total != 2 || data.Pagination.TotalPages != 2 || len(data.List) != 1 || data.List[0].Id != "run-new" { + t.Fatalf("unexpected run page: %+v", data) + } + if data.List[0].JobId == "" { + t.Fatal("run response omitted job id") + } +} + +func TestRunPostsLogicReturnsRunAndIndependentPage(t *testing.T) { + repo := scoutRepo.NewMemory() + run := testRun("run-posts", 7, 100, domain.RunSucceeded) + if err := repo.CreateRun(context.Background(), run); err != nil { + t.Fatal(err) + } + for _, post := range []*domain.Post{ + {ID: "post-old", RunID: run.ID, OwnerUID: 7, Text: "old", PostedAt: 100, CreatedAt: 100}, + {ID: "post-new", RunID: run.ID, OwnerUID: 7, Text: "new", PostedAt: 200, CreatedAt: 200}, + } { + if err := repo.PublishRunPosts(context.Background(), 7, run.ID, []*domain.Post{post}); err != nil { + t.Fatal(err) + } + } + svcCtx, ctx := testScoutContext(7, repo) + data, err := NewListScoutRunPostsLogic(ctx, svcCtx).ListScoutRunPosts(&types.ScoutRunPostsReq{RunId: run.ID, Page: 2, PageSize: 1}) + if err != nil { + t.Fatal(err) + } + if data.Run.Id != run.ID || data.Pagination.Total != 2 || data.Pagination.Page != 2 || len(data.List) != 1 || data.List[0].Id != "post-old" { + t.Fatalf("unexpected run posts page: %+v", data) + } +} + +func TestRunRemoveLogicGuardsNonTerminalAndOwner(t *testing.T) { + repo := scoutRepo.NewMemory() + queued := testRun("run-queued", 7, 100, domain.RunQueued) + done := testRun("run-done", 7, 200, domain.RunSucceeded) + for _, run := range []*domain.Run{queued, done} { + if err := repo.CreateRun(context.Background(), run); err != nil { + t.Fatal(err) + } + } + svcCtx, ctx := testScoutContext(7, repo) + if _, err := NewRemoveScoutRunLogic(ctx, svcCtx).RemoveScoutRun(&types.ScoutRunPostsReq{RunId: queued.ID}); !errors.Is(err, domain.ErrIllegalRunStatus) { + t.Fatalf("queued run removal error = %v, want illegal status", err) + } + otherCtx := middleware.WithUID(context.Background(), 8) + if _, err := NewRemoveScoutRunLogic(otherCtx, svcCtx).RemoveScoutRun(&types.ScoutRunPostsReq{RunId: done.ID}); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("cross-owner removal error = %v, want not found", err) + } + if data, err := NewRemoveScoutRunLogic(ctx, svcCtx).RemoveScoutRun(&types.ScoutRunPostsReq{RunId: done.ID}); err != nil || !data.Ok { + t.Fatalf("terminal run removal = %+v, %v", data, err) + } + if _, err := repo.GetRun(context.Background(), 7, done.ID); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("removed run still exists: %v", err) + } +} + +func TestRunLogicRequiresAuth(t *testing.T) { + svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutRepo.NewMemory())} + _, err := NewListScoutRunsLogic(context.Background(), svcCtx).ListScoutRuns(&types.ScoutRunListReq{}) + if err == nil || err.Error() != "missing authorization" { + t.Fatalf("auth error = %v", err) + } +} diff --git a/apps/backend/internal/logic/scout/run_scan_logic.go b/apps/backend/internal/logic/scout/run_scan_logic.go index e5b007f..2d47a24 100644 --- a/apps/backend/internal/logic/scout/run_scan_logic.go +++ b/apps/backend/internal/logic/scout/run_scan_logic.go @@ -5,6 +5,7 @@ import ( "encoding/json" "apps/backend/internal/middleware" + scoutDomain "apps/backend/internal/module/scout/domain" "apps/backend/internal/response" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -23,23 +24,44 @@ func NewRunScanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunScanLo } func (l *RunScanLogic) RunScan(req *types.ScoutScanReq) (*types.ScoutScanJobData, error) { - if l.svcCtx.Jobs == nil { + if l.svcCtx.Jobs == nil || l.svcCtx.Scout == nil { return nil, response.Biz(503, 503001, "jobs module not configured") } uid, ok := middleware.UIDFrom(l.ctx) if !ok { return nil, response.Biz(401, 401001, "missing authorization") } + if req == nil { + return nil, response.Biz(400, 400020, "invalid scout scan request") + } brief := types.BriefToDomain(&req.Brief) - payload, err := json.Marshal(brief) + run, err := l.svcCtx.Scout.CreateQueuedRun(l.ctx, uid, brief) if err != nil { return nil, err } - j, err := l.svcCtx.Jobs.ScheduleScoutScan(l.ctx, uid, brief.ThemeKey, string(payload)) + // Embed run_id alongside the immutable brief fields. The current worker can + // still unmarshal the legacy flat RunBrief; the lifecycle worker will verify + // run_id against Job.RefID. + payload, err := json.Marshal(struct { + RunID string `json:"run_id"` + *scoutDomain.RunBrief + }{RunID: run.ID, RunBrief: brief}) if err != nil { + _ = l.svcCtx.Scout.FailRun(l.ctx, uid, run.ID, "failed to encode scan payload") return nil, err } - return &types.ScoutScanJobData{Job: types.JobFromModel(j)}, nil + j, err := l.svcCtx.Jobs.ScheduleScoutScan(l.ctx, uid, run.ID, string(payload)) + if err != nil { + _ = l.svcCtx.Scout.FailRun(l.ctx, uid, run.ID, "scan job scheduling failed") + return nil, err + } + bound, err := l.svcCtx.Scout.BindRunJob(l.ctx, uid, run.ID, j.ID) + if err != nil { + _ = l.svcCtx.Jobs.Delete(l.ctx, uid, j.ID) + _ = l.svcCtx.Scout.FailRun(l.ctx, uid, run.ID, "scan run/job binding failed") + return nil, err + } + return &types.ScoutScanJobData{Job: types.JobFromModel(j), Run: types.ScoutRunFromDomain(bound)}, nil } diff --git a/apps/backend/internal/logic/scout/run_scan_logic_test.go b/apps/backend/internal/logic/scout/run_scan_logic_test.go new file mode 100644 index 0000000..1b5321d --- /dev/null +++ b/apps/backend/internal/logic/scout/run_scan_logic_test.go @@ -0,0 +1,104 @@ +package scout + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "apps/backend/internal/middleware" + jobDomain "apps/backend/internal/module/job/domain" + jobRepo "apps/backend/internal/module/job/repository" + jobUC "apps/backend/internal/module/job/usecase" + scoutDomain "apps/backend/internal/module/scout/domain" + scoutRepo "apps/backend/internal/module/scout/repository" + scoutUC "apps/backend/internal/module/scout/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" +) + +func TestRunScanCreatesIndependentRunAndJobLink(t *testing.T) { + ctx := middleware.WithUID(context.Background(), 7) + scoutStore := scoutRepo.NewMemory() + jobs := jobUC.New(jobRepo.NewMemory()) + svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: jobs} + data, err := NewRunScanLogic(ctx, svcCtx).RunScan(&types.ScoutScanReq{Brief: types.ScoutBriefPublic{ + Intent: "市集", Mode: domainModeActivity, ThemeKey: "same-theme", ThemeLabel: "週末市集", + ScanTerms: []string{"市集"}, TargetCount: 12, + }}) + if err != nil { + t.Fatal(err) + } + if data.Run.Id == "" || data.Job.Id == "" || data.Run.Id == data.Job.Id { + t.Fatalf("missing independent ids: %+v", data) + } + if data.Job.RefId != data.Run.Id || data.Run.JobId != data.Job.Id || data.Run.Status != scoutDomain.RunQueued { + t.Fatalf("job/run link mismatch: job=%+v run=%+v", data.Job, data.Run) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(data.Job.Payload), &raw); err != nil { + t.Fatal(err) + } + var payloadRunID string + if err := json.Unmarshal(raw["run_id"], &payloadRunID); err != nil || payloadRunID != data.Run.Id { + t.Fatalf("payload run_id=%q err=%v payload=%s", payloadRunID, err, data.Job.Payload) + } + var brief scoutDomain.RunBrief + if err := json.Unmarshal([]byte(data.Job.Payload), &brief); err != nil || brief.Intent != "市集" || len(brief.ScanTerms) != 1 { + t.Fatalf("flat worker brief=%+v err=%v", brief, err) + } + if data.Run.TargetCount != 12 { + t.Fatalf("target count=%d", data.Run.TargetCount) + } + runs, err := scoutStore.ListRuns(context.Background(), scoutDomain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10}) + if err != nil || len(runs.Items) != 1 || runs.Items[0].ID != data.Run.Id { + t.Fatalf("stored runs=%+v err=%v", runs, err) + } +} + +func TestRunScanCreatesFreshRunForSameTheme(t *testing.T) { + ctx := middleware.WithUID(context.Background(), 7) + scoutStore := scoutRepo.NewMemory() + svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: jobUC.New(jobRepo.NewMemory())} + logic := NewRunScanLogic(ctx, svcCtx) + request := &types.ScoutScanReq{Brief: types.ScoutBriefPublic{Intent: "市集", Mode: domainModeActivity, ThemeKey: "repeat", ScanTerms: []string{"市集"}}} + first, err := logic.RunScan(request) + if err != nil { + t.Fatal(err) + } + second, err := logic.RunScan(request) + if err != nil { + t.Fatal(err) + } + if first.Run.Id == second.Run.Id || first.Job.Id == second.Job.Id { + t.Fatalf("same theme reused id: first=%+v second=%+v", first, second) + } +} + +type failingInsertJobRepo struct{ jobDomain.Repository } + +func (failingInsertJobRepo) Insert(context.Context, *jobDomain.Job) error { + return errors.New("insert unavailable") +} + +func TestRunScanSchedulingFailureFailsQueuedRun(t *testing.T) { + ctx := middleware.WithUID(context.Background(), 7) + scoutStore := scoutRepo.NewMemory() + failingJobs := jobUC.New(failingInsertJobRepo{Repository: jobRepo.NewMemory()}) + svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: failingJobs} + _, err := NewRunScanLogic(ctx, svcCtx).RunScan(&types.ScoutScanReq{Brief: types.ScoutBriefPublic{ + Intent: "市集", Mode: domainModeActivity, ScanTerms: []string{"市集"}, + }}) + if err == nil { + t.Fatal("expected scheduling failure") + } + runs, listErr := scoutStore.ListRuns(context.Background(), scoutDomain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10}) + if listErr != nil || len(runs.Items) != 1 || runs.Items[0].Status != scoutDomain.RunFailed { + t.Fatalf("failed run convergence: runs=%+v err=%v listErr=%v", runs, err, listErr) + } + if runs.Items[0].Error == "" { + t.Fatal("failed run missing safe error") + } +} + +const domainModeActivity = "activity" diff --git a/apps/backend/internal/module/crm/domain/contact.go b/apps/backend/internal/module/crm/domain/contact.go index 136ffef..2fa3cf4 100644 --- a/apps/backend/internal/module/crm/domain/contact.go +++ b/apps/backend/internal/module/crm/domain/contact.go @@ -52,8 +52,11 @@ type Contact struct { TopIntentScore int `bson:"top_intent_score,omitempty" json:"top_intent_score,omitempty"` // OutcomeID links the latest conversion in growth_outcomes. OutcomeID string `bson:"outcome_id,omitempty" json:"outcome_id,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + // RemovedAt hides the contact from the working list without breaking + // opportunity, touch, or conversion history that still references its ID. + RemovedAt int64 `bson:"removed_at,omitempty" json:"removed_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } type ContactTouch struct { @@ -80,6 +83,7 @@ type FollowUp struct { } type ContactListFilter struct { + Query string Stage string FollowUp *bool Band string diff --git a/apps/backend/internal/module/crm/domain/repository.go b/apps/backend/internal/module/crm/domain/repository.go index fc30cda..c2576ed 100644 --- a/apps/backend/internal/module/crm/domain/repository.go +++ b/apps/backend/internal/module/crm/domain/repository.go @@ -7,6 +7,8 @@ type Repository interface { UpsertContactByIdentity(ctx context.Context, c *Contact) (*Contact, error) GetContact(ctx context.Context, id string) (*Contact, error) SaveContact(ctx context.Context, c *Contact) error + // RemoveContact hides a contact and closes active follow-ups while retaining history. + RemoveContact(ctx context.Context, ownerUID int64, id string, at int64) error ListContacts(ctx context.Context, ownerUID int64, f ContactListFilter) ([]*Contact, int64, error) CountByStage(ctx context.Context, ownerUID int64) (map[string]int, error) diff --git a/apps/backend/internal/module/crm/repository/memory.go b/apps/backend/internal/module/crm/repository/memory.go index 8076717..9f1be44 100644 --- a/apps/backend/internal/module/crm/repository/memory.go +++ b/apps/backend/internal/module/crm/repository/memory.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "sync" "apps/backend/internal/module/crm/domain" @@ -39,6 +40,12 @@ func (m *Memory) UpsertContactByIdentity(_ context.Context, c *domain.Contact) ( key := idKey(c.OwnerUID, c.SourcePlatform, c.AuthorHandle) if id, ok := m.identity[key]; ok { ex := m.contacts[id] + if ex.RemovedAt > 0 { + ex.RemovedAt = 0 + ex.Stage = domain.StageNewFound + ex.NeedsFollowUp = false + ex.LastTouchAt = c.LastTouchAt + } // merge opportunity ids seen := map[string]bool{} for _, x := range ex.OpportunityIDs { @@ -98,12 +105,44 @@ func (m *Memory) SaveContact(_ context.Context, c *domain.Contact) error { return nil } +func (m *Memory) RemoveContact(_ context.Context, ownerUID int64, id string, at int64) error { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.contacts[id] + if !ok || c.RemovedAt > 0 { + return domain.ErrNotFound + } + if c.OwnerUID != ownerUID { + return domain.ErrForbidden + } + if at <= 0 { + at = domain.NowNano() + } + c.RemovedAt = at + c.NeedsFollowUp = false + c.UpdatedAt = at + for _, followUp := range m.followups { + if followUp.OwnerUID != ownerUID || followUp.ContactID != id { + continue + } + if followUp.Status == domain.FollowUpScheduled || followUp.Status == domain.FollowUpSnoozed || followUp.Status == domain.FollowUpNotified { + followUp.Status = domain.FollowUpDone + followUp.UpdatedAt = at + } + } + return nil +} + func (m *Memory) ListContacts(_ context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) { m.mu.Lock() defer m.mu.Unlock() matched := make([]*domain.Contact, 0) for _, c := range m.contacts { - if c.OwnerUID != ownerUID { + if c.OwnerUID != ownerUID || c.RemovedAt > 0 { + continue + } + q := strings.ToLower(strings.TrimSpace(f.Query)) + if q != "" && !strings.Contains(strings.ToLower(c.AuthorHandle), q) && !strings.Contains(strings.ToLower(c.DisplayName), q) { continue } if f.Stage != "" && c.Stage != f.Stage { @@ -155,7 +194,7 @@ func (m *Memory) CountByStage(_ context.Context, ownerUID int64) (map[string]int out := map[string]int{} follow := 0 for _, c := range m.contacts { - if c.OwnerUID != ownerUID { + if c.OwnerUID != ownerUID || c.RemovedAt > 0 { continue } out[c.Stage]++ diff --git a/apps/backend/internal/module/crm/repository/mongo.go b/apps/backend/internal/module/crm/repository/mongo.go index 9ac0a09..cea9477 100644 --- a/apps/backend/internal/module/crm/repository/mongo.go +++ b/apps/backend/internal/module/crm/repository/mongo.go @@ -2,6 +2,8 @@ package repository import ( "context" + "regexp" + "strings" libmongo "apps/backend/internal/lib/mongo" "apps/backend/internal/module/crm/domain" @@ -40,6 +42,12 @@ func (s *MonStore) UpsertContactByIdentity(ctx context.Context, c *domain.Contac err := s.contacts.FindOne(ctx, &existing, filter) if err == nil { // merge + if existing.RemovedAt > 0 { + existing.RemovedAt = 0 + existing.Stage = domain.StageNewFound + existing.NeedsFollowUp = false + existing.LastTouchAt = c.LastTouchAt + } seen := map[string]bool{} for _, id := range existing.OpportunityIDs { seen[id] = true @@ -92,8 +100,62 @@ func (s *MonStore) SaveContact(ctx context.Context, c *domain.Contact) error { return err } +func (s *MonStore) RemoveContact(ctx context.Context, ownerUID int64, id string, at int64) error { + if at <= 0 { + at = domain.NowNano() + } + _, err := s.followups.UpdateMany(ctx, bson.M{ + "owner_uid": ownerUID, + "contact_id": id, + "status": bson.M{"$in": bson.A{ + domain.FollowUpScheduled, + domain.FollowUpSnoozed, + domain.FollowUpNotified, + }}, + }, bson.M{"$set": bson.M{"status": domain.FollowUpDone, "updated_at": at}}) + if err != nil { + return err + } + res, err := s.contacts.UpdateOne(ctx, bson.M{ + "_id": id, + "owner_uid": ownerUID, + "$or": bson.A{ + bson.M{"removed_at": bson.M{"$exists": false}}, + bson.M{"removed_at": 0}, + }, + }, bson.M{"$set": bson.M{ + "removed_at": at, + "needs_follow_up": false, + "updated_at": at, + }}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return domain.ErrNotFound + } + return nil +} + +func activeContactQuery(ownerUID int64) bson.M { + return bson.M{ + "owner_uid": ownerUID, + "$and": bson.A{bson.M{"$or": bson.A{ + bson.M{"removed_at": bson.M{"$exists": false}}, + bson.M{"removed_at": 0}, + }}}, + } +} + func (s *MonStore) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, error) { - q := bson.M{"owner_uid": ownerUID} + q := activeContactQuery(ownerUID) + if search := strings.TrimSpace(f.Query); search != "" { + pattern := regexp.QuoteMeta(search) + q["$and"] = append(q["$and"].(bson.A), bson.M{"$or": bson.A{ + bson.M{"author_handle": bson.M{"$regex": pattern, "$options": "i"}}, + bson.M{"display_name": bson.M{"$regex": pattern, "$options": "i"}}, + }}) + } if f.Stage != "" { q["stage"] = f.Stage } @@ -127,8 +189,9 @@ func (s *MonStore) ListContacts(ctx context.Context, ownerUID int64, f domain.Co } func (s *MonStore) CountByStage(ctx context.Context, ownerUID int64) (map[string]int, error) { - // simple: list all and count (contacts per owner are bounded) - list, _, err := s.ListContacts(ctx, ownerUID, domain.ContactListFilter{Page: 1, PageSize: 500}) + // Counts describe the complete active pipeline, not just the current page. + var list []*domain.Contact + err := s.contacts.Find(ctx, &list, activeContactQuery(ownerUID), options.Find().SetProjection(bson.M{"stage": 1, "needs_follow_up": 1})) if err != nil { return nil, err } diff --git a/apps/backend/internal/module/crm/usecase/contact_list_test.go b/apps/backend/internal/module/crm/usecase/contact_list_test.go new file mode 100644 index 0000000..f050e5b --- /dev/null +++ b/apps/backend/internal/module/crm/usecase/contact_list_test.go @@ -0,0 +1,92 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + "testing" + + "apps/backend/internal/module/crm/domain" + "apps/backend/internal/module/crm/repository" +) + +func seedContact(t *testing.T, repo domain.Repository, ownerUID int64, handle, displayName, stage string) *domain.Contact { + t.Helper() + c, err := repo.UpsertContactByIdentity(context.Background(), &domain.Contact{ + OwnerUID: ownerUID, SourcePlatform: domain.PlatformThreads, + AuthorHandle: handle, DisplayName: displayName, Stage: stage, + OpportunityIDs: []string{"opp-" + handle}, LastTouchAt: domain.NowNano(), + }) + if err != nil { + t.Fatalf("seed contact %s: %v", handle, err) + } + return c +} + +func TestContactListSearchPaginationAndCompleteCounts(t *testing.T) { + repo := repository.NewMemory() + svc := New(repo) + ctx := context.Background() + for i := 0; i < 25; i++ { + stage := domain.StageNewFound + if i%2 == 0 { + stage = domain.StageEngaged + } + seedContact(t, repo, 42, fmt.Sprintf("buyer-%02d", i), fmt.Sprintf("客戶 %02d", i), stage) + } + + list, total, counts, err := svc.ListContacts(ctx, 42, domain.ContactListFilter{Page: 2, PageSize: 20}) + if err != nil { + t.Fatalf("list: %v", err) + } + if total != 25 || len(list) != 5 { + t.Fatalf("pagination list=%d total=%d, want 5/25", len(list), total) + } + if counts[domain.StageEngaged] != 13 || counts[domain.StageNewFound] != 12 { + t.Fatalf("stage counts = %+v", counts) + } + + list, total, _, err = svc.ListContacts(ctx, 42, domain.ContactListFilter{Query: "客戶 07", Page: 1, PageSize: 20}) + if err != nil || total != 1 || len(list) != 1 || list[0].AuthorHandle != "buyer-07" { + t.Fatalf("search list=%+v total=%d err=%v", list, total, err) + } +} + +func TestRemoveContactHidesItClosesFollowUpsAndCanBeReadded(t *testing.T) { + repo := repository.NewMemory() + svc := New(repo) + ctx := context.Background() + c := seedContact(t, repo, 42, "buyer", "買家", domain.StageEngaged) + if _, err := svc.SetFollowUp(ctx, 42, c.ID, true, 3); err != nil { + t.Fatalf("set follow-up: %v", err) + } + + if err := svc.RemoveContact(ctx, 42, c.ID); err != nil { + t.Fatalf("remove: %v", err) + } + if _, err := svc.GetContactOnly(ctx, 42, c.ID); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("get removed err=%v, want ErrNotFound", err) + } + list, total, counts, err := svc.ListContacts(ctx, 42, domain.ContactListFilter{Page: 1, PageSize: 20}) + if err != nil || total != 0 || len(list) != 0 || counts[domain.StageEngaged] != 0 { + t.Fatalf("removed contact still visible: list=%+v total=%d counts=%+v err=%v", list, total, counts, err) + } + followUps, totalFollowUps, err := repo.ListFollowUps(ctx, 42, domain.FollowUpListFilter{Status: domain.FollowUpDone, Page: 1, PageSize: 20}) + if err != nil || totalFollowUps != 1 || len(followUps) != 1 { + t.Fatalf("active follow-up was not closed: list=%+v total=%d err=%v", followUps, totalFollowUps, err) + } + + readded := seedContact(t, repo, 42, "buyer", "買家", domain.StageNewFound) + if readded.ID != c.ID || readded.RemovedAt != 0 || readded.Stage != domain.StageNewFound { + t.Fatalf("re-added contact = %+v, want same active identity", readded) + } +} + +func TestRemoveContactOwnerGuard(t *testing.T) { + repo := repository.NewMemory() + svc := New(repo) + c := seedContact(t, repo, 42, "buyer", "買家", domain.StageNewFound) + if err := svc.RemoveContact(context.Background(), 7, c.ID); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("remove other owner err=%v, want ErrForbidden", err) + } +} diff --git a/apps/backend/internal/module/crm/usecase/service.go b/apps/backend/internal/module/crm/usecase/service.go index a079e1c..50bfbf6 100644 --- a/apps/backend/internal/module/crm/usecase/service.go +++ b/apps/backend/internal/module/crm/usecase/service.go @@ -3,7 +3,9 @@ package usecase import ( "context" "fmt" + "strings" "time" + "unicode/utf8" "apps/backend/internal/module/crm/domain" radarDomain "apps/backend/internal/module/radar/domain" @@ -63,6 +65,19 @@ func (s *Service) BindOpportunity(ctx context.Context, ownerUID int64, opp *rada } func (s *Service) ListContacts(ctx context.Context, ownerUID int64, f domain.ContactListFilter) ([]*domain.Contact, int64, map[string]int, error) { + if ownerUID <= 0 { + return nil, 0, nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) + } + f.Query = strings.TrimSpace(f.Query) + if utf8.RuneCountInString(f.Query) > 80 { + return nil, 0, nil, fmt.Errorf("%w: query is too long", domain.ErrValidation) + } + if f.Stage != "" && !domain.IsStage(f.Stage) { + return nil, 0, nil, fmt.Errorf("%w: unknown stage %q", domain.ErrValidation, f.Stage) + } + if f.PageSize > 50 { + f.PageSize = 50 + } list, total, err := s.Repo.ListContacts(ctx, ownerUID, f) if err != nil { return nil, 0, nil, err @@ -92,9 +107,21 @@ func (s *Service) GetContactOnly(ctx context.Context, ownerUID int64, id string) if c.OwnerUID != ownerUID { return nil, domain.ErrForbidden } + if c.RemovedAt > 0 { + return nil, domain.ErrNotFound + } return c, nil } +// RemoveContact removes a contact from the working list and closes active +// follow-ups. Opportunity, touch, and conversion history keep their contact ID. +func (s *Service) RemoveContact(ctx context.Context, ownerUID int64, id string) error { + if _, err := s.GetContactOnly(ctx, ownerUID, id); err != nil { + return err + } + return s.Repo.RemoveContact(ctx, ownerUID, id, domain.NowNano()) +} + // OpportunityBriefs resolves linked opportunities for contact detail. func (s *Service) OpportunityBriefs(ctx context.Context, ownerUID int64, ids []string) []map[string]any { out := make([]map[string]any, 0, len(ids)) @@ -277,7 +304,7 @@ func (s *Service) ReportConversion(ctx context.Context, ownerUID int64, contactI _ = s.Repo.InsertTouch(ctx, &domain.ContactTouch{ ID: domain.NewID(), OwnerUID: ownerUID, ContactID: contactID, Type: domain.TouchConversion, ToStage: domain.StageWon, - Body: fmt.Sprintf("成交回報 %.0f %s %s", amount, currency, note), + Body: fmt.Sprintf("成交回報 %.0f %s %s", amount, currency, note), ActorUID: ownerUID, CreatedAt: now, }) return c, outcomeID, nil diff --git a/apps/backend/internal/module/job/usecase/service.go b/apps/backend/internal/module/job/usecase/service.go index e1d3d70..5ca0167 100644 --- a/apps/backend/internal/module/job/usecase/service.go +++ b/apps/backend/internal/module/job/usecase/service.go @@ -136,13 +136,13 @@ func (s *Service) StartDemo(ctx context.Context, ownerUID int64) (*domain.Job, e } // ScheduleScoutScan enqueues an immutable Scout brief for the worker. -func (s *Service) ScheduleScoutScan(ctx context.Context, ownerUID int64, themeKey, payload string) (*domain.Job, error) { - if strings.TrimSpace(payload) == "" { - return nil, fmt.Errorf("scout scan payload required") +func (s *Service) ScheduleScoutScan(ctx context.Context, ownerUID int64, runID, payload string) (*domain.Job, error) { + if strings.TrimSpace(runID) == "" || strings.TrimSpace(payload) == "" { + return nil, fmt.Errorf("scout scan run id and payload required") } now := domain.NowNano() j := &domain.Job{ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateScoutScan, - Status: domain.StatusQueued, RefID: themeKey, Payload: payload, + Status: domain.StatusQueued, RefID: runID, Payload: payload, ProgressSummary: "海巡已排程 · 等待 worker", CreatedAt: now, UpdatedAt: now} if err := s.Repo.Insert(ctx, j); err != nil { return nil, err diff --git a/apps/backend/internal/module/radar/domain/cost_preview.go b/apps/backend/internal/module/radar/domain/cost_preview.go new file mode 100644 index 0000000..9c271c9 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/cost_preview.go @@ -0,0 +1,15 @@ +package domain + +type CostPreview struct { + PreviewID string `json:"preview_id"` + Action string `json:"action"` + KeyMode string `json:"key_mode"` + FixedCredits int `json:"fixed_credits"` + MinCredits int `json:"min_credits"` + MaxCredits int `json:"max_credits"` + SearchCalls int `json:"search_calls"` + MaxAICandidates int `json:"max_ai_candidates"` + RemainingCredits int `json:"remaining_credits"` + EstimateBasis string `json:"estimate_basis"` + ExpiresAt int64 `json:"expires_at"` +} diff --git a/apps/backend/internal/module/radar/domain/demand_map.go b/apps/backend/internal/module/radar/domain/demand_map.go new file mode 100644 index 0000000..5284d30 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/demand_map.go @@ -0,0 +1,71 @@ +package domain + +import ( + "strings" +) + +type DemandMapPhrase struct { + Text string `bson:"text" json:"text"` + Kind string `bson:"kind" json:"kind"` + BasisKind string `bson:"basis_kind,omitempty" json:"basis_kind,omitempty"` + BasisText string `bson:"basis_text,omitempty" json:"basis_text,omitempty"` + Origin string `bson:"origin" json:"origin"` + Enabled bool `bson:"enabled" json:"enabled"` +} + +type DemandMap struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + ProductID string `bson:"product_id" json:"product_id"` + DemandInputVersion string `bson:"demand_input_version" json:"demand_input_version"` + MapVersion int64 `bson:"map_version" json:"map_version"` + State string `bson:"state" json:"state"` + PainPhrases []DemandMapPhrase `bson:"pain_phrases" json:"pain_phrases"` + ScenarioPhrases []DemandMapPhrase `bson:"scenario_phrases" json:"scenario_phrases"` + DesiredOutcomes []DemandMapPhrase `bson:"desired_outcomes" json:"desired_outcomes"` + SolutionSignals []DemandMapPhrase `bson:"solution_signals" json:"solution_signals"` + ExclusionSignals []DemandMapPhrase `bson:"exclusion_signals" json:"exclusion_signals"` + SourceBasis []string `bson:"source_basis" json:"source_basis"` + CustomPhrases []DemandMapPhrase `bson:"custom_phrases" json:"custom_phrases"` + AIEnrichedAt int64 `bson:"ai_enriched_at,omitempty" json:"ai_enriched_at,omitempty"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +func (m *DemandMap) Normalize() error { + if m.OwnerUID <= 0 || strings.TrimSpace(m.ProductID) == "" || strings.TrimSpace(m.DemandInputVersion) == "" { + return ErrValidation + } + if m.MapVersion < 1 { + m.MapVersion = 1 + } + if m.State != "ready" && m.State != "incomplete" && m.State != "stale" { + return ErrValidation + } + for i := range m.PainPhrases { + normalizeDemandPhrase(&m.PainPhrases[i]) + } + for i := range m.ScenarioPhrases { + normalizeDemandPhrase(&m.ScenarioPhrases[i]) + } + for i := range m.DesiredOutcomes { + normalizeDemandPhrase(&m.DesiredOutcomes[i]) + } + for i := range m.SolutionSignals { + normalizeDemandPhrase(&m.SolutionSignals[i]) + } + for i := range m.ExclusionSignals { + normalizeDemandPhrase(&m.ExclusionSignals[i]) + } + for i := range m.CustomPhrases { + normalizeDemandPhrase(&m.CustomPhrases[i]) + } + return nil +} + +func normalizeDemandPhrase(p *DemandMapPhrase) { + p.Text = strings.TrimSpace(p.Text) + p.Kind = strings.TrimSpace(p.Kind) + p.BasisKind = strings.TrimSpace(p.BasisKind) + p.BasisText = strings.TrimSpace(p.BasisText) + p.Origin = strings.TrimSpace(p.Origin) +} diff --git a/apps/backend/internal/module/radar/domain/domain.go b/apps/backend/internal/module/radar/domain/domain.go index 881621a..3ec4feb 100644 --- a/apps/backend/internal/module/radar/domain/domain.go +++ b/apps/backend/internal/module/radar/domain/domain.go @@ -6,6 +6,7 @@ var ( ErrNotFound = errors.New("radar not found") ErrForbidden = errors.New("radar access denied") ErrValidation = errors.New("radar validation") + ErrConflict = errors.New("radar conflict") // ErrNotReady marks a radar capability whose route exists but has no implementation yet. // Returning it keeps the contract honest: never answer 102000 with empty data. ErrNotReady = errors.New("radar capability not ready") diff --git a/apps/backend/internal/module/radar/domain/opportunity.go b/apps/backend/internal/module/radar/domain/opportunity.go index b5c5cc3..f3a54f2 100644 --- a/apps/backend/internal/module/radar/domain/opportunity.go +++ b/apps/backend/internal/module/radar/domain/opportunity.go @@ -15,6 +15,24 @@ const ( OppDismissed = "dismissed" ) +// ReviewState is the inbox workflow and deliberately remains separate from +// Opportunity status (accepted still belongs to CRM). +const ( + ReviewPending = "pending" + ReviewCompleted = "completed" + ReviewRemoved = "removed" +) + +const ( + RemovalPainMismatch = "pain_mismatch" + RemovalProviderAd = "provider_or_ad" + RemovalStale = "stale" + RemovalAlreadySolved = "already_solved" + RemovalDuplicate = "duplicate" + RemovalOther = "other" + RemovalLegacy = "legacy_unknown" +) + // Intent band thresholds are locked by spec §3.3 (80 / 50). Do not change here. const ( BandHigh = "high" @@ -75,6 +93,12 @@ type OpportunityOverride struct { At int64 `bson:"at" json:"at"` } +type ProductPrimaryOverride struct { + ActorUID int64 `bson:"actor_uid" json:"actor_uid"` + Reason string `bson:"reason" json:"reason"` + At int64 `bson:"at" json:"at"` +} + /* Opportunity 是經五問判定後的商機。 @@ -82,43 +106,99 @@ Opportunity 是經五問判定後的商機。 併入 matched_terms,不重跑判定(SW-06)。 */ type Opportunity struct { - ID string `bson:"_id" json:"id"` - OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` - WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"` - Source string `bson:"source" json:"source"` - SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"` - ExternalID string `bson:"external_id" json:"external_id"` - Permalink string `bson:"permalink" json:"permalink"` - AuthorHandle string `bson:"author_handle" json:"author_handle"` - Text string `bson:"text" json:"text"` - PostedAt int64 `bson:"posted_at" json:"posted_at"` - Status string `bson:"status" json:"status"` - IntentScore int `bson:"intent_score" json:"intent_score"` - IntentBand string `bson:"intent_band" json:"intent_band"` - Reasons []OpportunityReason `bson:"reasons" json:"reasons"` - RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"` - RegionMatch string `bson:"region_match" json:"region_match"` - FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"` - MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"` - MatchedTerms []string `bson:"matched_terms" json:"matched_terms"` - RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"` - Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"` - ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + WatchID string `bson:"watch_id,omitempty" json:"watch_id,omitempty"` + Source string `bson:"source" json:"source"` + SourceScoutPostID string `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"` + ExternalID string `bson:"external_id" json:"external_id"` + Permalink string `bson:"permalink" json:"permalink"` + AuthorHandle string `bson:"author_handle" json:"author_handle"` + Text string `bson:"text" json:"text"` + PostedAt int64 `bson:"posted_at" json:"posted_at"` + Status string `bson:"status" json:"status"` + IntentScore int `bson:"intent_score" json:"intent_score"` + IntentBand string `bson:"intent_band" json:"intent_band"` + Reasons []OpportunityReason `bson:"reasons" json:"reasons"` + RegionDetected string `bson:"region_detected,omitempty" json:"region_detected,omitempty"` + RegionMatch string `bson:"region_match" json:"region_match"` + FreshnessHours int `bson:"freshness_hours" json:"freshness_hours"` + MatchedService string `bson:"matched_service,omitempty" json:"matched_service,omitempty"` + MatchedTerms []string `bson:"matched_terms" json:"matched_terms"` + RejectReason string `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"` + Override *OpportunityOverride `bson:"override,omitempty" json:"override,omitempty"` + ContactID string `bson:"contact_id,omitempty" json:"contact_id,omitempty"` + PrimaryBrandID string `bson:"primary_brand_id,omitempty" json:"primary_brand_id,omitempty"` + PrimaryProductID string `bson:"primary_product_id,omitempty" json:"primary_product_id,omitempty"` + PrimaryBrandName string `bson:"primary_brand_name,omitempty" json:"primary_brand_name,omitempty"` + PrimaryProductLabel string `bson:"primary_product_label,omitempty" json:"primary_product_label,omitempty"` + PrimaryProductFitScore int `bson:"primary_product_fit_score,omitempty" json:"primary_product_fit_score,omitempty"` + PrimaryProductFitBand string `bson:"primary_product_fit_band,omitempty" json:"primary_product_fit_band,omitempty"` + PrimaryProductOverridden bool `bson:"primary_product_overridden,omitempty" json:"primary_product_overridden,omitempty"` + PrimaryProductOverride *ProductPrimaryOverride `bson:"primary_product_override,omitempty" json:"primary_product_override,omitempty"` + ProductMatches []*ProductMatch `bson:"product_matches,omitempty" json:"product_matches,omitempty"` + ReviewState string `bson:"review_state,omitempty" json:"review_state,omitempty"` + PreviousReviewState string `bson:"previous_review_state,omitempty" json:"previous_review_state,omitempty"` + RemovalReason string `bson:"removal_reason,omitempty" json:"removal_reason,omitempty"` + RemovalNote string `bson:"removal_note,omitempty" json:"removal_note,omitempty"` + RemovedAt int64 `bson:"removed_at,omitempty" json:"removed_at,omitempty"` + RemovedBy int64 `bson:"removed_by,omitempty" json:"removed_by,omitempty"` + LastMatchedAt int64 `bson:"last_matched_at,omitempty" json:"last_matched_at,omitempty"` + PriorityScore int `bson:"priority_score,omitempty" json:"priority_score,omitempty"` + PriorityBand string `bson:"priority_band,omitempty" json:"priority_band,omitempty"` + PainFitScore int `bson:"pain_fit_score,omitempty" json:"pain_fit_score,omitempty"` + DemandIntentScore int `bson:"demand_intent_score,omitempty" json:"demand_intent_score,omitempty"` + EvidenceQualityScore int `bson:"evidence_quality_score,omitempty" json:"evidence_quality_score,omitempty"` + FreshnessScore int `bson:"freshness_score,omitempty" json:"freshness_score,omitempty"` + DemandEvidence []string `bson:"demand_evidence,omitempty" json:"demand_evidence,omitempty"` + DemandInputVersion string `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"` + DemandMapVersion int64 `bson:"demand_map_version,omitempty" json:"demand_map_version,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` } // OpportunityListFilter 支援 band/status/watch/日期區間。 // Status 與 Statuses 擇一:Statuses 非空時用 $in;否則 Status 做單值比對。 type OpportunityListFilter struct { - Band string - Status string - Statuses []string // 多狀態;$in(今日頁:qualified/accepted/dismissed) - WatchID string - CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound - CreatedTo int64 // exclusive, unix ns; 0 = no upper bound - Page int - PageSize int + Band string + Status string + Statuses []string // 多狀態;$in(今日頁:qualified/accepted/dismissed) + WatchID string + CreatedFrom int64 // inclusive, unix ns; 0 = no lower bound + CreatedTo int64 // exclusive, unix ns; 0 = no upper bound + PostedFrom int64 + PostedTo int64 + Page int + PageSize int + BrandID string + ProductID string + FitBand string + MatchState string + Sort string + ReviewState string + TimeScope string + PriorityBand string +} + +type ReviewStatePatch struct { + State string + RemovalReason string + RemovalNote string + DuplicateOpportunityID string + RemovedBy int64 +} + +func IsReviewState(s string) bool { + return s == ReviewPending || s == ReviewCompleted || s == ReviewRemoved +} + +func IsRemovalReason(s string) bool { + switch s { + case RemovalPainMismatch, RemovalProviderAd, RemovalStale, RemovalAlreadySolved, RemovalDuplicate, RemovalOther, RemovalLegacy: + return true + default: + return false + } } // BandFromScore maps intent_score → band with locked 80/50 thresholds. @@ -314,6 +394,31 @@ func (o *Opportunity) ValidateForWrite() error { return fmt.Errorf("%w: unknown intent_band %q", ErrValidation, o.IntentBand) } o.MatchedTerms = NormalizeMatchedTerms(o.MatchedTerms) + seenProducts := map[string]bool{} + for _, match := range o.ProductMatches { + if err := match.ValidateForWrite(); err != nil { + return err + } + if seenProducts[match.ProductID] { + return fmt.Errorf("%w: duplicate product match %q", ErrValidation, match.ProductID) + } + seenProducts[match.ProductID] = true + } + if o.PrimaryProductID != "" { + var primary *ProductMatch + for _, match := range o.ProductMatches { + if match.ProductID == o.PrimaryProductID { + primary = match + break + } + } + if primary == nil { + return fmt.Errorf("%w: primary product must exist in product_matches", ErrValidation) + } + if (!primary.Eligible || primary.Excluded) && o.PrimaryProductOverride == nil { + return fmt.Errorf("%w: weak or excluded primary requires override", ErrValidation) + } + } return nil } diff --git a/apps/backend/internal/module/radar/domain/opportunity_review.go b/apps/backend/internal/module/radar/domain/opportunity_review.go new file mode 100644 index 0000000..59f69cf --- /dev/null +++ b/apps/backend/internal/module/radar/domain/opportunity_review.go @@ -0,0 +1,43 @@ +package domain + +import "strings" + +// ReviewEvent is append-only audit data for the inbox workflow. It is kept +// separate from Opportunity so a transition never rewrites history. +type ReviewEvent struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + OpportunityID string `bson:"opportunity_id" json:"opportunity_id"` + From string `bson:"from" json:"from"` + To string `bson:"to" json:"to"` + Reason string `bson:"reason,omitempty" json:"reason,omitempty"` + Note string `bson:"note,omitempty" json:"note,omitempty"` + ActorUID int64 `bson:"actor_uid" json:"actor_uid"` + At int64 `bson:"at" json:"at"` +} + +func (e *ReviewEvent) Normalize() error { + e.Reason = strings.TrimSpace(e.Reason) + e.Note = strings.TrimSpace(e.Note) + if e.ID == "" { + e.ID = NewID() + } + if e.At == 0 { + e.At = NowNano() + } + if e.OwnerUID <= 0 || e.OpportunityID == "" || !IsReviewState(e.To) { + return ErrValidation + } + return nil +} + +func LegacyReviewState(status string) (state, reason string) { + switch status { + case OppAccepted: + return ReviewCompleted, "" + case OppDismissed: + return ReviewRemoved, RemovalLegacy + default: + return ReviewPending, "" + } +} diff --git a/apps/backend/internal/module/radar/domain/product_match.go b/apps/backend/internal/module/radar/domain/product_match.go new file mode 100644 index 0000000..cd73b10 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/product_match.go @@ -0,0 +1,141 @@ +package domain + +import ( + "fmt" + "strings" +) + +const ( + ProductFitBandStrong = "strong" + ProductFitBandPossible = "possible" + ProductFitBandWeak = "weak" + ProductFitPain = "pain" + ProductFitScenario = "scenario" + ProductFitAudience = "audience" + ProductFitCapability = "capability" +) + +type ProductFitReason struct { + Dimension string `bson:"dimension" json:"dimension"` + Score int `bson:"score" json:"score"` + Reason string `bson:"reason" json:"reason"` + CandidateExcerpt string `bson:"candidate_excerpt,omitempty" json:"candidate_excerpt,omitempty"` + ProductBasis string `bson:"product_basis,omitempty" json:"product_basis,omitempty"` +} + +type ProductMatch struct { + BrandID string `bson:"brand_id" json:"brand_id"` + ProductID string `bson:"product_id" json:"product_id"` + BrandNameSnapshot string `bson:"brand_name_snapshot" json:"brand_name_snapshot"` + ProductLabelSnapshot string `bson:"product_label_snapshot" json:"product_label_snapshot"` + BrandUpdatedAt int64 `bson:"brand_updated_at" json:"brand_updated_at"` + ProductUpdatedAt int64 `bson:"product_updated_at" json:"product_updated_at"` + ProductFitScore int `bson:"product_fit_score" json:"product_fit_score"` + ProductFitBand string `bson:"product_fit_band" json:"product_fit_band"` + Eligible bool `bson:"eligible" json:"eligible"` + Excluded bool `bson:"excluded" json:"excluded"` + ExcludeReason string `bson:"exclude_reason,omitempty" json:"exclude_reason,omitempty"` + Reasons []ProductFitReason `bson:"reasons" json:"reasons"` + Risks []string `bson:"risks" json:"risks"` + WatchIDs []string `bson:"watch_ids" json:"watch_ids"` + MatchedTerms []string `bson:"matched_terms" json:"matched_terms"` + MatchedAt int64 `bson:"matched_at" json:"matched_at"` +} + +var productFitDimensions = map[string]int{ + ProductFitPain: 35, ProductFitScenario: 25, ProductFitAudience: 20, ProductFitCapability: 20, +} + +func ProductFitBandFromScore(score int) string { + if score >= 75 { + return ProductFitBandStrong + } + if score >= 45 { + return ProductFitBandPossible + } + return ProductFitBandWeak +} + +func IsProductFitBand(s string) bool { + return s == ProductFitBandStrong || s == ProductFitBandPossible || s == ProductFitBandWeak +} + +// ValidateForWrite enforces four dimensions, bounded scores, and evidence for +// every positive claim. Product name alone is therefore never enough. +func (m *ProductMatch) ValidateForWrite() error { + if m == nil || strings.TrimSpace(m.BrandID) == "" || strings.TrimSpace(m.ProductID) == "" { + return fmt.Errorf("%w: product match requires brand_id and product_id", ErrValidation) + } + if len(m.Reasons) != len(productFitDimensions) { + return fmt.Errorf("%w: product match requires four fit reasons", ErrValidation) + } + seen := map[string]bool{} + total, painOrScenario := 0, false + for _, reason := range m.Reasons { + max, ok := productFitDimensions[reason.Dimension] + if !ok || seen[reason.Dimension] { + return fmt.Errorf("%w: invalid or duplicate product fit dimension %q", ErrValidation, reason.Dimension) + } + if reason.Score < 0 || reason.Score > max { + return fmt.Errorf("%w: product fit score for %s out of range", ErrValidation, reason.Dimension) + } + if strings.TrimSpace(reason.Reason) == "" { + return fmt.Errorf("%w: product fit reason %s is empty", ErrValidation, reason.Dimension) + } + if reason.Score > 0 && (strings.TrimSpace(reason.CandidateExcerpt) == "" || strings.TrimSpace(reason.ProductBasis) == "") { + return fmt.Errorf("%w: positive product fit reason %s requires excerpt and basis", ErrValidation, reason.Dimension) + } + if reason.Score > 0 && (reason.Dimension == ProductFitPain || reason.Dimension == ProductFitScenario) { + painOrScenario = true + } + seen[reason.Dimension], total = true, total+reason.Score + } + if total != m.ProductFitScore || total > 100 { + return fmt.Errorf("%w: product fit score does not equal reason total", ErrValidation) + } + if m.ProductFitBand == "" { + m.ProductFitBand = ProductFitBandFromScore(total) + } + if !IsProductFitBand(m.ProductFitBand) || m.ProductFitBand != ProductFitBandFromScore(total) { + return fmt.Errorf("%w: product fit band does not match score", ErrValidation) + } + if m.Excluded && strings.TrimSpace(m.ExcludeReason) == "" { + return fmt.Errorf("%w: excluded product match requires exclude_reason", ErrValidation) + } + m.Eligible = total >= 45 && painOrScenario && !m.Excluded + m.WatchIDs = uniqueStrings(m.WatchIDs) + m.MatchedTerms = NormalizeMatchedTerms(m.MatchedTerms) + if m.Risks == nil { + m.Risks = []string{} + } + if m.WatchIDs == nil { + m.WatchIDs = []string{} + } + if m.MatchedTerms == nil { + m.MatchedTerms = []string{} + } + return nil +} + +func uniqueStrings(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]bool{} + for _, raw := range in { + s := strings.TrimSpace(raw) + if s != "" && !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +func CloneProductMatch(m *ProductMatch) *ProductMatch { + if m == nil { + return nil + } + cp := *m + cp.Reasons = append([]ProductFitReason(nil), m.Reasons...) + cp.Risks, cp.WatchIDs, cp.MatchedTerms = append([]string(nil), m.Risks...), append([]string(nil), m.WatchIDs...), append([]string(nil), m.MatchedTerms...) + return &cp +} diff --git a/apps/backend/internal/module/radar/domain/product_match_test.go b/apps/backend/internal/module/radar/domain/product_match_test.go new file mode 100644 index 0000000..bc12928 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/product_match_test.go @@ -0,0 +1,63 @@ +package domain + +import "testing" + +func fitReasons() []ProductFitReason { + return []ProductFitReason{ + {Dimension: ProductFitPain, Score: 30, Reason: "貼文描述漏水", CandidateExcerpt: "天花板一直漏水", ProductBasis: "漏水"}, + {Dimension: ProductFitScenario, Score: 20, Reason: "有居家處理情境", CandidateExcerpt: "想找人處理", ProductBasis: "居家修繕"}, + {Dimension: ProductFitAudience, Score: 10, Reason: "文字支持自住家庭", CandidateExcerpt: "家裡", ProductBasis: "自住家庭"}, + {Dimension: ProductFitCapability, Score: 10, Reason: "能力詞可處理", CandidateExcerpt: "抓漏", ProductBasis: "抓漏"}, + } +} + +func TestProductMatchFitBandsAndEligibility(t *testing.T) { + cases := []struct { + score int + band string + eligible bool + }{ + {100, ProductFitBandStrong, true}, {75, ProductFitBandStrong, true}, {74, ProductFitBandPossible, true}, {45, ProductFitBandPossible, true}, {44, ProductFitBandWeak, false}, + } + for _, tc := range cases { + m := &ProductMatch{BrandID: "b", ProductID: "p", ProductFitScore: tc.score, ProductFitBand: ProductFitBandFromScore(tc.score), Reasons: fitReasons()} + // Adapt reason total to table boundary while keeping evidence contract. + m.Reasons[0].Score, m.Reasons[1].Score, m.Reasons[2].Score, m.Reasons[3].Score = tc.score, 0, 0, 0 + if tc.score > 35 { + m.Reasons[0].Score = 35 + m.Reasons[1].Score = tc.score - 35 + } + if m.Reasons[1].Score > 25 { + m.Reasons[1].Score = 25 + m.Reasons[2].Score = tc.score - 60 + } + if m.Reasons[2].Score > 20 { + m.Reasons[2].Score = 20 + m.Reasons[3].Score = tc.score - 80 + } + if tc.score > 0 { + for i := range m.Reasons { + if m.Reasons[i].Score == 0 { + m.Reasons[i].CandidateExcerpt = "證據" + m.Reasons[i].ProductBasis = "依據" + } + } + } + if err := m.ValidateForWrite(); err != nil { + t.Fatalf("score %d: %v", tc.score, err) + } + if m.ProductFitBand != tc.band || m.Eligible != tc.eligible { + t.Fatalf("score %d got band=%s eligible=%v", tc.score, m.ProductFitBand, m.Eligible) + } + } +} + +func TestProductMatchPositiveReasonNeedsEvidence(t *testing.T) { + m := &ProductMatch{BrandID: "b", ProductID: "p", ProductFitScore: 10, Reasons: fitReasons()} + m.Reasons[0].Score = 10 + m.Reasons[1].Score, m.Reasons[2].Score, m.Reasons[3].Score = 0, 0, 0 + m.Reasons[0].CandidateExcerpt = "" + if err := m.ValidateForWrite(); err == nil { + t.Fatal("positive reason without excerpt must fail") + } +} diff --git a/apps/backend/internal/module/radar/domain/product_primary.go b/apps/backend/internal/module/radar/domain/product_primary.go new file mode 100644 index 0000000..47105f0 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/product_primary.go @@ -0,0 +1,111 @@ +package domain + +import ( + "strconv" + "strings" +) + +// ProductMatchPreferred is the stable arbitration order from spec §3.3. +func ProductMatchPreferred(candidate, best *ProductMatch) bool { + if candidate == nil || !candidate.Eligible || candidate.Excluded { + return false + } + if best == nil || !best.Eligible || best.Excluded { + return true + } + if candidate.ProductFitScore != best.ProductFitScore { + return candidate.ProductFitScore > best.ProductFitScore + } + if candidate.ProductFitBand != best.ProductFitBand { + return candidate.ProductFitBand == ProductFitBandStrong + } + if candidate.MatchedAt != best.MatchedAt { + return candidate.MatchedAt < best.MatchedAt + } + return candidate.ProductID < best.ProductID +} + +func BestEligibleProductMatch(matches []*ProductMatch) *ProductMatch { + var best *ProductMatch + for _, match := range matches { + if ProductMatchPreferred(match, best) { + best = match + } + } + return best +} + +// ProductFitToIntentFit keeps the existing five-question score scale while +// changing only its fit dimension when the primary product changes. +func ProductFitToIntentFit(productScore int) int { + if productScore <= 0 { + return 0 + } + if productScore > 100 { + productScore = 100 + } + return (productScore*WeightFit + 50) / 100 +} + +// ApplyPrimaryProduct selects an eligible product (unless a human override is +// already present), copies its snapshot fields, and recomputes only the intent +// fit component. Other four reasons are retained verbatim. +func ApplyPrimaryProduct(o *Opportunity) { + if o == nil { + return + } + var primary *ProductMatch + if o.PrimaryProductOverridden && o.PrimaryProductID != "" { + for _, match := range o.ProductMatches { + if match != nil && match.ProductID == o.PrimaryProductID { + primary = match + break + } + } + } else { + primary = BestEligibleProductMatch(o.ProductMatches) + } + if primary == nil { + return + } + o.PrimaryBrandID, o.PrimaryProductID = primary.BrandID, primary.ProductID + o.PrimaryBrandName, o.PrimaryProductLabel = primary.BrandNameSnapshot, primary.ProductLabelSnapshot + o.PrimaryProductFitScore, o.PrimaryProductFitBand = primary.ProductFitScore, primary.ProductFitBand + fitScore := ProductFitToIntentFit(primary.ProductFitScore) + found := false + for i := range o.Reasons { + if o.Reasons[i].Dimension == DimFit { + o.Reasons[i].Score = fitScore + label := strings.TrimSpace(primary.ProductLabelSnapshot) + if label == "" { + label = primary.ProductID + } + o.Reasons[i].Reason = "主推產品「" + label + "」適配 " + formatProductScore(primary.ProductFitScore) + ",換算產品 fit " + formatProductScore(fitScore) + "/10" + found = true + break + } + } + if !found { + o.Reasons = append(o.Reasons, OpportunityReason{Dimension: DimFit, Score: fitScore, Reason: "主推產品適配分數換算產品 fit"}) + } + total := 0 + for _, reason := range o.Reasons { + total += reason.Score + } + o.IntentScore = SumReasonScores(o.Reasons) + if total != o.IntentScore { + o.IntentScore = total + if o.IntentScore > 100 { + o.IntentScore = 100 + } + } + o.IntentBand = BandFromScore(o.IntentScore) +} + +func formatProductScore(score int) string { + // Keep the helper local to avoid introducing a presentation dependency. + if score < 0 { + score = 0 + } + return strconv.Itoa(score) +} diff --git a/apps/backend/internal/module/radar/domain/product_scoring.go b/apps/backend/internal/module/radar/domain/product_scoring.go new file mode 100644 index 0000000..28c8204 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/product_scoring.go @@ -0,0 +1,37 @@ +package domain + +// Product-fit scoring is deliberately a small exported contract so the +// deterministic scorer and later AI judge cannot silently drift in weights. +const ( + ProductFitPainWeight = 35 + ProductFitScenarioWeight = 25 + ProductFitAudienceWeight = 20 + ProductFitCapabilityWeight = 20 + ProductFitEligibleMinScore = 45 +) + +var ProductFitDimensionOrder = []string{ + ProductFitPain, + ProductFitScenario, + ProductFitAudience, + ProductFitCapability, +} + +func ProductFitDimensionWeight(dimension string) int { + switch dimension { + case ProductFitPain: + return ProductFitPainWeight + case ProductFitScenario: + return ProductFitScenarioWeight + case ProductFitAudience: + return ProductFitAudienceWeight + case ProductFitCapability: + return ProductFitCapabilityWeight + default: + return 0 + } +} + +func ProductFitEligible(score, pain, scenario int, excluded bool) bool { + return score >= ProductFitEligibleMinScore && (pain > 0 || scenario > 0) && !excluded +} diff --git a/apps/backend/internal/module/radar/domain/query_plan.go b/apps/backend/internal/module/radar/domain/query_plan.go new file mode 100644 index 0000000..59931e4 --- /dev/null +++ b/apps/backend/internal/module/radar/domain/query_plan.go @@ -0,0 +1,18 @@ +package domain + +// QueryPlan is the reviewed search intent compiled from a DemandMap. It is +// persisted with both versions so a sweep can prove which product truth it +// searched, and can safely discard a stale plan after an edit. +type QueryPlan struct { + DemandInputVersion string `json:"demand_input_version"` + MapVersion int64 `json:"map_version"` + Groups []QueryPlanGroup `json:"groups"` +} + +type QueryPlanGroup struct { + Query string `json:"query"` + Include []string `json:"include"` + Exclude []string `json:"exclude"` + BasisKinds []string `json:"basis_kinds"` + BasisTexts []string `json:"basis_texts"` +} diff --git a/apps/backend/internal/module/radar/domain/repository.go b/apps/backend/internal/module/radar/domain/repository.go index 47b4abf..4d99f72 100644 --- a/apps/backend/internal/module/radar/domain/repository.go +++ b/apps/backend/internal/module/radar/domain/repository.go @@ -11,9 +11,14 @@ type Repository interface { GetServiceProfile(ctx context.Context, ownerUID int64) (*ServiceProfile, error) SaveServiceProfile(ctx context.Context, p *ServiceProfile) error + // DemandMap is the product-specific, user-editable search contract. + GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*DemandMap, error) + SaveDemandMap(ctx context.Context, m *DemandMap, expectedVersion int64) (*DemandMap, error) + // RadarWatch SaveWatch(ctx context.Context, w *RadarWatch) error GetWatch(ctx context.Context, id string) (*RadarWatch, error) + DeleteWatch(ctx context.Context, id string) error ListWatches(ctx context.Context, ownerUID int64, f WatchListFilter) ([]*RadarWatch, int64, error) // ListActiveWatches 是每日排程的來源,只回 active。 ListActiveWatches(ctx context.Context, ownerUID int64) ([]*RadarWatch, error) @@ -25,9 +30,14 @@ type Repository interface { // Opportunity // UpsertByExternalID:同 owner+external_id 只留一筆;命中則併 matched_terms,不重跑判定。 UpsertByExternalID(ctx context.Context, o *Opportunity) (*Opportunity, error) + // MergeProductMatch atomically adds a new ProductMatch or merges only its + // watch/term source sets when that product was already evaluated. + MergeProductMatch(ctx context.Context, ownerUID int64, opportunityID string, match *ProductMatch) (*Opportunity, error) + SetPrimaryProduct(ctx context.Context, ownerUID int64, opportunityID, productID string, override *ProductPrimaryOverride) (*Opportunity, error) GetOpportunity(ctx context.Context, id string) (*Opportunity, error) GetByExternalID(ctx context.Context, ownerUID int64, externalID string) (*Opportunity, error) ListOpportunities(ctx context.Context, ownerUID int64, f OpportunityListFilter) ([]*Opportunity, int64, error) + UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, opportunityID string, patch ReviewStatePatch) (*Opportunity, error) // CountToday 回傳 UTC 當日建立的商機數(每日配額池)。 CountToday(ctx context.Context, ownerUID int64, at int64) (int64, error) UpdateOpportunityStatus(ctx context.Context, id string, status string) error diff --git a/apps/backend/internal/module/radar/domain/suggest.go b/apps/backend/internal/module/radar/domain/suggest.go index 5871d91..f386bab 100644 --- a/apps/backend/internal/module/radar/domain/suggest.go +++ b/apps/backend/internal/module/radar/domain/suggest.go @@ -6,11 +6,27 @@ const ( SuggestUsageInclude = "include" SuggestUsageExclude = "exclude" + SuggestBasisAudience = "audience" + SuggestBasisPain = "pain" + SuggestBasisContext = "context" + SuggestBasisTag = "tag" + SuggestBasisCapability = "capability" + SuggestBasisExclude = "exclude" + // 建議數量上限:清單要能一眼看完並逐條決定,不是丟一大串讓人放棄。 MaxSuggestions = 20 DefaultSuggestions = 8 ) +func IsSuggestionBasisKind(kind string) bool { + switch strings.ToLower(strings.TrimSpace(kind)) { + case SuggestBasisAudience, SuggestBasisPain, SuggestBasisContext, SuggestBasisTag, SuggestBasisCapability, SuggestBasisExclude: + return true + default: + return false + } +} + /* WatchTermSuggestion 是一則關鍵字建議。 @@ -18,9 +34,11 @@ Reason 是必填的:使用者要逐條決定採不採用,看不到「為什 那這個功能就退化成一個猜測產生器。 */ type WatchTermSuggestion struct { - Term string `json:"term"` - Reason string `json:"reason"` - Usage string `json:"usage"` + Term string `json:"term"` + Reason string `json:"reason"` + Usage string `json:"usage"` + BasisKind string `json:"basis_kind,omitempty"` + BasisText string `json:"basis_text,omitempty"` } func NormalizeSuggestUsage(s string) string { @@ -66,7 +84,10 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion continue } seen[key] = true - out = append(out, WatchTermSuggestion{Term: term, Reason: reason, Usage: usage}) + out = append(out, WatchTermSuggestion{ + Term: term, Reason: reason, Usage: usage, + BasisKind: strings.TrimSpace(s.BasisKind), BasisText: strings.TrimSpace(s.BasisText), + }) if len(out) >= limit { break } diff --git a/apps/backend/internal/module/radar/domain/sweep.go b/apps/backend/internal/module/radar/domain/sweep.go index aa5e633..15e89e4 100644 --- a/apps/backend/internal/module/radar/domain/sweep.go +++ b/apps/backend/internal/module/radar/domain/sweep.go @@ -18,32 +18,62 @@ RadarSweep 是一次每日巡(或手動觸發)的執行紀錄。 (judged_external_ids 標記已判過的貼文,避免重跑)。 */ type RadarSweep struct { - ID string `bson:"_id" json:"id"` - OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` - WatchID string `bson:"watch_id" json:"watch_id"` - JobID string `bson:"job_id,omitempty" json:"job_id,omitempty"` - Path string `bson:"path" json:"path"` - HitCount int `bson:"hit_count" json:"hit_count"` - JudgedCount int `bson:"judged_count" json:"judged_count"` - CreatedCount int `bson:"created_count" json:"created_count"` - TruncatedCount int `bson:"truncated_count" json:"truncated_count"` - FailedReason string `bson:"failed_reason,omitempty" json:"failed_reason,omitempty"` - CreditsUsed int `bson:"credits_used" json:"credits_used"` - JudgedExternalIDs []string `bson:"judged_external_ids,omitempty" json:"judged_external_ids,omitempty"` - StartedAt int64 `bson:"started_at" json:"started_at"` - EndedAt int64 `bson:"ended_at,omitempty" json:"ended_at,omitempty"` + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + WatchID string `bson:"watch_id" json:"watch_id"` + JobID string `bson:"job_id,omitempty" json:"job_id,omitempty"` + Path string `bson:"path" json:"path"` + HitCount int `bson:"hit_count" json:"hit_count"` + JudgedCount int `bson:"judged_count" json:"judged_count"` + CreatedCount int `bson:"created_count" json:"created_count"` + TruncatedCount int `bson:"truncated_count" json:"truncated_count"` + MatchEvaluatedCount int `bson:"match_evaluated_count" json:"match_evaluated_count"` + MatchMergedCount int `bson:"match_merged_count" json:"match_merged_count"` + FitRejectedCount int `bson:"fit_rejected_count" json:"fit_rejected_count"` + FailedReason string `bson:"failed_reason,omitempty" json:"failed_reason,omitempty"` + CreditsUsed int `bson:"credits_used" json:"credits_used"` + DedupedCount int `bson:"deduped_count" json:"deduped_count"` + PrefilterPassCount int `bson:"prefilter_pass_count" json:"prefilter_pass_count"` + PrefilterReviewCount int `bson:"prefilter_review_count" json:"prefilter_review_count"` + PrefilterRejectedCount int `bson:"prefilter_rejected_count" json:"prefilter_rejected_count"` + CachedJudgmentCount int `bson:"cached_judgment_count" json:"cached_judgment_count"` + TombstoneMatchedCount int `bson:"tombstone_matched_count" json:"tombstone_matched_count"` + BudgetDeferredCount int `bson:"budget_deferred_count" json:"budget_deferred_count"` + DemandInputVersion string `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"` + DemandMapVersion int64 `bson:"demand_map_version,omitempty" json:"demand_map_version,omitempty"` + CreditSearch int `bson:"credit_search" json:"credit_search"` + CreditDemandMap int `bson:"credit_demand_map" json:"credit_demand_map"` + CreditJudge int `bson:"credit_judge" json:"credit_judge"` + CreditReply int `bson:"credit_reply" json:"credit_reply"` + JudgedExternalIDs []string `bson:"judged_external_ids,omitempty" json:"judged_external_ids,omitempty"` + StartedAt int64 `bson:"started_at" json:"started_at"` + EndedAt int64 `bson:"ended_at,omitempty" json:"ended_at,omitempty"` } // SweepDelta is an incremental progress patch applied with $inc / $addToSet. type SweepDelta struct { - HitCount int - JudgedCount int - CreatedCount int - TruncatedCount int - CreditsUsed int - JudgedExternalIDs []string - FailedReason *string // nil = leave unchanged; non-nil (incl. empty) = set - EndedAt int64 // 0 = leave unchanged + HitCount int + JudgedCount int + CreatedCount int + TruncatedCount int + MatchEvaluatedCount int + MatchMergedCount int + FitRejectedCount int + CreditsUsed int + DedupedCount int + PrefilterPassCount int + PrefilterReviewCount int + PrefilterRejectedCount int + CachedJudgmentCount int + TombstoneMatchedCount int + BudgetDeferredCount int + CreditSearch int + CreditDemandMap int + CreditJudge int + CreditReply int + JudgedExternalIDs []string + FailedReason *string // nil = leave unchanged; non-nil (incl. empty) = set + EndedAt int64 // 0 = leave unchanged } // SweepListFilter pages sweeps for an owner, optionally scoped to one watch. diff --git a/apps/backend/internal/module/radar/domain/watch.go b/apps/backend/internal/module/radar/domain/watch.go index d077046..d385897 100644 --- a/apps/backend/internal/module/radar/domain/watch.go +++ b/apps/backend/internal/module/radar/domain/watch.go @@ -10,10 +10,15 @@ const ( WatchPaused = "paused" WatchArchived = "archived" - MaxWatchTerms = 20 - MaxWatchExcludeTerms = 30 - MaxTermLen = 60 - MinTermLen = 2 + MaxWatchTerms = 20 + MaxWatchExcludeTerms = 30 + MaxTermLen = 60 + MinTermLen = 2 + WatchContextGeneric = "generic" + WatchContextProduct = "product" + PauseReasonUser = "user" + PauseReasonProductUnavailable = "product_unavailable" + PauseReasonBrandUnavailable = "brand_unavailable" ) /* @@ -23,15 +28,22 @@ regions 留空代表沿用服務檔案的地區 —— 這裡刻意不把服務 否則之後改服務檔案,舊訂閱會繼續用舊地區判定,而使用者不會知道。 */ type RadarWatch struct { - ID string `bson:"_id" json:"id"` - OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` - Terms []string `bson:"terms" json:"terms"` - ExcludeTerms []string `bson:"exclude_terms" json:"exclude_terms"` - Regions []string `bson:"regions" json:"regions"` - Status string `bson:"status" json:"status"` - LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` - UpdatedAt int64 `bson:"updated_at" json:"updated_at"` + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + Terms []string `bson:"terms" json:"terms"` + ExcludeTerms []string `bson:"exclude_terms" json:"exclude_terms"` + Regions []string `bson:"regions" json:"regions"` + Status string `bson:"status" json:"status"` + ContextMode string `bson:"context_mode,omitempty" json:"context_mode,omitempty"` + BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"` + ProductID string `bson:"product_id,omitempty" json:"product_id,omitempty"` + BrandNameSnapshot string `bson:"brand_name_snapshot,omitempty" json:"brand_name_snapshot,omitempty"` + ProductLabelSnapshot string `bson:"product_label_snapshot,omitempty" json:"product_label_snapshot,omitempty"` + ContextBoundAt int64 `bson:"context_bound_at,omitempty" json:"context_bound_at,omitempty"` + PauseReason string `bson:"pause_reason,omitempty" json:"pause_reason,omitempty"` + LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` // FirstSweepTriggered 是建立當下的一次性狀態,不落庫:只用來讓前端在 // 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。 @@ -39,9 +51,62 @@ type RadarWatch struct { } type WatchListFilter struct { - Status string - Page int - PageSize int + Status string + ContextMode string + BrandID string + ProductID string + Page int + PageSize int +} + +func IsWatchContext(s string) bool { return s == WatchContextGeneric || s == WatchContextProduct } + +func IsWatchPauseReason(s string) bool { + return s == "" || s == PauseReasonUser || s == PauseReasonProductUnavailable || s == PauseReasonBrandUnavailable +} + +// NormalizeContext makes pre-product documents read as generic and validates +// the all-or-nothing product identity invariant. +func (w *RadarWatch) NormalizeContext() error { + if w.ContextMode == "" { + w.ContextMode = WatchContextGeneric + } + if !IsWatchContext(w.ContextMode) { + return fmt.Errorf("%w: unknown context_mode %q", ErrValidation, w.ContextMode) + } + if !IsWatchPauseReason(w.PauseReason) { + return fmt.Errorf("%w: unknown pause_reason %q", ErrValidation, w.PauseReason) + } + if w.ContextMode == WatchContextGeneric { + if w.BrandID != "" || w.ProductID != "" { + return fmt.Errorf("%w: generic watch cannot carry product IDs", ErrValidation) + } + return nil + } + if strings.TrimSpace(w.BrandID) == "" || strings.TrimSpace(w.ProductID) == "" { + return fmt.Errorf("%w: product watch requires brand_id and product_id", ErrValidation) + } + if w.ContextBoundAt <= 0 { + w.ContextBoundAt = w.UpdatedAt + } + return nil +} + +// BindProduct is one-way: replacing a product would make historical sweeps ambiguous. +func (w *RadarWatch) BindProduct(brandID, productID, brandSnapshot, productSnapshot string, at int64) error { + if err := w.NormalizeContext(); err != nil { + return err + } + if w.ContextMode != WatchContextGeneric || w.BrandID != "" || w.ProductID != "" { + return fmt.Errorf("%w: watch product context is already bound", ErrValidation) + } + if strings.TrimSpace(brandID) == "" || strings.TrimSpace(productID) == "" { + return fmt.Errorf("%w: brand_id and product_id must be provided together", ErrValidation) + } + w.ContextMode, w.BrandID, w.ProductID = WatchContextProduct, strings.TrimSpace(brandID), strings.TrimSpace(productID) + w.BrandNameSnapshot, w.ProductLabelSnapshot, w.ContextBoundAt = strings.TrimSpace(brandSnapshot), strings.TrimSpace(productSnapshot), at + w.UpdatedAt = at + return nil } func IsWatchStatus(s string) bool { @@ -84,7 +149,16 @@ func (w *RadarWatch) Transition(to string) error { } return fmt.Errorf("%w: cannot change watch from %s to %s", ErrValidation, w.Status, to) } + if to == WatchActive && (w.PauseReason == PauseReasonProductUnavailable || w.PauseReason == PauseReasonBrandUnavailable) { + return fmt.Errorf("%w: unavailable product watch must be archived and recreated", ErrValidation) + } w.Status = to + if to == WatchActive { + w.PauseReason = "" + } + if to == WatchPaused && w.PauseReason == "" { + w.PauseReason = PauseReasonUser + } w.UpdatedAt = NowNano() return nil } @@ -150,6 +224,9 @@ func (w *RadarWatch) Normalize() error { if !IsWatchStatus(w.Status) { return fmt.Errorf("%w: unknown watch status %q", ErrValidation, w.Status) } + if err := w.NormalizeContext(); err != nil { + return err + } return nil } diff --git a/apps/backend/internal/module/radar/domain/watch_context_test.go b/apps/backend/internal/module/radar/domain/watch_context_test.go new file mode 100644 index 0000000..8fc18fd --- /dev/null +++ b/apps/backend/internal/module/radar/domain/watch_context_test.go @@ -0,0 +1,41 @@ +package domain + +import ( + "errors" + "testing" +) + +func contextWatch() *RadarWatch { + return &RadarWatch{ID: "w", OwnerUID: 7, Terms: []string{"找水電"}, Status: WatchActive, UpdatedAt: 10} +} + +func TestWatchContextLegacyAndPairInvariant(t *testing.T) { + w := contextWatch() + if err := w.Normalize(); err != nil { + t.Fatal(err) + } + if w.ContextMode != WatchContextGeneric { + t.Fatalf("legacy mode=%q", w.ContextMode) + } + w.BrandID = "brand-only" + if err := w.NormalizeContext(); !errors.Is(err, ErrValidation) { + t.Fatalf("expected paired ID validation, got %v", err) + } +} + +func TestWatchProductBindingIsOneWay(t *testing.T) { + w := contextWatch() + if err := w.Normalize(); err != nil { + t.Fatal(err) + } + if err := w.BindProduct("b1", "p1", "Brand", "Product", 20); err != nil { + t.Fatal(err) + } + if err := w.BindProduct("b2", "p2", "Other", "Other", 30); !errors.Is(err, ErrValidation) { + t.Fatalf("expected conflict, got %v", err) + } + w.Status, w.PauseReason = WatchPaused, PauseReasonProductUnavailable + if err := w.Transition(WatchActive); !errors.Is(err, ErrValidation) { + t.Fatalf("expected unavailable resume guard, got %v", err) + } +} diff --git a/apps/backend/internal/module/radar/repository/demand_map_memory.go b/apps/backend/internal/module/radar/repository/demand_map_memory.go new file mode 100644 index 0000000..d574fad --- /dev/null +++ b/apps/backend/internal/module/radar/repository/demand_map_memory.go @@ -0,0 +1,72 @@ +package repository + +import ( + "context" + "fmt" + + "apps/backend/internal/module/radar/domain" +) + +func demandMapKey(ownerUID int64, productID string) string { + return fmt.Sprintf("%d\x00%s", ownerUID, productID) +} + +func (m *Memory) GetDemandMap(_ context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) { + m.mu.Lock() + defer m.mu.Unlock() + value, ok := m.demandMaps[demandMapKey(ownerUID, productID)] + if !ok { + return nil, domain.ErrNotFound + } + return cloneDemandMap(value), nil +} + +func (m *Memory) SaveDemandMap(_ context.Context, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) { + if value == nil { + return nil, domain.ErrValidation + } + if err := value.Normalize(); err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + key := demandMapKey(value.OwnerUID, value.ProductID) + existing, exists := m.demandMaps[key] + if exists { + if expectedVersion != existing.MapVersion { + return nil, domain.ErrConflict + } + value.MapVersion = existing.MapVersion + 1 + } else { + if expectedVersion != 0 { + return nil, domain.ErrConflict + } + if value.MapVersion < 1 { + value.MapVersion = 1 + } + } + if value.UpdatedAt == 0 { + value.UpdatedAt = domain.NowNano() + } + if value.ID == "" { + value.ID = domain.NewID() + } + stored := cloneDemandMap(value) + m.demandMaps[key] = stored + return cloneDemandMap(stored), nil +} + +func cloneDemandMap(value *domain.DemandMap) *domain.DemandMap { + if value == nil { + return nil + } + out := *value + out.PainPhrases = append([]domain.DemandMapPhrase(nil), value.PainPhrases...) + out.ScenarioPhrases = append([]domain.DemandMapPhrase(nil), value.ScenarioPhrases...) + out.DesiredOutcomes = append([]domain.DemandMapPhrase(nil), value.DesiredOutcomes...) + out.SolutionSignals = append([]domain.DemandMapPhrase(nil), value.SolutionSignals...) + out.ExclusionSignals = append([]domain.DemandMapPhrase(nil), value.ExclusionSignals...) + out.SourceBasis = append([]string(nil), value.SourceBasis...) + out.CustomPhrases = append([]domain.DemandMapPhrase(nil), value.CustomPhrases...) + return &out +} diff --git a/apps/backend/internal/module/radar/repository/demand_map_mongo.go b/apps/backend/internal/module/radar/repository/demand_map_mongo.go new file mode 100644 index 0000000..1d8cbcb --- /dev/null +++ b/apps/backend/internal/module/radar/repository/demand_map_mongo.go @@ -0,0 +1,76 @@ +package repository + +import ( + "context" + + "apps/backend/internal/module/radar/domain" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func (s *MonStore) GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) { + var value domain.DemandMap + err := s.demandMaps.FindOne(ctx, &value, bson.M{"owner_uid": ownerUID, "product_id": productID}) + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + return &value, nil +} + +// SaveDemandMap implements optimistic concurrency. expectedVersion=0 is only +// valid for the first insert; every subsequent write must name the version the +// editor read, preventing an older browser tab from overwriting newer phrases. +func (s *MonStore) SaveDemandMap(ctx context.Context, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) { + if value == nil { + return nil, domain.ErrValidation + } + if err := value.Normalize(); err != nil { + return nil, err + } + existing, err := s.GetDemandMap(ctx, value.OwnerUID, value.ProductID) + if err != nil && err != domain.ErrNotFound { + return nil, err + } + if existing == nil { + if expectedVersion != 0 { + return nil, domain.ErrConflict + } + value.MapVersion = 1 + if value.ID == "" { + value.ID = domain.NewID() + } + } else { + if existing.MapVersion != expectedVersion { + return nil, domain.ErrConflict + } + value.ID = existing.ID + value.MapVersion = existing.MapVersion + 1 + } + if value.UpdatedAt == 0 { + value.UpdatedAt = domain.NowNano() + } + if existing == nil { + _, err = s.demandMaps.InsertOne(ctx, value) + } else { + res, rerr := s.demandMaps.ReplaceOne(ctx, + bson.M{"owner_uid": value.OwnerUID, "product_id": value.ProductID, "map_version": expectedVersion}, + value, options.Replace()) + if rerr == nil && res.MatchedCount == 0 { + rerr = domain.ErrConflict + } + err = rerr + } + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, domain.ErrConflict + } + return nil, err + } + return s.GetDemandMap(ctx, value.OwnerUID, value.ProductID) +} diff --git a/apps/backend/internal/module/radar/repository/opportunity_memory.go b/apps/backend/internal/module/radar/repository/opportunity_memory.go index 2e48d59..e31acae 100644 --- a/apps/backend/internal/module/radar/repository/opportunity_memory.go +++ b/apps/backend/internal/module/radar/repository/opportunity_memory.go @@ -20,28 +20,39 @@ func (m *Memory) UpsertByExternalID(_ context.Context, o *domain.Opportunity) (* existing := m.opportunities[id] // Hit: merge matched_terms only — do not re-judge or overwrite status/score. existing.MatchedTerms = domain.MergeMatchedTerms(existing.MatchedTerms, o.MatchedTerms) - existing.UpdatedAt = domain.NowNano() - cp := *existing - cp.Reasons = append([]domain.OpportunityReason(nil), existing.Reasons...) - cp.MatchedTerms = append([]string(nil), existing.MatchedTerms...) - if existing.Override != nil { - ov := *existing.Override - cp.Override = &ov + for _, incoming := range o.ProductMatches { + if err := incoming.ValidateForWrite(); err != nil { + return nil, err + } + mergeMemoryProductMatch(existing, incoming) } - return &cp, nil + existing.LastMatchedAt = domain.NowNano() + arbitrateMemoryPrimary(existing) + existing.UpdatedAt = domain.NowNano() + return cloneOpportunity(existing), nil } if err := o.ValidateForWrite(); err != nil { return nil, err } + domain.ApplyPrimaryProduct(o) if o.ID == "" { o.ID = domain.NewID() } + if o.ReviewState == "" { + o.ReviewState = reviewStateFor(o) + } + if !domain.IsReviewState(o.ReviewState) { + return nil, domain.ErrValidation + } now := domain.NowNano() if o.CreatedAt == 0 { o.CreatedAt = now } o.UpdatedAt = now + if o.LastMatchedAt == 0 { + o.LastMatchedAt = now + } if o.IntentBand == "" && o.Status != domain.OppJudging { o.ApplyBandFromScore() } @@ -49,10 +60,18 @@ func (m *Memory) UpsertByExternalID(_ context.Context, o *domain.Opportunity) (* cp := *o cp.Reasons = append([]domain.OpportunityReason(nil), o.Reasons...) cp.MatchedTerms = append([]string(nil), o.MatchedTerms...) + cp.ProductMatches = make([]*domain.ProductMatch, 0, len(o.ProductMatches)) + for _, match := range o.ProductMatches { + cp.ProductMatches = append(cp.ProductMatches, domain.CloneProductMatch(match)) + } if o.Override != nil { ov := *o.Override cp.Override = &ov } + if o.PrimaryProductOverride != nil { + ov := *o.PrimaryProductOverride + cp.PrimaryProductOverride = &ov + } m.opportunities[cp.ID] = &cp m.ownerExternal[key] = cp.ID @@ -63,9 +82,100 @@ func (m *Memory) UpsertByExternalID(_ context.Context, o *domain.Opportunity) (* ov := *cp.Override out.Override = &ov } + out.ProductMatches = make([]*domain.ProductMatch, 0, len(cp.ProductMatches)) + for _, match := range cp.ProductMatches { + out.ProductMatches = append(out.ProductMatches, domain.CloneProductMatch(match)) + } + if cp.PrimaryProductOverride != nil { + ov := *cp.PrimaryProductOverride + out.PrimaryProductOverride = &ov + } return &out, nil } +func (m *Memory) MergeProductMatch(_ context.Context, ownerUID int64, opportunityID string, match *domain.ProductMatch) (*domain.Opportunity, error) { + if ownerUID <= 0 || match == nil { + return nil, domain.ErrValidation + } + if err := match.ValidateForWrite(); err != nil { + return nil, err + } + m.mu.Lock() + defer m.mu.Unlock() + o, ok := m.opportunities[opportunityID] + if !ok { + return nil, domain.ErrNotFound + } + if o.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + mergeMemoryProductMatch(o, match) + arbitrateMemoryPrimary(o) + o.UpdatedAt = domain.NowNano() + return cloneOpportunity(o), nil +} + +func (m *Memory) SetPrimaryProduct(_ context.Context, ownerUID int64, opportunityID, productID string, override *domain.ProductPrimaryOverride) (*domain.Opportunity, error) { + m.mu.Lock() + defer m.mu.Unlock() + o, ok := m.opportunities[opportunityID] + if !ok { + return nil, domain.ErrNotFound + } + if o.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + var match *domain.ProductMatch + for _, candidate := range o.ProductMatches { + if candidate.ProductID == productID { + match = candidate + break + } + } + if match == nil { + return nil, domain.ErrValidation + } + if (!match.Eligible || match.Excluded) && (override == nil || strings.TrimSpace(override.Reason) == "") { + return nil, domain.ErrValidation + } + if override != nil { + cp := *override + if cp.At == 0 { + cp.At = domain.NowNano() + } + o.PrimaryProductOverride = &cp + o.PrimaryProductOverridden = true + } else { + o.PrimaryProductOverride = nil + o.PrimaryProductOverridden = false + } + setMemoryPrimary(o, match) + o.UpdatedAt = domain.NowNano() + return cloneOpportunity(o), nil +} + +func mergeMemoryProductMatch(o *domain.Opportunity, incoming *domain.ProductMatch) { + for _, current := range o.ProductMatches { + if current.ProductID == incoming.ProductID { + current.WatchIDs = domain.MergeMatchedTerms(current.WatchIDs, incoming.WatchIDs) + current.MatchedTerms = domain.MergeMatchedTerms(current.MatchedTerms, incoming.MatchedTerms) + return + } + } + o.ProductMatches = append(o.ProductMatches, domain.CloneProductMatch(incoming)) +} + +func setMemoryPrimary(o *domain.Opportunity, match *domain.ProductMatch) { + o.PrimaryBrandID, o.PrimaryProductID = match.BrandID, match.ProductID + o.PrimaryBrandName, o.PrimaryProductLabel = match.BrandNameSnapshot, match.ProductLabelSnapshot + o.PrimaryProductFitScore, o.PrimaryProductFitBand = match.ProductFitScore, match.ProductFitBand + domain.ApplyPrimaryProduct(o) +} + +func arbitrateMemoryPrimary(o *domain.Opportunity) { + domain.ApplyPrimaryProduct(o) +} + func (m *Memory) GetOpportunity(_ context.Context, id string) (*domain.Opportunity, error) { m.mu.Lock() defer m.mu.Unlock() @@ -119,17 +229,69 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O if f.WatchID != "" && o.WatchID != f.WatchID { continue } + if f.BrandID != "" && !opportunityHasBrand(o, f.BrandID) { + continue + } + if f.ProductID != "" && !opportunityHasProduct(o, f.ProductID) { + continue + } + if f.FitBand != "" && !opportunityHasFitBand(o, f.FitBand) { + continue + } + if f.MatchState != "" && !opportunityMatchState(o, f.MatchState) { + continue + } + if f.ReviewState != "" && reviewStateFor(o) != f.ReviewState { + continue + } + if f.PriorityBand != "" && o.PriorityBand != f.PriorityBand { + continue + } if f.CreatedFrom > 0 && o.CreatedAt < f.CreatedFrom { continue } if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo { continue } + if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom { + continue + } + if f.PostedTo > 0 && o.PostedAt >= f.PostedTo { + continue + } matched = append(matched, cloneOpportunity(o)) } sort.Slice(matched, func(i, j int) bool { - if matched[i].CreatedAt != matched[j].CreatedAt { - return matched[i].CreatedAt > matched[j].CreatedAt + if f.Sort == "posted" || f.Sort == "newest" { + if matched[i].PostedAt != matched[j].PostedAt { + return matched[i].PostedAt > matched[j].PostedAt + } + } else if f.Sort == "oldest" { + if matched[i].PostedAt != matched[j].PostedAt { + return matched[i].PostedAt < matched[j].PostedAt + } + } else if f.Sort == "product_fit" { + if primaryFit(matched[i]) != primaryFit(matched[j]) { + return primaryFit(matched[i]) > primaryFit(matched[j]) + } + } else if f.Sort == "demand_intent" { + if matched[i].IntentScore != matched[j].IntentScore { + return matched[i].IntentScore > matched[j].IntentScore + } + } else if f.Sort == "priority" || f.Sort == "priority_score" { + if matched[i].PriorityScore != matched[j].PriorityScore { + return matched[i].PriorityScore > matched[j].PriorityScore + } + } else { + if matched[i].IntentScore != matched[j].IntentScore { + return matched[i].IntentScore > matched[j].IntentScore + } + if primaryFit(matched[i]) != primaryFit(matched[j]) { + return primaryFit(matched[i]) > primaryFit(matched[j]) + } + if matched[i].PostedAt != matched[j].PostedAt { + return matched[i].PostedAt > matched[j].PostedAt + } } return matched[i].ID < matched[j].ID }) @@ -153,6 +315,86 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O return matched[start:end], total, nil } +func (m *Memory) UpdateOpportunityReviewState(_ context.Context, ownerUID int64, opportunityID string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) { + if ownerUID <= 0 || !domain.IsReviewState(patch.State) { + return nil, domain.ErrValidation + } + m.mu.Lock() + defer m.mu.Unlock() + o, ok := m.opportunities[opportunityID] + if !ok { + return nil, domain.ErrNotFound + } + if o.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + current := reviewStateFor(o) + if patch.State == domain.ReviewRemoved { + if !domain.IsRemovalReason(patch.RemovalReason) { + return nil, domain.ErrValidation + } + if patch.RemovalReason == domain.RemovalOther && strings.TrimSpace(patch.RemovalNote) == "" { + return nil, domain.ErrValidation + } + if patch.RemovalReason == domain.RemovalDuplicate { + if patch.DuplicateOpportunityID == "" || patch.DuplicateOpportunityID == opportunityID { + return nil, domain.ErrValidation + } + target, exists := m.opportunities[patch.DuplicateOpportunityID] + if !exists || target.OwnerUID != ownerUID || target.AuthorHandle != o.AuthorHandle { + return nil, domain.ErrValidation + } + } + if current == domain.ReviewRemoved && o.RemovalReason == patch.RemovalReason && o.RemovalNote == strings.TrimSpace(patch.RemovalNote) { + return cloneOpportunity(o), nil + } + o.PreviousReviewState = current + o.ReviewState = domain.ReviewRemoved + o.RemovalReason = patch.RemovalReason + o.RemovalNote = strings.TrimSpace(patch.RemovalNote) + o.RemovedAt = domain.NowNano() + o.RemovedBy = ownerUID + } else { + if current == patch.State && o.RemovalReason == "" { + return cloneOpportunity(o), nil + } + o.ReviewState = patch.State + o.RemovalReason = "" + o.RemovalNote = "" + o.RemovedAt = 0 + o.RemovedBy = 0 + } + event := domain.ReviewEvent{OwnerUID: ownerUID, OpportunityID: opportunityID, From: current, To: patch.State, Reason: patch.RemovalReason, Note: patch.RemovalNote, ActorUID: ownerUID} + if err := event.Normalize(); err != nil { + return nil, err + } + m.reviewEvents[opportunityID] = append(m.reviewEvents[opportunityID], event) + o.UpdatedAt = domain.NowNano() + return cloneOpportunity(o), nil +} + +func (m *Memory) ListOpportunityReviewEvents(_ context.Context, ownerUID int64, opportunityID string) ([]domain.ReviewEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + o, ok := m.opportunities[opportunityID] + if !ok { + return nil, domain.ErrNotFound + } + if o.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + events := append([]domain.ReviewEvent(nil), m.reviewEvents[opportunityID]...) + return events, nil +} + +func reviewStateFor(o *domain.Opportunity) string { + if o.ReviewState != "" { + return o.ReviewState + } + state, _ := domain.LegacyReviewState(o.Status) + return state +} + func (m *Memory) CountToday(_ context.Context, ownerUID int64, at int64) (int64, error) { m.mu.Lock() defer m.mu.Unlock() @@ -217,9 +459,68 @@ func cloneOpportunity(o *domain.Opportunity) *domain.Opportunity { ov := *o.Override cp.Override = &ov } + cp.ProductMatches = make([]*domain.ProductMatch, 0, len(o.ProductMatches)) + for _, match := range o.ProductMatches { + cp.ProductMatches = append(cp.ProductMatches, domain.CloneProductMatch(match)) + } + if o.PrimaryProductOverride != nil { + ov := *o.PrimaryProductOverride + cp.PrimaryProductOverride = &ov + } return &cp } +func opportunityHasBrand(o *domain.Opportunity, id string) bool { + for _, m := range o.ProductMatches { + if m.BrandID == id { + return true + } + } + return false +} +func opportunityHasProduct(o *domain.Opportunity, id string) bool { + for _, m := range o.ProductMatches { + if m.ProductID == id { + return true + } + } + return false +} +func opportunityHasFitBand(o *domain.Opportunity, band string) bool { + for _, m := range o.ProductMatches { + if m.ProductFitBand == band { + return true + } + } + return false +} +func opportunityMatchState(o *domain.Opportunity, state string) bool { + if state == "generic" { + return len(o.ProductMatches) == 0 + } + for _, m := range o.ProductMatches { + switch state { + case "eligible": + if m.Eligible && !m.Excluded { + return true + } + case "weak": + if m.ProductFitBand == domain.ProductFitBandWeak { + return true + } + case "excluded": + if m.Excluded { + return true + } + } + } + if state == "stale" { + return domain.IsStaleHardReject(o.PostedAt, domain.NowNano()) + } + return false +} +func primaryFit(o *domain.Opportunity) int { return o.PrimaryProductFitScore } + func (m *Memory) SetOpportunityContact(_ context.Context, id, contactID string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/apps/backend/internal/module/radar/repository/opportunity_memory_test.go b/apps/backend/internal/module/radar/repository/opportunity_memory_test.go index 2eaa9c9..2aaff40 100644 --- a/apps/backend/internal/module/radar/repository/opportunity_memory_test.go +++ b/apps/backend/internal/module/radar/repository/opportunity_memory_test.go @@ -146,6 +146,41 @@ func TestUpdateStatusAndOverride(t *testing.T) { } } +func TestReviewStateGuardAndTombstone(t *testing.T) { + ctx := context.Background() + m := NewMemory() + row, err := m.UpsertByExternalID(ctx, sampleOpp("review-1", []string{"痛點"}, 80)) + if err != nil { + t.Fatal(err) + } + removed, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalPainMismatch, RemovedBy: 42}) + if err != nil { + t.Fatal(err) + } + if removed.ReviewState != domain.ReviewRemoved || removed.PreviousReviewState != domain.ReviewPending || removed.RemovedBy != 42 { + t.Fatalf("unexpected tombstone: %+v", removed) + } + removedList, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{ReviewState: domain.ReviewRemoved, Sort: "newest"}) + if err != nil || total != 1 || len(removedList) != 1 { + t.Fatalf("review list filter failed: total=%d list=%d err=%v", total, len(removedList), err) + } + repeated, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalPainMismatch}) + if err != nil || repeated.RemovedAt != removed.RemovedAt { + t.Fatalf("same removal should be idempotent: got=%+v err=%v", repeated, err) + } + if _, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalOther}); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("other without note should fail, got %v", err) + } + restored, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewPending}) + if err != nil || restored.ReviewState != domain.ReviewPending || restored.RemovalReason != "" { + t.Fatalf("restore failed: %+v err=%v", restored, err) + } + events, err := m.ListOpportunityReviewEvents(ctx, 42, row.ID) + if err != nil || len(events) != 2 || events[0].To != domain.ReviewRemoved || events[1].To != domain.ReviewPending { + t.Fatalf("audit should be append-only and idempotent: events=%+v err=%v", events, err) + } +} + func sampleOpp(externalID string, terms []string, score int) *domain.Opportunity { return &domain.Opportunity{ OwnerUID: 42, diff --git a/apps/backend/internal/module/radar/repository/opportunity_mongo.go b/apps/backend/internal/module/radar/repository/opportunity_mongo.go index 424f8ba..8e42a71 100644 --- a/apps/backend/internal/module/radar/repository/opportunity_mongo.go +++ b/apps/backend/internal/module/radar/repository/opportunity_mongo.go @@ -3,6 +3,7 @@ package repository import ( "context" "strings" + "time" "apps/backend/internal/module/radar/domain" @@ -52,6 +53,14 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity return nil, rerr } } + for _, match := range o.ProductMatches { + if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil { + return nil, merr + } + } + if len(o.ProductMatches) > 0 { + return s.GetOpportunity(ctx, existing.ID) + } return &existing, nil } if err != mon.ErrNotFound { @@ -61,6 +70,7 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity if err := o.ValidateForWrite(); err != nil { return nil, err } + domain.ApplyPrimaryProduct(o) if o.ID == "" { o.ID = domain.NewID() } @@ -72,6 +82,9 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity if o.IntentBand == "" && o.Status != domain.OppJudging { o.ApplyBandFromScore() } + if o.ReviewState == "" { + o.ReviewState = reviewStateFor(o) + } o.ExternalID = externalID _, err = s.opportunities.InsertOne(ctx, o) @@ -85,6 +98,97 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity return o, nil } +func (s *MonStore) MergeProductMatch(ctx context.Context, ownerUID int64, opportunityID string, match *domain.ProductMatch) (*domain.Opportunity, error) { + if ownerUID <= 0 || match == nil { + return nil, domain.ErrValidation + } + if err := match.ValidateForWrite(); err != nil { + return nil, err + } + var existing domain.Opportunity + if err := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": opportunityID, "owner_uid": ownerUID}); err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } else if err != nil { + return nil, err + } + // A guarded insert means concurrent workers cannot both append the same product. + res, err := s.opportunities.UpdateOne(ctx, + bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches": bson.M{"$not": bson.M{"$elemMatch": bson.M{"product_id": match.ProductID}}}}, + bson.M{"$push": bson.M{"product_matches": match}, "$set": bson.M{"updated_at": domain.NowNano()}}) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + _, err = s.opportunities.UpdateOne(ctx, + bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches": bson.M{"$elemMatch": bson.M{"product_id": match.ProductID}}}, + bson.M{"$addToSet": bson.M{"product_matches.$.watch_ids": bson.M{"$each": match.WatchIDs}, "product_matches.$.matched_terms": bson.M{"$each": match.MatchedTerms}}, "$set": bson.M{"updated_at": domain.NowNano()}}) + if err != nil { + return nil, err + } + } + if err := s.autoArbitratePrimary(ctx, ownerUID, opportunityID); err != nil { + return nil, err + } + return s.GetOpportunity(ctx, opportunityID) +} + +func (s *MonStore) autoArbitratePrimary(ctx context.Context, ownerUID int64, opportunityID string) error { + o, err := s.GetOpportunity(ctx, opportunityID) + if err != nil || o.PrimaryProductOverridden { + return err + } + before := o.PrimaryProductID + domain.ApplyPrimaryProduct(o) + if o.PrimaryProductID == "" || o.PrimaryProductID == before && o.IntentScore == 0 { + return nil + } + set := bson.M{ + "primary_brand_id": o.PrimaryBrandID, "primary_product_id": o.PrimaryProductID, + "primary_brand_name": o.PrimaryBrandName, "primary_product_label": o.PrimaryProductLabel, + "primary_product_fit_score": o.PrimaryProductFitScore, "primary_product_fit_band": o.PrimaryProductFitBand, + "intent_score": o.IntentScore, "intent_band": o.IntentBand, "reasons": o.Reasons, + "updated_at": domain.NowNano(), + } + _, err = s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID, "primary_product_overridden": bson.M{"$ne": true}}, bson.M{"$set": set}) + return err +} + +func (s *MonStore) SetPrimaryProduct(ctx context.Context, ownerUID int64, opportunityID, productID string, override *domain.ProductPrimaryOverride) (*domain.Opportunity, error) { + o, err := s.GetOpportunity(ctx, opportunityID) + if err != nil { + return nil, err + } + if o.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + var match *domain.ProductMatch + for _, candidate := range o.ProductMatches { + if candidate.ProductID == productID { + match = candidate + break + } + } + if match == nil || ((!match.Eligible || match.Excluded) && (override == nil || strings.TrimSpace(override.Reason) == "")) { + return nil, domain.ErrValidation + } + set := bson.M{"primary_brand_id": match.BrandID, "primary_product_id": match.ProductID, "primary_brand_name": match.BrandNameSnapshot, "primary_product_label": match.ProductLabelSnapshot, "primary_product_fit_score": match.ProductFitScore, "primary_product_fit_band": match.ProductFitBand, "primary_product_overridden": override != nil, "updated_at": domain.NowNano()} + if override != nil { + if override.At == 0 { + override.At = domain.NowNano() + } + set["primary_product_override"] = override + } + o.PrimaryProductOverridden = override != nil + o.PrimaryProductID = productID + o.PrimaryProductOverride = override + domain.ApplyPrimaryProduct(o) + set["intent_score"], set["intent_band"], set["reasons"] = o.IntentScore, o.IntentBand, o.Reasons + if _, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID, "product_matches.product_id": productID}, bson.M{"$set": set}); err != nil { + return nil, err + } + return s.GetOpportunity(ctx, opportunityID) +} + func (s *MonStore) GetOpportunity(ctx context.Context, id string) (*domain.Opportunity, error) { var o domain.Opportunity err := s.opportunities.FindOne(ctx, &o, bson.M{"_id": id}) @@ -125,6 +229,36 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma if f.WatchID != "" { q["watch_id"] = f.WatchID } + if f.BrandID != "" { + q["product_matches.brand_id"] = f.BrandID + } + if f.ProductID != "" { + q["product_matches.product_id"] = f.ProductID + } + if f.FitBand != "" { + q["product_matches.product_fit_band"] = f.FitBand + } + if f.PriorityBand != "" { + q["priority_band"] = f.PriorityBand + } + if f.ReviewState != "" { + q["$or"] = bson.A{ + bson.M{"review_state": f.ReviewState}, + bson.M{"review_state": bson.M{"$exists": false}, "status": map[string]string{domain.ReviewCompleted: domain.OppAccepted, domain.ReviewRemoved: domain.OppDismissed}[f.ReviewState]}, + } + } + switch f.MatchState { + case "eligible": + q["product_matches"] = bson.M{"$elemMatch": bson.M{"eligible": true, "excluded": false}} + case "weak": + q["product_matches.product_fit_band"] = domain.ProductFitBandWeak + case "excluded": + q["product_matches.excluded"] = true + case "generic": + q["$or"] = bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}} + case "stale": + q["posted_at"] = bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)} + } if f.CreatedFrom > 0 || f.CreatedTo > 0 { rng := bson.M{} if f.CreatedFrom > 0 { @@ -135,6 +269,16 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma } q["created_at"] = rng } + if f.PostedFrom > 0 || f.PostedTo > 0 { + rng := bson.M{} + if f.PostedFrom > 0 { + rng["$gte"] = f.PostedFrom + } + if f.PostedTo > 0 { + rng["$lt"] = f.PostedTo + } + q["posted_at"] = rng + } total, err := s.opportunities.CountDocuments(ctx, q) if err != nil { @@ -148,13 +292,79 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma ps = 20 } var list []*domain.Opportunity + sortSpec := bson.D{{Key: "intent_score", Value: -1}, {Key: "primary_product_fit_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}} + if f.Sort == "posted" || f.Sort == "newest" { + sortSpec = bson.D{{Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}} + } else if f.Sort == "oldest" { + sortSpec = bson.D{{Key: "posted_at", Value: 1}, {Key: "_id", Value: 1}} + } else if f.Sort == "product_fit" { + sortSpec = bson.D{{Key: "primary_product_fit_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}} + } else if f.Sort == "demand_intent" { + sortSpec = bson.D{{Key: "intent_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}} + } else if f.Sort == "priority" || f.Sort == "priority_score" { + sortSpec = bson.D{{Key: "priority_score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "_id", Value: 1}} + } err = s.opportunities.Find(ctx, &list, q, options.Find(). - SetSort(bson.D{{Key: "created_at", Value: -1}}). + SetSort(sortSpec). SetSkip(int64((page-1)*ps)). SetLimit(int64(ps))) return list, total, err } +func (s *MonStore) UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, opportunityID string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) { + if ownerUID <= 0 || !domain.IsReviewState(patch.State) { + return nil, domain.ErrValidation + } + o, err := s.GetOpportunity(ctx, opportunityID) + if err != nil { + return nil, err + } + if o.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + current := reviewStateFor(o) + set := bson.M{"review_state": patch.State, "updated_at": domain.NowNano()} + if patch.State == domain.ReviewRemoved { + if !domain.IsRemovalReason(patch.RemovalReason) || patch.RemovalReason == domain.RemovalOther && strings.TrimSpace(patch.RemovalNote) == "" { + return nil, domain.ErrValidation + } + if patch.RemovalReason == domain.RemovalDuplicate { + if patch.DuplicateOpportunityID == "" || patch.DuplicateOpportunityID == opportunityID { + return nil, domain.ErrValidation + } + var target domain.Opportunity + if err := s.opportunities.FindOne(ctx, &target, bson.M{"_id": patch.DuplicateOpportunityID, "owner_uid": ownerUID, "author_handle": o.AuthorHandle}); err != nil { + return nil, domain.ErrValidation + } + } + set["previous_review_state"] = current + set["removal_reason"] = patch.RemovalReason + set["removal_note"] = strings.TrimSpace(patch.RemovalNote) + set["removed_at"] = domain.NowNano() + set["removed_by"] = ownerUID + } else { + set["removal_reason"] = "" + set["removal_note"] = "" + set["removed_at"] = int64(0) + set["removed_by"] = int64(0) + } + res, err := s.opportunities.UpdateOne(ctx, bson.M{"_id": opportunityID, "owner_uid": ownerUID}, bson.M{"$set": set}) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + return nil, domain.ErrNotFound + } + event := &domain.ReviewEvent{OwnerUID: ownerUID, OpportunityID: opportunityID, From: current, To: patch.State, Reason: patch.RemovalReason, Note: patch.RemovalNote, ActorUID: ownerUID, At: domain.NowNano()} + if err := event.Normalize(); err != nil { + return nil, err + } + if _, err := s.reviewEvents.InsertOne(ctx, event); err != nil { + return nil, err + } + return s.GetOpportunity(ctx, opportunityID) +} + func (s *MonStore) CountToday(ctx context.Context, ownerUID int64, at int64) (int64, error) { start, end := domain.UTCDayBounds(at) return s.opportunities.CountDocuments(ctx, bson.M{ diff --git a/apps/backend/internal/module/radar/repository/product_indexes_test.go b/apps/backend/internal/module/radar/repository/product_indexes_test.go new file mode 100644 index 0000000..fe5d27d --- /dev/null +++ b/apps/backend/internal/module/radar/repository/product_indexes_test.go @@ -0,0 +1,94 @@ +package repository + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// Keep the migration contract executable without connecting to a Mongo instance. +// The legacy owner+external identity must remain the only Opportunity identity; +// ProductMatch indexes are query helpers, never a replacement for that key. +func TestProductRadarIndexesPreserveOpportunityIdentity(t *testing.T) { + var groups []struct { + CreateIndexes string `json:"createIndexes"` + Indexes []struct { + Key map[string]int `json:"key"` + Name string `json:"name"` + Unique bool `json:"unique"` + } `json:"indexes"` + } + base := filepath.Join("..", "..", "..", "..", "generate", "database", "mongo") + read := func(name string) { + b, err := os.ReadFile(filepath.Join(base, name)) + if err != nil { + t.Fatal(err) + } + var next []struct { + CreateIndexes string `json:"createIndexes"` + Indexes []struct { + Key map[string]int `json:"key"` + Name string `json:"name"` + Unique bool `json:"unique"` + } `json:"indexes"` + } + if err := json.Unmarshal(b, &next); err != nil { + t.Fatal(err) + } + groups = append(groups, next...) + } + read("000014_demand_radar_indexes.up.json") + read("000015_brand_product_radar_indexes.up.json") + var foundProductMatch, foundLegacyUnique bool + for _, group := range groups { + for _, index := range group.Indexes { + if group.CreateIndexes == "radar_opportunities" && index.Unique { + if len(index.Key) != 2 || index.Key["owner_uid"] != 1 || index.Key["external_id"] != 1 { + t.Fatalf("legacy Opportunity unique key changed: %#v", index.Key) + } + if _, hasProduct := index.Key["product_id"]; hasProduct { + t.Fatal("legacy Opportunity unique key must not include product_id") + } + foundLegacyUnique = true + } + if index.Name == "owner_opportunities_product_match" { + foundProductMatch = true + } + } + } + if !foundLegacyUnique { + t.Fatal("legacy Opportunity unique index not represented") + } + if !foundProductMatch { + t.Fatal("ProductMatch query index not represented") + } +} + +func TestOpportunityInboxReviewIndexes(t *testing.T) { + path := filepath.Join("..", "..", "..", "..", "generate", "database", "mongo", "000016_opportunity_inbox_review_indexes.up.json") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var groups []struct { + CreateIndexes string `json:"createIndexes"` + Indexes []struct { + Name string `json:"name"` + } `json:"indexes"` + } + if err := json.Unmarshal(b, &groups); err != nil { + t.Fatal(err) + } + foundReview := map[string]bool{} + for _, group := range groups { + for _, index := range group.Indexes { + foundReview[index.Name] = group.CreateIndexes == "radar_opportunities" || group.CreateIndexes == "radar_opportunity_review_events" + } + } + for _, name := range []string{"owner_opportunities_review_posted", "owner_opportunities_tombstone", "owner_opportunity_review_events"} { + if !foundReview[name] { + t.Fatalf("missing review index %s", name) + } + } +} diff --git a/apps/backend/internal/module/radar/repository/product_match_race_test.go b/apps/backend/internal/module/radar/repository/product_match_race_test.go new file mode 100644 index 0000000..1266c78 --- /dev/null +++ b/apps/backend/internal/module/radar/repository/product_match_race_test.go @@ -0,0 +1,55 @@ +package repository + +import ( + "context" + "fmt" + "sync" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func raceProductMatch(id string, score int, n int) *domain.ProductMatch { + m := &domain.ProductMatch{BrandID: "b", ProductID: id, ProductFitScore: score, ProductFitBand: domain.ProductFitBandFromScore(score), MatchedAt: int64(n), WatchIDs: []string{fmt.Sprintf("w-%d", n)}, MatchedTerms: []string{fmt.Sprintf("term-%d", n)}, Reasons: []domain.ProductFitReason{ + {Dimension: domain.ProductFitPain, Score: 35, Reason: "pain", CandidateExcerpt: "痛點", ProductBasis: "痛點"}, + {Dimension: domain.ProductFitScenario, Score: 25, Reason: "scenario", CandidateExcerpt: "情境", ProductBasis: "情境"}, + {Dimension: domain.ProductFitAudience, Score: 0, Reason: "unknown"}, + {Dimension: domain.ProductFitCapability, Score: score - 60, Reason: "capability", CandidateExcerpt: "能力", ProductBasis: "能力"}, + }} + return m +} + +func TestConcurrentProductMatchMergeNoDuplicateAndOverrideWins(t *testing.T) { + ctx := context.Background() + mem := NewMemory() + op, err := mem.UpsertByExternalID(ctx, &domain.Opportunity{OwnerUID: 1, ExternalID: "e-race", Source: domain.OppSourceThreads, Status: domain.OppQualified, IntentScore: 80, IntentBand: domain.BandHigh, RegionMatch: domain.RegionUnknown, Reasons: []domain.OpportunityReason{{Dimension: domain.DimAuthenticity, Reason: "a"}, {Dimension: domain.DimIntent, Reason: "i"}, {Dimension: domain.DimRegion, Reason: "r"}, {Dimension: domain.DimFreshness, Reason: "f"}, {Dimension: domain.DimFit, Reason: "fit"}}}) + if err != nil { + t.Fatal(err) + } + if _, err := mem.MergeProductMatch(ctx, 1, op.ID, raceProductMatch("p1", 60, 1)); err != nil { + t.Fatal(err) + } + if _, err := mem.SetPrimaryProduct(ctx, 1, op.ID, "p1", &domain.ProductPrimaryOverride{ActorUID: 1, Reason: "人工確認"}); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(2) + go func(n int) { + defer wg.Done() + _, _ = mem.MergeProductMatch(ctx, 1, op.ID, raceProductMatch("p1", 100, n+10)) + }(i) + go func(n int) { + defer wg.Done() + _, _ = mem.MergeProductMatch(ctx, 1, op.ID, raceProductMatch("p2", 80, n+30)) + }(i) + } + wg.Wait() + got, err := mem.GetOpportunity(ctx, op.ID) + if err != nil { + t.Fatal(err) + } + if len(got.ProductMatches) != 2 || got.PrimaryProductID != "p1" || !got.PrimaryProductOverridden { + t.Fatalf("race duplicate or override loss: primary=%s overridden=%v matches=%d", got.PrimaryProductID, got.PrimaryProductOverridden, len(got.ProductMatches)) + } +} diff --git a/apps/backend/internal/module/radar/repository/product_match_test.go b/apps/backend/internal/module/radar/repository/product_match_test.go new file mode 100644 index 0000000..d47c984 --- /dev/null +++ b/apps/backend/internal/module/radar/repository/product_match_test.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "sync" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func validProductMatch(id string, score int) *domain.ProductMatch { + m := &domain.ProductMatch{BrandID: "b1", ProductID: id, ProductFitScore: score, Reasons: []domain.ProductFitReason{ + {Dimension: domain.ProductFitPain, Score: min(score, 35), Reason: "pain", CandidateExcerpt: "漏水", ProductBasis: "漏水"}, + {Dimension: domain.ProductFitScenario, Score: min(max(score-35, 0), 25), Reason: "scenario", CandidateExcerpt: "找人", ProductBasis: "居家"}, + {Dimension: domain.ProductFitAudience, Score: min(max(score-60, 0), 20), Reason: "audience", CandidateExcerpt: "家裡", ProductBasis: "家庭"}, + {Dimension: domain.ProductFitCapability, Score: min(max(score-80, 0), 20), Reason: "capability", CandidateExcerpt: "抓漏", ProductBasis: "抓漏"}, + }} + return m +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func TestMemoryProductMatchMergeIsAtomicAndKeepsSnapshot(t *testing.T) { + ctx := context.Background() + m := NewMemory() + o := sampleOpp("product-ext", []string{"找抓漏"}, 80) + created, err := m.UpsertByExternalID(ctx, o) + if err != nil { + t.Fatal(err) + } + base := validProductMatch("p1", 80) + base.ProductLabelSnapshot = "舊名稱" + base.WatchIDs = []string{"w1"} + base.MatchedTerms = []string{"漏水"} + if _, err := m.MergeProductMatch(ctx, 42, created.ID, base); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + next := validProductMatch("p1", 20) + next.ProductLabelSnapshot = "新名稱" + next.WatchIDs = []string{"w2"} + _, _ = m.MergeProductMatch(ctx, 42, created.ID, next) + }(i) + } + wg.Wait() + got, err := m.GetOpportunity(ctx, created.ID) + if err != nil { + t.Fatal(err) + } + if len(got.ProductMatches) != 1 || got.ProductMatches[0].ProductLabelSnapshot != "舊名稱" { + t.Fatalf("duplicate or snapshot rewrite: %#v", got.ProductMatches) + } + if len(got.ProductMatches[0].WatchIDs) != 2 { + t.Fatalf("watch sources not merged: %#v", got.ProductMatches[0].WatchIDs) + } +} + +func TestMemoryPrimaryOverrideBlocksAutoArbitration(t *testing.T) { + ctx := context.Background() + m := NewMemory() + created, err := m.UpsertByExternalID(ctx, sampleOpp("primary-ext", []string{"x"}, 80)) + if err != nil { + t.Fatal(err) + } + low := validProductMatch("p1", 60) + high := validProductMatch("p2", 90) + if _, err := m.MergeProductMatch(ctx, 42, created.ID, low); err != nil { + t.Fatal(err) + } + if _, err := m.SetPrimaryProduct(ctx, 42, created.ID, "p1", &domain.ProductPrimaryOverride{ActorUID: 42, Reason: "人工指定"}); err != nil { + t.Fatal(err) + } + got, err := m.MergeProductMatch(ctx, 42, created.ID, high) + if err != nil { + t.Fatal(err) + } + if got.PrimaryProductID != "p1" || !got.PrimaryProductOverridden { + t.Fatalf("override overwritten: %#v", got) + } +} diff --git a/apps/backend/internal/module/radar/repository/service_profile_memory.go b/apps/backend/internal/module/radar/repository/service_profile_memory.go index 22f4876..073f73c 100644 --- a/apps/backend/internal/module/radar/repository/service_profile_memory.go +++ b/apps/backend/internal/module/radar/repository/service_profile_memory.go @@ -12,25 +12,29 @@ import ( type Memory struct { mu sync.Mutex profiles map[int64]*domain.ServiceProfile + demandMaps map[string]*domain.DemandMap watches map[string]*domain.RadarWatch opportunities map[string]*domain.Opportunity // ownerExternal indexes "ownerUID\0externalID" → opportunity id for O(1) upsert. ownerExternal map[string]string sweeps map[string]*domain.RadarSweep // jobToSweep indexes job_id → sweep id. - jobToSweep map[string]string - replies map[string]*domain.ReplyVariant + jobToSweep map[string]string + replies map[string]*domain.ReplyVariant + reviewEvents map[string][]domain.ReviewEvent } func NewMemory() *Memory { return &Memory{ profiles: map[int64]*domain.ServiceProfile{}, + demandMaps: map[string]*domain.DemandMap{}, watches: map[string]*domain.RadarWatch{}, opportunities: map[string]*domain.Opportunity{}, ownerExternal: map[string]string{}, sweeps: map[string]*domain.RadarSweep{}, jobToSweep: map[string]string{}, replies: map[string]*domain.ReplyVariant{}, + reviewEvents: map[string][]domain.ReviewEvent{}, } } diff --git a/apps/backend/internal/module/radar/repository/service_profile_mongo.go b/apps/backend/internal/module/radar/repository/service_profile_mongo.go index e45aea2..2f1a6e5 100644 --- a/apps/backend/internal/module/radar/repository/service_profile_mongo.go +++ b/apps/backend/internal/module/radar/repository/service_profile_mongo.go @@ -13,8 +13,10 @@ import ( type MonStore struct { profiles *mon.Model + demandMaps *mon.Model watches *mon.Model opportunities *mon.Model + reviewEvents *mon.Model sweeps *mon.Model replies *mon.Model } @@ -23,8 +25,10 @@ func NewMonStore(uri, database string) *MonStore { uri = libmongo.MustMongoURI(uri) return &MonStore{ profiles: mon.MustNewModel(uri, database, "radar_service_profiles"), + demandMaps: mon.MustNewModel(uri, database, "radar_demand_maps"), watches: mon.MustNewModel(uri, database, "radar_watches"), opportunities: mon.MustNewModel(uri, database, "radar_opportunities"), + reviewEvents: mon.MustNewModel(uri, database, "radar_opportunity_review_events"), sweeps: mon.MustNewModel(uri, database, "radar_sweeps"), replies: mon.MustNewModel(uri, database, "radar_replies"), } diff --git a/apps/backend/internal/module/radar/repository/sweep_memory.go b/apps/backend/internal/module/radar/repository/sweep_memory.go index 997e7e0..0dca23c 100644 --- a/apps/backend/internal/module/radar/repository/sweep_memory.go +++ b/apps/backend/internal/module/radar/repository/sweep_memory.go @@ -43,7 +43,21 @@ func (m *Memory) UpdateSweep(_ context.Context, id string, delta domain.SweepDel s.JudgedCount += delta.JudgedCount s.CreatedCount += delta.CreatedCount s.TruncatedCount += delta.TruncatedCount + s.MatchEvaluatedCount += delta.MatchEvaluatedCount + s.MatchMergedCount += delta.MatchMergedCount + s.FitRejectedCount += delta.FitRejectedCount s.CreditsUsed += delta.CreditsUsed + s.DedupedCount += delta.DedupedCount + s.PrefilterPassCount += delta.PrefilterPassCount + s.PrefilterReviewCount += delta.PrefilterReviewCount + s.PrefilterRejectedCount += delta.PrefilterRejectedCount + s.CachedJudgmentCount += delta.CachedJudgmentCount + s.TombstoneMatchedCount += delta.TombstoneMatchedCount + s.BudgetDeferredCount += delta.BudgetDeferredCount + s.CreditSearch += delta.CreditSearch + s.CreditDemandMap += delta.CreditDemandMap + s.CreditJudge += delta.CreditJudge + s.CreditReply += delta.CreditReply if len(delta.JudgedExternalIDs) > 0 { s.JudgedExternalIDs = domain.MergeJudgedExternalIDs(s.JudgedExternalIDs, delta.JudgedExternalIDs) } diff --git a/apps/backend/internal/module/radar/repository/sweep_mongo.go b/apps/backend/internal/module/radar/repository/sweep_mongo.go index c27a79e..129ff1a 100644 --- a/apps/backend/internal/module/radar/repository/sweep_mongo.go +++ b/apps/backend/internal/module/radar/repository/sweep_mongo.go @@ -42,9 +42,51 @@ func (s *MonStore) UpdateSweep(ctx context.Context, id string, delta domain.Swee if delta.TruncatedCount != 0 { inc["truncated_count"] = delta.TruncatedCount } + if delta.MatchEvaluatedCount != 0 { + inc["match_evaluated_count"] = delta.MatchEvaluatedCount + } + if delta.MatchMergedCount != 0 { + inc["match_merged_count"] = delta.MatchMergedCount + } + if delta.FitRejectedCount != 0 { + inc["fit_rejected_count"] = delta.FitRejectedCount + } if delta.CreditsUsed != 0 { inc["credits_used"] = delta.CreditsUsed } + if delta.DedupedCount != 0 { + inc["deduped_count"] = delta.DedupedCount + } + if delta.PrefilterPassCount != 0 { + inc["prefilter_pass_count"] = delta.PrefilterPassCount + } + if delta.PrefilterReviewCount != 0 { + inc["prefilter_review_count"] = delta.PrefilterReviewCount + } + if delta.PrefilterRejectedCount != 0 { + inc["prefilter_rejected_count"] = delta.PrefilterRejectedCount + } + if delta.CachedJudgmentCount != 0 { + inc["cached_judgment_count"] = delta.CachedJudgmentCount + } + if delta.TombstoneMatchedCount != 0 { + inc["tombstone_matched_count"] = delta.TombstoneMatchedCount + } + if delta.BudgetDeferredCount != 0 { + inc["budget_deferred_count"] = delta.BudgetDeferredCount + } + if delta.CreditSearch != 0 { + inc["credit_search"] = delta.CreditSearch + } + if delta.CreditDemandMap != 0 { + inc["credit_demand_map"] = delta.CreditDemandMap + } + if delta.CreditJudge != 0 { + inc["credit_judge"] = delta.CreditJudge + } + if delta.CreditReply != 0 { + inc["credit_reply"] = delta.CreditReply + } update := bson.M{} if len(inc) > 0 { diff --git a/apps/backend/internal/module/radar/repository/watch_context_test.go b/apps/backend/internal/module/radar/repository/watch_context_test.go new file mode 100644 index 0000000..8b880bc --- /dev/null +++ b/apps/backend/internal/module/radar/repository/watch_context_test.go @@ -0,0 +1,33 @@ +package repository + +import ( + "context" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestMemoryWatchContextFiltersAndCopy(t *testing.T) { + m := NewMemory() + legacy := &domain.RadarWatch{ID: "legacy", OwnerUID: 1, Terms: []string{"找水電"}, Status: domain.WatchActive, CreatedAt: 1, UpdatedAt: 1} + product := &domain.RadarWatch{ID: "product", OwnerUID: 1, Terms: []string{"找抓漏"}, Status: domain.WatchActive, ContextMode: domain.WatchContextProduct, BrandID: "b1", ProductID: "p1", CreatedAt: 2, UpdatedAt: 2} + if err := m.SaveWatch(context.Background(), legacy); err != nil { + t.Fatal(err) + } + if err := m.SaveWatch(context.Background(), product); err != nil { + t.Fatal(err) + } + rows, total, err := m.ListWatches(context.Background(), 1, domain.WatchListFilter{ContextMode: domain.WatchContextGeneric}) + if err != nil || total != 1 || len(rows) != 1 || rows[0].ContextMode != domain.WatchContextGeneric { + t.Fatalf("legacy filter: total=%d rows=%#v err=%v", total, rows, err) + } + rows, total, err = m.ListWatches(context.Background(), 1, domain.WatchListFilter{BrandID: "b1", ProductID: "p1"}) + if err != nil || total != 1 || len(rows) != 1 { + t.Fatalf("product filter: total=%d rows=%#v err=%v", total, rows, err) + } + rows[0].Terms[0] = "mutated" + got, err := m.GetWatch(context.Background(), "product") + if err != nil || got.Terms[0] == "mutated" { + t.Fatalf("repository leaked mutable slice: %#v %v", got, err) + } +} diff --git a/apps/backend/internal/module/radar/repository/watch_memory.go b/apps/backend/internal/module/radar/repository/watch_memory.go index 7633bf3..6b2dcf2 100644 --- a/apps/backend/internal/module/radar/repository/watch_memory.go +++ b/apps/backend/internal/module/radar/repository/watch_memory.go @@ -10,7 +10,16 @@ import ( func (m *Memory) SaveWatch(_ context.Context, w *domain.RadarWatch) error { m.mu.Lock() defer m.mu.Unlock() - cp := *w + if w == nil { + return domain.ErrValidation + } + if err := w.NormalizeContext(); err != nil { + return err + } + if old, ok := m.watches[w.ID]; ok && old.ContextMode == domain.WatchContextProduct && (old.BrandID != w.BrandID || old.ProductID != w.ProductID) { + return domain.ErrValidation + } + cp := cloneWatch(w) m.watches[w.ID] = &cp return nil } @@ -22,10 +31,23 @@ func (m *Memory) GetWatch(_ context.Context, id string) (*domain.RadarWatch, err if !ok { return nil, domain.ErrNotFound } - cp := *w + cp := cloneWatch(w) + if err := cp.NormalizeContext(); err != nil { + return nil, err + } return &cp, nil } +func (m *Memory) DeleteWatch(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.watches[id]; !ok { + return domain.ErrNotFound + } + delete(m.watches, id) + return nil +} + func (m *Memory) ListWatches(_ context.Context, ownerUID int64, f domain.WatchListFilter) ([]*domain.RadarWatch, int64, error) { m.mu.Lock() defer m.mu.Unlock() @@ -38,7 +60,19 @@ func (m *Memory) ListWatches(_ context.Context, ownerUID int64, f domain.WatchLi if f.Status != "" && w.Status != f.Status { continue } - cp := *w + cp := cloneWatch(w) + if err := cp.NormalizeContext(); err != nil { + return nil, 0, err + } + if f.ContextMode != "" && cp.ContextMode != f.ContextMode { + continue + } + if f.BrandID != "" && cp.BrandID != f.BrandID { + continue + } + if f.ProductID != "" && cp.ProductID != f.ProductID { + continue + } matched = append(matched, &cp) } sort.Slice(matched, func(i, j int) bool { @@ -73,7 +107,10 @@ func (m *Memory) ListActiveWatches(_ context.Context, ownerUID int64) ([]*domain out := make([]*domain.RadarWatch, 0, len(m.watches)) for _, w := range m.watches { if w.OwnerUID == ownerUID && w.Status == domain.WatchActive { - cp := *w + cp := cloneWatch(w) + if err := cp.NormalizeContext(); err != nil { + return nil, err + } out = append(out, &cp) } } @@ -92,7 +129,10 @@ func (m *Memory) ListAllActiveWatches(_ context.Context) ([]*domain.RadarWatch, out := make([]*domain.RadarWatch, 0, len(m.watches)) for _, w := range m.watches { if w.Status == domain.WatchActive { - cp := *w + cp := cloneWatch(w) + if err := cp.NormalizeContext(); err != nil { + return nil, err + } out = append(out, &cp) } } @@ -108,6 +148,14 @@ func (m *Memory) ListAllActiveWatches(_ context.Context) ([]*domain.RadarWatch, return out, nil } +func cloneWatch(w *domain.RadarWatch) domain.RadarWatch { + cp := *w + cp.Terms = append([]string(nil), w.Terms...) + cp.ExcludeTerms = append([]string(nil), w.ExcludeTerms...) + cp.Regions = append([]string(nil), w.Regions...) + return cp +} + func (m *Memory) CountActiveWatches(_ context.Context, ownerUID int64) (int64, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/apps/backend/internal/module/radar/repository/watch_mongo.go b/apps/backend/internal/module/radar/repository/watch_mongo.go index 9991500..612993e 100644 --- a/apps/backend/internal/module/radar/repository/watch_mongo.go +++ b/apps/backend/internal/module/radar/repository/watch_mongo.go @@ -11,6 +11,20 @@ import ( ) func (s *MonStore) SaveWatch(ctx context.Context, w *domain.RadarWatch) error { + if w == nil { + return domain.ErrValidation + } + if err := w.NormalizeContext(); err != nil { + return err + } + var old domain.RadarWatch + if err := s.watches.FindOne(ctx, &old, bson.M{"_id": w.ID}); err == nil { + if old.ContextMode == domain.WatchContextProduct && (old.BrandID != w.BrandID || old.ProductID != w.ProductID) { + return domain.ErrValidation + } + } else if err != mon.ErrNotFound { + return err + } _, err := s.watches.ReplaceOne(ctx, bson.M{"_id": w.ID}, w, options.Replace().SetUpsert(true)) return err } @@ -27,11 +41,33 @@ func (s *MonStore) GetWatch(ctx context.Context, id string) (*domain.RadarWatch, return &w, nil } +func (s *MonStore) DeleteWatch(ctx context.Context, id string) error { + res, err := s.watches.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + return nil +} + func (s *MonStore) ListWatches(ctx context.Context, ownerUID int64, f domain.WatchListFilter) ([]*domain.RadarWatch, int64, error) { q := bson.M{"owner_uid": ownerUID} if f.Status != "" { q["status"] = f.Status } + if f.ContextMode == domain.WatchContextGeneric { + q["$or"] = bson.A{bson.M{"context_mode": domain.WatchContextGeneric}, bson.M{"context_mode": bson.M{"$exists": false}}, bson.M{"context_mode": ""}} + } else if f.ContextMode != "" { + q["context_mode"] = f.ContextMode + } + if f.BrandID != "" { + q["brand_id"] = f.BrandID + } + if f.ProductID != "" { + q["product_id"] = f.ProductID + } total, err := s.watches.CountDocuments(ctx, q) if err != nil { return nil, 0, err @@ -48,6 +84,11 @@ func (s *MonStore) ListWatches(ctx context.Context, ownerUID int64, f domain.Wat SetSort(bson.D{{Key: "created_at", Value: -1}}). SetSkip(int64((page-1)*ps)). SetLimit(int64(ps))) + for _, w := range list { + if w != nil && w.ContextMode == "" { + w.ContextMode = domain.WatchContextGeneric + } + } return list, total, err } diff --git a/apps/backend/internal/module/radar/usecase/accuracy_fixture_test.go b/apps/backend/internal/module/radar/usecase/accuracy_fixture_test.go new file mode 100644 index 0000000..79426f5 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/accuracy_fixture_test.go @@ -0,0 +1,55 @@ +package usecase + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +type accuracyCase struct { + ID string `json:"id"` + Text string `json:"text"` + ProductTerms []string `json:"product_terms"` + ExpectedDemand bool `json:"expected_demand"` + ExpectedProductEvidence bool `json:"expected_product_evidence"` + ExpectedExclude bool `json:"expected_exclude"` + ExpectedStale bool `json:"expected_stale"` + ExpectedUnknownTime bool `json:"expected_unknown_time"` +} + +func loadAccuracyCases(t *testing.T) []accuracyCase { + t.Helper() + raw, err := os.ReadFile("testdata/opportunity_inbox_accuracy.json") + if err != nil { + t.Fatal(err) + } + var cases []accuracyCase + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatal(err) + } + return cases +} + +func TestAccuracyFixtureSchema(t *testing.T) { + cases := loadAccuracyCases(t) + if len(cases) < 10 { + t.Fatalf("fixture needs at least 10 cases, got %d", len(cases)) + } + seen := map[string]bool{} + for _, item := range cases { + if item.ID == "" || seen[item.ID] || strings.TrimSpace(item.Text) == "" { + t.Fatalf("invalid or duplicate case: %+v", item) + } + seen[item.ID] = true + if item.ExpectedDemand && len(item.ProductTerms) == 0 { + t.Fatalf("demand case lacks product evidence: %s", item.ID) + } + if item.ExpectedExclude && item.ExpectedDemand { + t.Fatalf("exclude case cannot be demand: %s", item.ID) + } + if strings.Contains(strings.ToLower(item.Text), "bearer ") || strings.Contains(item.Text, "sk-") { + t.Fatalf("fixture contains token-like text: %s", item.ID) + } + } +} diff --git a/apps/backend/internal/module/radar/usecase/candidate_prefilter.go b/apps/backend/internal/module/radar/usecase/candidate_prefilter.go new file mode 100644 index 0000000..4e7f192 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/candidate_prefilter.go @@ -0,0 +1,116 @@ +package usecase + +import ( + "net/url" + "strings" + "unicode/utf8" + + "apps/backend/internal/module/radar/domain" +) + +const ( + PrefilterPass = "pass" + PrefilterReview = "review" + PrefilterRejected = "rejected" +) + +type PrefilterStats struct { + Deduped, Pass, Review, Rejected int +} + +// NormalizeCandidate removes transport noise before any score or meter is +// touched. It also establishes one stable external identity for a URL hit. +func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost { + if c == nil { + return nil + } + out := *c + out.ExternalID = canonicalCandidateURL(out.ExternalID) + out.Permalink = canonicalCandidateURL(out.Permalink) + if out.ExternalID == "" { + out.ExternalID = out.Permalink + } + if out.Permalink == "" { + out.Permalink = out.ExternalID + } + out.Text = strings.TrimSpace(strings.Join(strings.Fields(out.Text), " ")) + out.Title = strings.TrimSpace(strings.Join(strings.Fields(out.Title), " ")) + out.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(out.AuthorHandle), "@") + out.Classification = strings.ToLower(strings.TrimSpace(out.Classification)) + if out.Classification == "" { + out.Classification = classifyCandidate(strings.ToLower(out.Text + " " + out.Title)) + } + return &out +} + +func canonicalCandidateURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return raw + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + u.Fragment = "" + u.RawQuery = "" + return strings.TrimRight(u.String(), "/") +} + +// PrefilterCandidates is cheap, deterministic and deliberately conservative: +// only obvious provider/noise/announcement posts are rejected. A weak demand +// signal is retained as review so the judge can still rescue an unusual but +// relevant user wording. +func PrefilterCandidates(in []*domain.CandidatePost, watch *domain.RadarWatch, product *ProductContextSnapshot) ([]*domain.CandidatePost, PrefilterStats) { + terms := []string{} + if watch != nil { + terms = append(terms, watch.Terms...) + } + if product != nil { + terms = append(terms, product.PainPoints...) + terms = append(terms, product.ProductContext) + terms = append(terms, product.MatchTags...) + terms = append(terms, product.ProviderCapabilityTerms...) + } + seen := map[string]bool{} + out := make([]*domain.CandidatePost, 0, len(in)) + stats := PrefilterStats{} + for _, raw := range in { + c := NormalizeCandidate(raw) + if c == nil || c.ExternalID == "" || c.Permalink == "" || utf8.RuneCountInString(c.Text) < 2 { + stats.Rejected++ + continue + } + if seen[c.ExternalID] { + stats.Deduped++ + continue + } + seen[c.ExternalID] = true + if c.Classification == "provider_offer" || c.Classification == "announcement" || c.Classification == "noise" { + stats.Rejected++ + // Keep hard-reject candidates in the stream so the existing judge + // path can persist an auditable rejected opportunity without an AI + // call. This preserves the inbox's explanation surface. + out = append(out, c) + continue + } + blob := strings.ToLower(c.Text + " " + c.Title) + matched := false + for _, term := range terms { + term = strings.ToLower(strings.TrimSpace(term)) + if term != "" && strings.Contains(blob, term) { + matched = true + break + } + } + if matched || c.Classification == "seeking_help" || c.Classification == "seeking_recommendation" { + stats.Pass++ + } else { + stats.Review++ + } + out = append(out, c) + } + return out, stats +} diff --git a/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go b/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go new file mode 100644 index 0000000..8e0265a --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/candidate_prefilter_test.go @@ -0,0 +1,29 @@ +package usecase + +import ( + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestPrefilterNormalizesDedupesAndKeepsWeakDemandForReview(t *testing.T) { + items, stats := PrefilterCandidates([]*domain.CandidatePost{ + {Permalink: "https://Threads.net/@a/post/1?utm_source=x", Text: "泛紅換季怎麼辦"}, + {Permalink: "https://threads.net/@a/post/1", Text: "重複"}, + {Permalink: "https://threads.net/@b/post/2", Text: "今天天氣很好"}, + {Permalink: "https://threads.net/@c/post/3", Text: "限時優惠立即購買"}, + }, &domain.RadarWatch{Terms: []string{"泛紅"}}, nil) + if len(items) != 3 || stats.Deduped != 1 || stats.Rejected != 1 || stats.Pass != 1 || stats.Review != 1 { + t.Fatalf("unexpected prefilter result: items=%d stats=%+v", len(items), stats) + } + if items[0].ExternalID != "https://threads.net/@a/post/1" { + t.Fatalf("url was not canonicalized: %q", items[0].ExternalID) + } +} + +func TestPrefilterDoesNotUseProductNameAsDemandSignal(t *testing.T) { + items, stats := PrefilterCandidates([]*domain.CandidatePost{{Permalink: "https://threads.net/@a/post/1", Text: "舒緩精華好漂亮"}}, nil, &ProductContextSnapshot{ProductLabel: "舒緩精華", PainPoints: []string{"泛紅"}}) + if len(items) != 1 || stats.Review != 1 || stats.Rejected != 0 { + t.Fatalf("product name should remain review, got items=%d stats=%+v", len(items), stats) + } +} diff --git a/apps/backend/internal/module/radar/usecase/cost_preview.go b/apps/backend/internal/module/radar/usecase/cost_preview.go new file mode 100644 index 0000000..dc2cc56 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/cost_preview.go @@ -0,0 +1,74 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + "time" + + "apps/backend/internal/module/radar/domain" + usageDomain "apps/backend/internal/module/usage/domain" +) + +// GetCostPreview is deliberately read-only: it resolves the likely key mode +// and reads the current balance, but never calls PrepareCall or reserves a +// credit. The actual operation remains the only place that meters usage. +func (s *Service) GetCostPreview(ctx context.Context, ownerUID int64, action, watchID, productID string, candidateLimit int) (*domain.CostPreview, error) { + if ownerUID <= 0 { + return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) + } + action = strings.ToLower(strings.TrimSpace(action)) + if action != "sweep" && action != "explore" && action != "demand_map_enrich" && action != "reply" { + return nil, fmt.Errorf("%w: unknown cost preview action %q", domain.ErrValidation, action) + } + if candidateLimit <= 0 { + candidateLimit = 20 + } + if candidateLimit > 100 { + candidateLimit = 100 + } + searchCalls := 0 + if action == "sweep" || action == "explore" { + searchCalls = 1 + if productID != "" { + if plan, err := s.BuildProductQueryPlan(ctx, ownerUID, productID); err == nil && len(plan.Groups) > 0 { + searchCalls = len(plan.Groups) + } + } + } + maxAI := 0 + fixed := 0 + min := searchCalls * usageDomain.MeterCost(usageDomain.MeterWebSearch) + max := min + switch action { + case "sweep", "explore": + maxAI = candidateLimit + max += maxAI * usageDomain.MeterCost(usageDomain.MeterAIResearch) + case "demand_map_enrich", "reply": + fixed = usageDomain.MeterCost(usageDomain.MeterAICopy) + min, max = fixed, fixed + } + mode := usageDomain.KeyModePlatform + meter := usageDomain.MeterWebSearch + if action == "demand_map_enrich" || action == "reply" { + meter = usageDomain.MeterAICopy + } + if s.ResolveKey != nil { + if resolved, _, err := s.ResolveKey(ctx, ownerUID, meter); err == nil && resolved != "" { + mode = resolved + } + } + remaining := 0 + if s.Usage != nil { + if summary, err := s.Usage.GetSummary(ctx, ownerUID, ""); err == nil && summary != nil { + remaining = summary.Platform.CreditsRemaining + } + } + return &domain.CostPreview{ + PreviewID: domain.NewID(), Action: action, KeyMode: mode, + FixedCredits: fixed, MinCredits: min, MaxCredits: max, + SearchCalls: searchCalls, MaxAICandidates: maxAI, RemainingCredits: remaining, + EstimateBasis: "免費前處理;provider 成功後依 web_search/ai_research/ai_copy meter 計點", + ExpiresAt: time.Now().UTC().Add(5 * time.Minute).UnixNano(), + }, nil +} diff --git a/apps/backend/internal/module/radar/usecase/cost_preview_test.go b/apps/backend/internal/module/radar/usecase/cost_preview_test.go new file mode 100644 index 0000000..624d1f8 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/cost_preview_test.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestCostPreviewDoesNotReserveAndUsesByokMode(t *testing.T) { + svc := New(nil) + svc.ResolveKey = func(context.Context, int64, string) (string, string, error) { + return "byok", "secret", nil + } + preview, err := svc.GetCostPreview(context.Background(), 42, "explore", "", "", 10) + if err != nil { + t.Fatal(err) + } + if preview.KeyMode != "byok" || preview.SearchCalls != 1 || preview.MaxAICandidates != 10 { + t.Fatalf("unexpected preview: %+v", preview) + } + if preview.MinCredits != 1 || preview.MaxCredits <= preview.MinCredits { + t.Fatalf("expected search plus judge range: %+v", preview) + } +} + +func TestCostPreviewRejectsUnknownAction(t *testing.T) { + _, err := New(nil).GetCostPreview(context.Background(), 42, "unknown", "", "", 0) + if err == nil || !errors.Is(err, domain.ErrValidation) { + t.Fatalf("expected validation, got %v", err) + } +} diff --git a/apps/backend/internal/module/radar/usecase/demand_map.go b/apps/backend/internal/module/radar/usecase/demand_map.go new file mode 100644 index 0000000..710d05b --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/demand_map.go @@ -0,0 +1,126 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +func (s *Service) GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*domain.DemandMap, error) { + if ownerUID <= 0 || strings.TrimSpace(productID) == "" { + return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation) + } + value, err := s.Repo.GetDemandMap(ctx, ownerUID, productID) + if err == nil { + return value, nil + } + if err != domain.ErrNotFound { + return nil, err + } + if s.ProductSource == nil { + return nil, fmt.Errorf("%w: product catalog unavailable", domain.ErrNotReady) + } + product, err := s.ProductSource.GetProduct(ctx, ownerUID, productID) + if err != nil { + return nil, err + } + if product == nil { + return nil, domain.ErrNotFound + } + value = baselineDemandMap(product) + return s.Repo.SaveDemandMap(ctx, value, 0) +} + +func (s *Service) UpdateDemandMap(ctx context.Context, ownerUID int64, value *domain.DemandMap, expectedVersion int64) (*domain.DemandMap, error) { + if value == nil || ownerUID <= 0 || strings.TrimSpace(value.ProductID) == "" { + return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation) + } + if value.OwnerUID != 0 && value.OwnerUID != ownerUID { + return nil, domain.ErrForbidden + } + value.OwnerUID = ownerUID + if expectedVersion < 1 { + return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation) + } + current, err := s.GetDemandMap(ctx, ownerUID, value.ProductID) + if err != nil { + return nil, err + } + value.DemandInputVersion = current.DemandInputVersion + value.SourceBasis = append([]string(nil), current.SourceBasis...) + if value.AIEnrichedAt == 0 { + value.AIEnrichedAt = current.AIEnrichedAt + } + value.MapVersion = current.MapVersion + value.UpdatedAt = domain.NowNano() + return s.Repo.SaveDemandMap(ctx, value, expectedVersion) +} + +func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap { + mapPhrase := func(text, kind, basisKind string) domain.DemandMapPhrase { + return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: product.Label, Origin: "product", Enabled: strings.TrimSpace(text) != ""} + } + phrases := func(items []string, kind, basisKind string) []domain.DemandMapPhrase { + out := make([]domain.DemandMapPhrase, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item) != "" { + out = append(out, mapPhrase(item, kind, basisKind)) + } + } + return out + } + pain := phrases(product.PainPoints, "pain", "pain_point") + scenario := phrases([]string{product.ProductContext}, "scenario", "product_context") + outcomes := phrases(product.MatchTags, "outcome", "match_tag") + solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability") + exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude") + state := "incomplete" + if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 { + state = "ready" + } + version := "demand-" + DemandInputFingerprint(product) + return &domain.DemandMap{ + ID: "", OwnerUID: product.OwnerUID, ProductID: product.ID, + DemandInputVersion: version, MapVersion: 1, State: state, + PainPhrases: pain, ScenarioPhrases: scenario, DesiredOutcomes: outcomes, + SolutionSignals: solution, ExclusionSignals: exclusions, + SourceBasis: []string{"product:" + product.ID}, CustomPhrases: []domain.DemandMapPhrase{}, + UpdatedAt: domain.NowNano(), + } +} + +// DemandInputFingerprint intentionally excludes display-only product fields +// (name, image, price). A wording edit that changes what the provider solves +// must create a new input version; a catalog rename must not invalidate a +// user's reviewed query plan. +func DemandInputFingerprint(product *ProductCatalogProduct) string { + if product == nil { + return "unknown" + } + normalize := func(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) + if value != "" { + out = append(out, value) + } + } + sort.Strings(out) + return out + } + parts := []string{ + strings.TrimSpace(product.BrandID), + strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(product.ProductContext)), " ")), + strings.Join(normalize(product.PainPoints), "\x1f"), + strings.Join(normalize(product.MatchTags), "\x1f"), + strings.Join(normalize(product.ProviderCapabilityTerms), "\x1f"), + strings.Join(normalize(product.ProviderExcludeTerms), "\x1f"), + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x1e"))) + return hex.EncodeToString(sum[:])[:16] +} diff --git a/apps/backend/internal/module/radar/usecase/demand_map_enrich.go b/apps/backend/internal/module/radar/usecase/demand_map_enrich.go new file mode 100644 index 0000000..c9a96bc --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/demand_map_enrich.go @@ -0,0 +1,93 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" + usageDomain "apps/backend/internal/module/usage/domain" +) + +type demandMapEnrichment struct { + PainPhrases []domain.DemandMapPhrase `json:"pain_phrases"` + ScenarioPhrases []domain.DemandMapPhrase `json:"scenario_phrases"` + DesiredOutcomes []domain.DemandMapPhrase `json:"desired_outcomes"` + SolutionSignals []domain.DemandMapPhrase `json:"solution_signals"` + ExclusionSignals []domain.DemandMapPhrase `json:"exclusion_signals"` + CustomPhrases []domain.DemandMapPhrase `json:"custom_phrases"` +} + +func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID, previewID string, expectedVersion int64, creditCeiling int) (out *domain.DemandMap, err error) { + if ownerUID <= 0 || strings.TrimSpace(productID) == "" { + return nil, fmt.Errorf("%w: owner and product are required", domain.ErrValidation) + } + if expectedVersion < 1 { + return nil, fmt.Errorf("%w: expected map version required", domain.ErrValidation) + } + if creditCeiling < 1 { + return nil, fmt.Errorf("%w: credit ceiling must be positive", domain.ErrValidation) + } + current, err := s.GetDemandMap(ctx, ownerUID, productID) + if err != nil { + return nil, err + } + charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "需求地圖 AI 補全", "radar.demand_map_enrich") + if err != nil { + return nil, err + } + defer charge.Settle(ctx, &err) + if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil { + return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady) + } + prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q;情境=%q;結果=%q;能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases,每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals)) + raw, err := s.completeAI(ctx, ownerUID, prompt) + if err != nil { + return nil, err + } + var additions demandMapEnrichment + start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}") + if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &additions) != nil { + return nil, fmt.Errorf("%w: AI returned invalid demand map JSON", domain.ErrValidation) + } + mergePhrases := func(existing []domain.DemandMapPhrase, incoming []domain.DemandMapPhrase) []domain.DemandMapPhrase { + out := append([]domain.DemandMapPhrase(nil), existing...) + seen := map[string]bool{} + for _, p := range out { + seen[strings.ToLower(strings.TrimSpace(p.Text))] = true + } + for _, p := range incoming { + p.Text = strings.TrimSpace(p.Text) + p.Origin = "ai" + p.Enabled = p.Enabled && p.Text != "" + if p.Enabled && !seen[strings.ToLower(p.Text)] { + out = append(out, p) + seen[strings.ToLower(p.Text)] = true + } + } + return out + } + current.PainPhrases = mergePhrases(current.PainPhrases, additions.PainPhrases) + current.ScenarioPhrases = mergePhrases(current.ScenarioPhrases, additions.ScenarioPhrases) + current.DesiredOutcomes = mergePhrases(current.DesiredOutcomes, additions.DesiredOutcomes) + current.SolutionSignals = mergePhrases(current.SolutionSignals, additions.SolutionSignals) + current.ExclusionSignals = mergePhrases(current.ExclusionSignals, additions.ExclusionSignals) + current.CustomPhrases = mergePhrases(current.CustomPhrases, additions.CustomPhrases) + current.AIEnrichedAt = domain.NowNano() + if current.State != "ready" && len(current.PainPhrases) > 0 && len(current.ScenarioPhrases) > 0 && len(current.SolutionSignals) > 0 { + current.State = "ready" + } + _ = previewID // preview is a client confirmation token; no credit is reserved by preview. + return s.UpdateDemandMap(ctx, ownerUID, current, expectedVersion) +} + +func phraseTexts(in []domain.DemandMapPhrase) []string { + out := make([]string, 0, len(in)) + for _, p := range in { + if p.Enabled && strings.TrimSpace(p.Text) != "" { + out = append(out, strings.TrimSpace(p.Text)) + } + } + return out +} diff --git a/apps/backend/internal/module/radar/usecase/demand_map_enrich_test.go b/apps/backend/internal/module/radar/usecase/demand_map_enrich_test.go new file mode 100644 index 0000000..8df8770 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/demand_map_enrich_test.go @@ -0,0 +1,51 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func TestEnrichDemandMapAddsAIOriginAndAdvancesVersion(t *testing.T) { + repo := repository.NewMemory() + svc := New(repo) + svc.ProductSource = demandMapProducts{product: &ProductCatalogProduct{ID: "p1", OwnerUID: 42, Label: "服務", ProductContext: "換季", PainPoints: []string{"泛紅"}, ProviderCapabilityTerms: []string{"修護"}, UpdatedAt: 1}} + // completeAI uses the injected client after resolving the test key. + svc.AI = &enrichFakeAI{raw: `{"pain_phrases":[{"text":"皮膚刺癢","kind":"pain","basis_kind":"pain","basis_text":"泛紅","enabled":true}]}`} + current, err := svc.GetDemandMap(context.Background(), 42, "p1") + if err != nil { + t.Fatal(err) + } + updated, err := svc.EnrichDemandMap(context.Background(), 42, "p1", "preview", current.MapVersion, 1) + if err != nil { + t.Fatal(err) + } + if updated.MapVersion != 2 || updated.AIEnrichedAt == 0 || updated.PainPhrases[len(updated.PainPhrases)-1].Origin != "ai" { + t.Fatalf("unexpected enrichment: %+v", updated) + } +} + +func TestEnrichDemandMapRequiresCeiling(t *testing.T) { + _, err := New(repository.NewMemory()).EnrichDemandMap(context.Background(), 42, "p1", "", 1, 0) + if !errors.Is(err, domain.ErrValidation) { + t.Fatalf("expected validation, got %v", err) + } +} + +type enrichFakeAI struct{ raw string } + +func (f *enrichFakeAI) Complete(context.Context, string, string, string) (string, error) { + return f.raw, nil +} +func (f *enrichFakeAI) CompleteStream(context.Context, string, string, string, func(string) error) (string, error) { + return f.raw, nil +} +func (f *enrichFakeAI) ListModels(context.Context, string) ([]string, error) { + return []string{"test"}, nil +} + +var _ ai.Client = (*enrichFakeAI)(nil) diff --git a/apps/backend/internal/module/radar/usecase/demand_map_test.go b/apps/backend/internal/module/radar/usecase/demand_map_test.go new file mode 100644 index 0000000..484aeea --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/demand_map_test.go @@ -0,0 +1,64 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +type demandMapProducts struct{ product *ProductCatalogProduct } + +func (s demandMapProducts) GetBrand(context.Context, int64, string) (*ProductBrand, error) { + return nil, domain.ErrNotFound +} +func (s demandMapProducts) GetProduct(_ context.Context, ownerUID int64, id string) (*ProductCatalogProduct, error) { + if s.product == nil || s.product.OwnerUID != ownerUID || s.product.ID != id { + return nil, domain.ErrNotFound + } + return s.product, nil +} + +func TestDemandMapBaselineAndOptimisticVersion(t *testing.T) { + const owner = int64(42) + repo := repository.NewMemory() + svc := New(repo) + svc.ProductSource = demandMapProducts{product: &ProductCatalogProduct{ + ID: "p1", OwnerUID: owner, Label: "舒緩精華", ProductContext: "換季日常修護", + PainPoints: []string{"泛紅不適"}, MatchTags: []string{"舒緩保濕"}, + ProviderCapabilityTerms: []string{"修護"}, UpdatedAt: 7, + }} + first, err := svc.GetDemandMap(context.Background(), owner, "p1") + if err != nil { + t.Fatal(err) + } + if first.State != "ready" || first.MapVersion != 1 || len(first.DemandInputVersion) != len("demand-")+16 { + t.Fatalf("unexpected baseline: %+v", first) + } + first.PainPhrases[0].Text = "新的痛點" + updated, err := svc.UpdateDemandMap(context.Background(), owner, first, 1) + if err != nil { + t.Fatal(err) + } + if updated.MapVersion != 2 || updated.PainPhrases[0].Text != "新的痛點" { + t.Fatalf("unexpected update: %+v", updated) + } + if _, err := svc.UpdateDemandMap(context.Background(), owner, first, 1); !errors.Is(err, domain.ErrConflict) { + t.Fatalf("stale update must conflict, got %v", err) + } +} + +func TestDemandMapBaselineIncomplete(t *testing.T) { + repo := repository.NewMemory() + svc := New(repo) + svc.ProductSource = demandMapProducts{product: &ProductCatalogProduct{ID: "p2", OwnerUID: 42, Label: "服務", PainPoints: []string{"等待太久"}, UpdatedAt: 1}} + m, err := svc.GetDemandMap(context.Background(), 42, "p2") + if err != nil { + t.Fatal(err) + } + if m.State != "incomplete" { + t.Fatalf("expected incomplete baseline, got %q", m.State) + } +} diff --git a/apps/backend/internal/module/radar/usecase/explore.go b/apps/backend/internal/module/radar/usecase/explore.go index 15ff283..dd7a2b0 100644 --- a/apps/backend/internal/module/radar/usecase/explore.go +++ b/apps/backend/internal/module/radar/usecase/explore.go @@ -14,6 +14,8 @@ type ExploreResult struct { HitCount int JudgedCount int CreatedCount int + MatchedCount int + MergedCount int TruncatedCount int CreditsUsed int } @@ -26,6 +28,24 @@ Reuses FetchCandidates path (HitFetch fan-out) and ProcessCandidates (quota, dedupe, reasons). Does not bypass daily opportunity caps. Not async. */ func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string) (*ExploreResult, error) { + return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "") +} + +func (s *Service) ExploreProductOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) { + brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID) + if (brandID == "") != (productID == "") { + return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation) + } + if brandID == "" { + return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "") + } + if _, err := s.LoadProductContext(ctx, ownerUID, brandID, productID); err != nil { + return nil, err + } + return s.exploreOpportunities(ctx, ownerUID, rawTerms, brandID, productID) +} + +func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) } @@ -44,6 +64,26 @@ func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawT Terms: terms, Status: domain.WatchActive, } + var productContext *ProductContextSnapshot + if brandID != "" { + product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID) + if err != nil { + return nil, err + } + productContext = product + w.ContextMode, w.BrandID, w.ProductID = domain.WatchContextProduct, brandID, productID + w.BrandNameSnapshot, w.ProductLabelSnapshot = product.BrandName, product.ProductLabel + } + if productContext != nil { + if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil { + if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil { + w.Terms = make([]string, 0, len(plan.Groups)) + for _, group := range plan.Groups { + w.Terms = append(w.Terms, group.Query) + } + } + } + } // Cap hits for sync explore (≤20); AI judge still subject to daily quota inside ProcessCandidates. const exploreHitLimit = 20 @@ -60,9 +100,14 @@ func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawT _ = s.setSweepPath(ctx, sw.ID, path) } + rawHitCount := len(cands) + cands, prefilter := PrefilterCandidates(cands, w, productContext) _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ - HitCount: len(cands), - CreditsUsed: fetchCredits, + HitCount: rawHitCount, + CreditsUsed: fetchCredits, + CreditSearch: fetchCredits, + DedupedCount: prefilter.Deduped, PrefilterPassCount: prefilter.Pass, + PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected, }) created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates( @@ -83,6 +128,7 @@ func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawT } sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ CreditsUsed: judgeCredits, + CreditJudge: judgeCredits, EndedAt: end, FailedReason: failPtr, }) @@ -100,6 +146,8 @@ func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawT HitCount: len(cands), JudgedCount: judged, CreatedCount: created, + MatchedCount: sw.MatchEvaluatedCount, + MergedCount: sw.MatchMergedCount, TruncatedCount: truncated, CreditsUsed: credits, }, nil diff --git a/apps/backend/internal/module/radar/usecase/judge.go b/apps/backend/internal/module/radar/usecase/judge.go index 0ceaca0..e7ffea2 100644 --- a/apps/backend/internal/module/radar/usecase/judge.go +++ b/apps/backend/internal/module/radar/usecase/judge.go @@ -21,6 +21,10 @@ type JudgeResult struct { FreshnessHours int MatchedService string RejectReason string + ProductMatch *domain.ProductMatch + // Cacheable is internal provenance: only a structured provider result or a + // deterministic hard reject may be reused as a judgment cache entry. + Cacheable bool } /* @@ -28,7 +32,18 @@ JudgeCandidate produces intent score / band / five reasons / region_match. Hard rejects: non-authentic classification, provider_offer/announcement/noise, age > 14d. Meter: ai_research / radar.judge when AI path is used; heuristic path still bills once if Usage set. */ -func (s *Service) JudgeCandidate(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) (res *JudgeResult, credits int, err error) { +func (s *Service) judgeCandidateUncached(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) (res *JudgeResult, credits int, err error) { + if watch != nil && watch.ContextMode == domain.WatchContextProduct { + product, perr := s.LoadProductContext(ctx, ownerUID, watch.BrandID, watch.ProductID) + if perr != nil { + return nil, 0, perr + } + return s.judgeProductCandidate(ctx, ownerUID, profile, watch, cand, product) + } + return s.judgeCandidateGeneric(ctx, ownerUID, profile, watch, cand) +} + +func (s *Service) judgeCandidateGeneric(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) (res *JudgeResult, credits int, err error) { if cand == nil { return nil, 0, fmt.Errorf("%w: candidate required", domain.ErrValidation) } @@ -67,6 +82,7 @@ func (s *Service) JudgeCandidate(ctx context.Context, ownerUID int64, profile *d if parsed.IntentBand == "" { parsed.IntentBand = domain.BandFromScore(parsed.IntentScore) } + parsed.Cacheable = true return parsed, credits, nil } } @@ -104,6 +120,7 @@ func hardReject(reason string, hours int, profile *domain.ServiceProfile, watch RegionMatch: match, FreshnessHours: hours, RejectReason: reason, + Cacheable: true, } } diff --git a/apps/backend/internal/module/radar/usecase/judge_cache.go b/apps/backend/internal/module/radar/usecase/judge_cache.go new file mode 100644 index 0000000..62374de --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/judge_cache.go @@ -0,0 +1,90 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +// JudgeCandidate is the metered boundary. A successful structured result is +// cached by source identity and DemandMap version; cache hits return zero +// credits and never call the provider again. +func (s *Service) JudgeCandidate(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost) (*JudgeResult, int, error) { + key := s.judgeCacheKey(ctx, ownerUID, watch, cand) + if key != "" { + if result, ok := s.cachedJudgeResult(key); ok { + return result, 0, nil + } + } + result, credits, err := s.judgeCandidateUncached(ctx, ownerUID, profile, watch, cand) + if err == nil && result != nil && result.Cacheable && key != "" { + s.storeJudgeResult(key, result) + } + return result, credits, err +} + +func (s *Service) judgeCacheKey(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost) string { + if cand == nil || strings.TrimSpace(cand.ExternalID) == "" { + return "" + } + contextKey := "generic" + mapVersion := int64(0) + if watch != nil { + contextKey = watch.ContextMode + ":" + watch.ProductID + if watch.ContextMode == domain.WatchContextProduct && s.Repo != nil { + if dm, err := s.GetDemandMap(ctx, ownerUID, watch.ProductID); err == nil && dm != nil { + mapVersion = dm.MapVersion + } + } + } + raw := fmt.Sprintf("%d|%s|%d|%s|%s|%d", ownerUID, contextKey, mapVersion, cand.ExternalID, cand.Text, cand.PostedAt) + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func (s *Service) cachedJudgeResult(key string) (*JudgeResult, bool) { + s.judgeCacheMu.Lock() + defer s.judgeCacheMu.Unlock() + if s.judgeCache == nil { + s.judgeCache = map[string]cachedJudge{} + } + value, ok := s.judgeCache[key] + if !ok || domain.NowNano()-value.createdAt > int64(24*60*60*1e9) { + if ok { + delete(s.judgeCache, key) + } + return nil, false + } + return cloneJudgeResult(value.result), true +} + +func (s *Service) storeJudgeResult(key string, result *JudgeResult) { + s.judgeCacheMu.Lock() + defer s.judgeCacheMu.Unlock() + if s.judgeCache == nil { + s.judgeCache = map[string]cachedJudge{} + } + if len(s.judgeCache) >= 2048 { + for oldKey := range s.judgeCache { + delete(s.judgeCache, oldKey) + break + } + } + s.judgeCache[key] = cachedJudge{result: cloneJudgeResult(result), createdAt: domain.NowNano()} +} + +func cloneJudgeResult(in *JudgeResult) *JudgeResult { + if in == nil { + return nil + } + out := *in + out.Reasons = append([]domain.OpportunityReason(nil), in.Reasons...) + if in.ProductMatch != nil { + out.ProductMatch = domain.CloneProductMatch(in.ProductMatch) + } + return &out +} diff --git a/apps/backend/internal/module/radar/usecase/judge_cache_test.go b/apps/backend/internal/module/radar/usecase/judge_cache_test.go new file mode 100644 index 0000000..e9deee7 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/judge_cache_test.go @@ -0,0 +1,43 @@ +package usecase + +import ( + "context" + "testing" + + "apps/backend/internal/module/ai" + "apps/backend/internal/module/radar/domain" +) + +func TestJudgeCacheAvoidsSecondProviderCall(t *testing.T) { + client := &countingJudgeAI{} + svc := New(nil) + svc.AI = client + cand := &domain.CandidatePost{ExternalID: "https://threads.net/@a/post/1", Text: "有人推薦保母嗎", Classification: "seeking_recommendation"} + first, firstCredits, err := svc.JudgeCandidate(context.Background(), 42, nil, nil, cand) + if err != nil { + t.Fatal(err) + } + second, secondCredits, err := svc.JudgeCandidate(context.Background(), 42, nil, nil, cand) + if err != nil { + t.Fatal(err) + } + if client.calls != 1 || first == nil || second == nil || firstCredits == 0 || secondCredits != 0 { + t.Fatalf("cache/provider accounting mismatch: calls=%d first=%d second=%d", client.calls, firstCredits, secondCredits) + } +} + +type countingJudgeAI struct{ calls int } + +func (c *countingJudgeAI) Complete(context.Context, string, string, string) (string, error) { + c.calls++ + return `{"status":"qualified","intent_score":80,"region_match":"unknown","reasons":[{"dimension":"authenticity","score":30,"reason":"真實求助"},{"dimension":"intent","score":30,"reason":"明確求推薦"},{"dimension":"region","score":15,"reason":"未提供地區"},{"dimension":"freshness","score":15,"reason":"近期貼文"},{"dimension":"fit","score":10,"reason":"命中需求"}]}`, nil +} +func (c *countingJudgeAI) CompleteStream(context.Context, string, string, string, func(string) error) (string, error) { + c.calls++ + return `{"status":"qualified","intent_score":80,"region_match":"unknown","reasons":[{"dimension":"authenticity","score":30,"reason":"真實求助"},{"dimension":"intent","score":30,"reason":"明確求推薦"},{"dimension":"region","score":15,"reason":"未提供地區"},{"dimension":"freshness","score":15,"reason":"近期貼文"},{"dimension":"fit","score":10,"reason":"命中需求"}]}`, nil +} +func (c *countingJudgeAI) ListModels(context.Context, string) ([]string, error) { + return []string{"test"}, nil +} + +var _ ai.Client = (*countingJudgeAI)(nil) diff --git a/apps/backend/internal/module/radar/usecase/judge_persist.go b/apps/backend/internal/module/radar/usecase/judge_persist.go index 470a695..9b0a07e 100644 --- a/apps/backend/internal/module/radar/usecase/judge_persist.go +++ b/apps/backend/internal/module/radar/usecase/judge_persist.go @@ -8,8 +8,8 @@ import ( ) type scoredCandidate struct { - cand *domain.CandidatePost - result *JudgeResult + cand *domain.CandidatePost + result *JudgeResult credits int } @@ -30,6 +30,7 @@ func (s *Service) ProcessCandidates( cands []*domain.CandidatePost, alreadyJudged map[string]bool, ) (created, judged, truncated, failed int, credits int, err error) { + matchEvaluated, matchMerged, fitRejected, budgetDeferred := 0, 0, 0, 0 if alreadyJudged == nil { alreadyJudged = map[string]bool{} } @@ -50,16 +51,42 @@ func (s *Service) ProcessCandidates( // Spec: judge count hard-capped by daily max. judgeBudget := remaining if judgeBudget <= 0 { - // Still may hit only-merge paths; mark all as truncated without judging if no room. - truncated = len(cands) - if sweepID != "" { - _, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{TruncatedCount: truncated}) + // Existing opportunities may still receive a previously unseen product + // match; only genuinely new opportunities are quota-truncated. + for _, c := range cands { + if c == nil || c.ExternalID == "" || alreadyJudged[c.ExternalID] { + continue + } + existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, c.ExternalID) + if gerr == nil && existing != nil && watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil { + _, _ = s.Repo.UpsertByExternalID(ctx, &domain.Opportunity{OwnerUID: ownerUID, ExternalID: c.ExternalID, MatchedTerms: []string{c.MatchedTerm}}) + matchEvaluated++ + pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing) + credits += pcredits + judged++ + if perr == nil { + matchMerged++ + } else { + failed++ + } + continue + } + truncated++ + budgetDeferred++ } - return 0, 0, truncated, 0, 0, nil + if sweepID != "" { + _, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedCount: judged, TruncatedCount: truncated, BudgetDeferredCount: budgetDeferred, CreditsUsed: credits, CreditJudge: credits, MatchEvaluatedCount: matchEvaluated, MatchMergedCount: matchMerged, FitRejectedCount: fitRejected}) + } + return 0, judged, truncated, failed, credits, nil } var scored []scoredCandidate var judgedIDs []string + checkpoint := func() { + if sweepID != "" && len(judgedIDs)%10 == 0 && len(judgedIDs) > 0 { + _, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedExternalIDs: judgedIDs}) + } + } for _, c := range cands { if c == nil || c.ExternalID == "" { @@ -77,27 +104,49 @@ func (s *Service) ProcessCandidates( ExternalID: c.ExternalID, MatchedTerms: []string{term}, }) + if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil { + matchEvaluated++ + } + if pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing); perr != nil { + credits += pcredits + failed++ + } else { + credits += pcredits + if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil { + matchMerged++ + } + } judgedIDs = append(judgedIDs, c.ExternalID) judged++ + checkpoint() continue } if len(scored) >= judgeBudget { truncated++ + budgetDeferred++ continue } + if watch != nil && watch.ContextMode == domain.WatchContextProduct { + matchEvaluated++ + } res, cred, jerr := s.JudgeCandidate(ctx, ownerUID, profile, watch, c) credits += cred if jerr != nil || res == nil { failed++ judgedIDs = append(judgedIDs, c.ExternalID) judged++ + checkpoint() continue } + if res.ProductMatch != nil && (!res.ProductMatch.Eligible || res.ProductMatch.Excluded) { + fitRejected++ + } scored = append(scored, scoredCandidate{cand: c, result: res, credits: cred}) judgedIDs = append(judgedIDs, c.ExternalID) judged++ + checkpoint() } // Rank qualified/rejected by score desc; always persist rejected; qualified subject to remaining. @@ -118,6 +167,7 @@ func (s *Service) ProcessCandidates( } if remaining <= 0 { truncated++ + budgetDeferred++ continue } if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil { @@ -130,11 +180,16 @@ func (s *Service) ProcessCandidates( if sweepID != "" { delta := domain.SweepDelta{ - JudgedCount: judged, - CreatedCount: created, - TruncatedCount: truncated, - CreditsUsed: credits, - JudgedExternalIDs: judgedIDs, + JudgedCount: judged, + CreatedCount: created, + TruncatedCount: truncated, + CreditsUsed: credits, + CreditJudge: credits, + JudgedExternalIDs: judgedIDs, + MatchEvaluatedCount: matchEvaluated, + MatchMergedCount: matchMerged, + FitRejectedCount: fitRejected, + BudgetDeferredCount: budgetDeferred, } if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil { return created, judged, truncated, failed, credits, uerr @@ -151,26 +206,46 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain. if watch != nil { watchID = watch.ID } + priority := scoreOpportunityPriority(cand, res) o := &domain.Opportunity{ - ID: domain.NewID(), - OwnerUID: ownerUID, - WatchID: watchID, - Source: domain.OppSourceThreads, - ExternalID: cand.ExternalID, - Permalink: cand.Permalink, - AuthorHandle: cand.AuthorHandle, - Text: cand.Text, - PostedAt: cand.PostedAt, - Status: res.Status, - IntentScore: res.IntentScore, - IntentBand: res.IntentBand, - Reasons: res.Reasons, - RegionDetected: res.RegionDetected, - RegionMatch: res.RegionMatch, - FreshnessHours: res.FreshnessHours, - MatchedService: res.MatchedService, - MatchedTerms: []string{cand.MatchedTerm}, - RejectReason: res.RejectReason, + ID: domain.NewID(), + OwnerUID: ownerUID, + WatchID: watchID, + Source: domain.OppSourceThreads, + ExternalID: cand.ExternalID, + Permalink: cand.Permalink, + AuthorHandle: cand.AuthorHandle, + Text: cand.Text, + PostedAt: cand.PostedAt, + Status: res.Status, + IntentScore: res.IntentScore, + IntentBand: res.IntentBand, + Reasons: res.Reasons, + RegionDetected: res.RegionDetected, + RegionMatch: res.RegionMatch, + FreshnessHours: res.FreshnessHours, + MatchedService: res.MatchedService, + MatchedTerms: []string{cand.MatchedTerm}, + RejectReason: res.RejectReason, + ProductMatches: nil, + PriorityScore: priority.Priority, + PriorityBand: priority.Band, + PainFitScore: priority.PainFit, + DemandIntentScore: priority.DemandIntent, + EvidenceQualityScore: priority.EvidenceQuality, + FreshnessScore: priority.Freshness, + DemandEvidence: priority.Evidence, + } + if watch != nil && watch.ContextMode == domain.WatchContextProduct { + if dm, derr := s.GetDemandMap(ctx, ownerUID, watch.ProductID); derr == nil && dm != nil { + o.DemandInputVersion, o.DemandMapVersion = dm.DemandInputVersion, dm.MapVersion + } + } + if res.ProductMatch != nil { + o.ProductMatches = []*domain.ProductMatch{domain.CloneProductMatch(res.ProductMatch)} + } + if err := mergeProductMatchIntoOpportunity(o, res.ProductMatch); res.ProductMatch != nil && err != nil { + return err } if o.IntentBand == "" { o.ApplyBandFromScore() @@ -178,5 +253,3 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain. _, err := s.Repo.UpsertByExternalID(ctx, o) return err } - - diff --git a/apps/backend/internal/module/radar/usecase/manual_import.go b/apps/backend/internal/module/radar/usecase/manual_import.go index 7c3f28b..4544bf9 100644 --- a/apps/backend/internal/module/radar/usecase/manual_import.go +++ b/apps/backend/internal/module/radar/usecase/manual_import.go @@ -34,6 +34,7 @@ type ManualImportResult struct { const ( ImportStatusSkipped = "skipped" + ImportStatusMerged = "merged" ImportStatusFailed = "failed" ) @@ -45,6 +46,20 @@ ImportManualOpportunities 讓使用者貼 Threads/Facebook 貼文網址(或 同一 owner 下同網址已匯入過 → 略過不重判(跟 sweep 的 dedupe 邏輯一致)。 */ func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem) ([]ManualImportResult, error) { + return s.importManualOpportunities(ctx, ownerUID, items, "", "") +} + +// ImportManualProductOpportunities runs the same manual-import path with an +// owned Brand/Product context and attaches ProductMatch evidence. +func (s *Service) ImportManualProductOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem, brandID, productID string) ([]ManualImportResult, error) { + brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID) + if (brandID == "") != (productID == "") { + return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation) + } + return s.importManualOpportunities(ctx, ownerUID, items, brandID, productID) +} + +func (s *Service) importManualOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem, brandID, productID string) ([]ManualImportResult, error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner required", domain.ErrValidation) } @@ -56,6 +71,14 @@ func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, } profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID) + var product *ProductContextSnapshot + if brandID != "" { + var err error + product, err = s.LoadProductContext(ctx, ownerUID, brandID, productID) + if err != nil { + return nil, err + } + } now := domain.NowNano() results := make([]ManualImportResult, 0, len(items)) @@ -76,6 +99,24 @@ func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, } if existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, rawURL); gerr == nil && existing != nil { + if product != nil && productMatchFor(existing, product.ProductID) == nil { + postedAt := item.PostedAt + if postedAt <= 0 { + postedAt = now + } + cand := &domain.CandidatePost{ExternalID: rawURL, Permalink: rawURL, AuthorHandle: item.Author, Text: text, PostedAt: postedAt, MatchedTerm: "manual_import", Classification: classifyCandidate(strings.ToLower(text))} + match, _, merr := s.scoreProductFit(ctx, ownerUID, product, cand) + if merr == nil { + match.MatchedTerms = []string{"manual_import"} + _, merr = s.Repo.MergeProductMatch(ctx, ownerUID, existing.ID, match) + } + if merr != nil { + results = append(results, ManualImportResult{URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusFailed, Error: "產品匹配合併失敗,請稍後再試"}) + } else { + results = append(results, ManualImportResult{URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusMerged, IntentBand: existing.IntentBand, IntentScore: existing.IntentScore, Error: "已補上產品匹配,未重建商機"}) + } + continue + } results = append(results, ManualImportResult{ URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusSkipped, IntentBand: existing.IntentBand, IntentScore: existing.IntentScore, @@ -96,8 +137,12 @@ func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, ExternalID: rawURL, Permalink: rawURL, AuthorHandle: author, Text: text, PostedAt: postedAt, MatchedTerm: "manual_import", Classification: classifyCandidate(strings.ToLower(text)), } + var judgeWatch *domain.RadarWatch + if product != nil { + judgeWatch = &domain.RadarWatch{ContextMode: domain.WatchContextProduct, BrandID: product.BrandID, ProductID: product.ProductID, ID: "manual-import"} + } - res, _, jerr := s.JudgeCandidate(ctx, ownerUID, profile, nil, cand) + res, _, jerr := s.JudgeCandidate(ctx, ownerUID, profile, judgeWatch, cand) if jerr != nil || res == nil { results = append(results, ManualImportResult{URL: rawURL, Status: ImportStatusFailed, Error: "判定失敗,請稍後再試"}) continue @@ -111,6 +156,11 @@ func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, FreshnessHours: res.FreshnessHours, MatchedService: res.MatchedService, MatchedTerms: []string{"manual_import"}, RejectReason: res.RejectReason, } + if res.ProductMatch != nil { + res.ProductMatch.MatchedTerms = []string{"manual_import"} + o.ProductMatches = []*domain.ProductMatch{res.ProductMatch} + domain.ApplyPrimaryProduct(o) + } saved, perr := s.Repo.UpsertByExternalID(ctx, o) if perr != nil { results = append(results, ManualImportResult{URL: rawURL, Status: ImportStatusFailed, Error: "儲存失敗,請稍後再試"}) diff --git a/apps/backend/internal/module/radar/usecase/opportunity_accuracy_test.go b/apps/backend/internal/module/radar/usecase/opportunity_accuracy_test.go new file mode 100644 index 0000000..6ae7dc8 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/opportunity_accuracy_test.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestOpportunityAccuracyPrecisionAtTenAndHighEvidenceGate(t *testing.T) { + cases := loadAccuracyCases(t) + if len(cases) < 10 { + t.Fatal("accuracy fixture must contain ten cases") + } + truePositive, highWithoutEvidence, providerInHigh := 0, 0, 0 + for _, item := range cases[:10] { + candidate := &domain.CandidatePost{ExternalID: "https://threads.net/post/" + item.ID, Text: item.Text} + filtered, stats := PrefilterCandidates([]*domain.CandidatePost{candidate}, &domain.RadarWatch{Terms: item.ProductTerms}, nil) + predictedRelevant := len(filtered) > 0 && stats.Rejected == 0 + if predictedRelevant && item.ExpectedDemand { + truePositive++ + } + if predictedRelevant && !item.ExpectedDemand { + providerInHigh++ + } + if predictedRelevant && item.ExpectedDemand && len(item.ProductTerms) == 0 { + highWithoutEvidence++ + } + } + precision := float64(truePositive) / 10 + if precision < 0.8 || highWithoutEvidence != 0 || providerInHigh != 0 { + t.Fatalf("precision gate failed: precision_at_10=%.2f high_without_evidence=%d provider_in_high=%d", precision, highWithoutEvidence, providerInHigh) + } +} diff --git a/apps/backend/internal/module/radar/usecase/opportunity_inbox_race_test.go b/apps/backend/internal/module/radar/usecase/opportunity_inbox_race_test.go new file mode 100644 index 0000000..8ee7b96 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/opportunity_inbox_race_test.go @@ -0,0 +1,42 @@ +package usecase + +import ( + "context" + "sync" + "testing" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func TestOpportunityInboxConcurrentUpsertKeepsOneIdentity(t *testing.T) { + repo := repository.NewMemory() + const workers = 20 + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, _ = repo.UpsertByExternalID(context.Background(), &domain.Opportunity{ + OwnerUID: 42, ExternalID: "https://threads.net/@a/post/1", Permalink: "https://threads.net/@a/post/1", + Text: "有人推薦保母嗎", Status: domain.OppQualified, IntentScore: 60, + IntentBand: domain.BandMid, Reasons: validRaceReasons(), MatchedTerms: []string{"保母", string(rune('a' + i%3))}, + }) + }(i) + } + wg.Wait() + list, total, err := repo.ListOpportunities(context.Background(), 42, domain.OpportunityListFilter{}) + if err != nil || total != 1 || len(list) != 1 { + t.Fatalf("duplicate identity created: total=%d list=%d err=%v", total, len(list), err) + } +} + +func validRaceReasons() []domain.OpportunityReason { + return []domain.OpportunityReason{ + {Dimension: domain.DimAuthenticity, Score: 30, Reason: "真實求助"}, + {Dimension: domain.DimIntent, Score: 30, Reason: "明確求推薦"}, + {Dimension: domain.DimRegion, Score: 15, Reason: "地區未知"}, + {Dimension: domain.DimFreshness, Score: 15, Reason: "近期"}, + {Dimension: domain.DimFit, Score: 10, Reason: "命中服務"}, + } +} diff --git a/apps/backend/internal/module/radar/usecase/opportunity_ops.go b/apps/backend/internal/module/radar/usecase/opportunity_ops.go index 9cbcc77..f511d20 100644 --- a/apps/backend/internal/module/radar/usecase/opportunity_ops.go +++ b/apps/backend/internal/module/radar/usecase/opportunity_ops.go @@ -3,6 +3,7 @@ package usecase import ( "context" "fmt" + "strings" "apps/backend/internal/module/radar/domain" ) @@ -19,9 +20,40 @@ func (s *Service) GetOpportunity(ctx context.Context, ownerUID int64, id string) } func (s *Service) ListOpportunities(ctx context.Context, ownerUID int64, f domain.OpportunityListFilter) ([]*domain.Opportunity, int64, error) { + if ownerUID <= 0 { + return nil, 0, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) + } + if f.FitBand != "" && !domain.IsProductFitBand(f.FitBand) { + return nil, 0, fmt.Errorf("%w: unknown product fit band %q", domain.ErrValidation, f.FitBand) + } + if f.MatchState != "" && !strings.Contains(" eligible weak excluded generic stale ", " "+f.MatchState+" ") { + return nil, 0, fmt.Errorf("%w: unknown product match state %q", domain.ErrValidation, f.MatchState) + } + if f.Sort != "" && f.Sort != "posted" && f.Sort != "score" && f.Sort != "recommended" && f.Sort != "newest" && f.Sort != "oldest" && f.Sort != "product_fit" && f.Sort != "demand_intent" && f.Sort != "priority" && f.Sort != "priority_score" { + return nil, 0, fmt.Errorf("%w: unknown opportunity sort %q", domain.ErrValidation, f.Sort) + } + if f.ReviewState != "" && !domain.IsReviewState(f.ReviewState) { + return nil, 0, fmt.Errorf("%w: unknown review state %q", domain.ErrValidation, f.ReviewState) + } + if f.TimeScope != "" && f.TimeScope != "today" && f.TimeScope != "7d" && f.TimeScope != "all" { + return nil, 0, fmt.Errorf("%w: unknown time scope %q", domain.ErrValidation, f.TimeScope) + } + if f.PriorityBand != "" && f.PriorityBand != "high" && f.PriorityBand != "review" && f.PriorityBand != "low" { + return nil, 0, fmt.Errorf("%w: unknown priority band %q", domain.ErrValidation, f.PriorityBand) + } return s.Repo.ListOpportunities(ctx, ownerUID, f) } +func (s *Service) UpdateOpportunityReviewState(ctx context.Context, ownerUID int64, id string, patch domain.ReviewStatePatch) (*domain.Opportunity, error) { + if ownerUID <= 0 || strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("%w: owner and opportunity are required", domain.ErrValidation) + } + if patch.State == domain.ReviewRemoved && patch.RemovalReason == "" { + return nil, fmt.Errorf("%w: removal reason required", domain.ErrValidation) + } + return s.Repo.UpdateOpportunityReviewState(ctx, ownerUID, id, patch) +} + func (s *Service) ListSweeps(ctx context.Context, ownerUID int64, f domain.SweepListFilter) ([]*domain.RadarSweep, int64, error) { return s.Repo.ListSweeps(ctx, ownerUID, f) } @@ -66,20 +98,17 @@ func (s *Service) AcceptOpportunity(ctx context.Context, ownerUID int64, id stri return nil, "", err } if contactID != "" { - // store contact_id via override path isn't right — re-upsert fields - o2, _ := s.Repo.GetOpportunity(ctx, id) - if o2 != nil { - o2.ContactID = contactID - o2.Status = domain.OppAccepted - // Use SetOpportunityOverride no-op and a simple save: Upsert won't rewrite existing without terms merge. - // Patch via SetOverride with status already updated — add contact via list reload. - _ = s.setContactID(ctx, id, contactID) - o2.ContactID = contactID - return o2, contactID, nil + if err := s.setContactID(ctx, id, contactID); err != nil { + return nil, "", err } } - o, err = s.Repo.GetOpportunity(ctx, id) - return o, contactID, err + // 加入 CRM 是收件匣的正向完成動作。業務狀態 accepted 與工作狀態 + // completed 必須一起成立,否則加入名單後同一張卡仍會留在待處理。 + o, err = s.Repo.UpdateOpportunityReviewState(ctx, ownerUID, id, domain.ReviewStatePatch{State: domain.ReviewCompleted}) + if err != nil { + return nil, contactID, err + } + return o, contactID, nil } func (s *Service) setContactID(ctx context.Context, id, contactID string) error { diff --git a/apps/backend/internal/module/radar/usecase/primary_product.go b/apps/backend/internal/module/radar/usecase/primary_product.go new file mode 100644 index 0000000..008ef2c --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/primary_product.go @@ -0,0 +1,36 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +// SetPrimaryProduct records an explicit member choice. Repository guards keep +// owner and ProductMatch identity checks close to the mutation as well. +func (s *Service) SetPrimaryProduct(ctx context.Context, ownerUID int64, opportunityID, productID, reason string) (*domain.Opportunity, error) { + if ownerUID <= 0 || strings.TrimSpace(opportunityID) == "" || strings.TrimSpace(productID) == "" { + return nil, fmt.Errorf("%w: owner, opportunity, and product required", domain.ErrValidation) + } + o, err := s.GetOpportunity(ctx, ownerUID, opportunityID) + if err != nil { + return nil, err + } + var match *domain.ProductMatch + for _, candidate := range o.ProductMatches { + if candidate != nil && candidate.ProductID == productID { + match = candidate + break + } + } + if match == nil { + return nil, fmt.Errorf("%w: product is not matched to this opportunity", domain.ErrValidation) + } + reason = strings.TrimSpace(reason) + if reason == "" { + return nil, fmt.Errorf("%w: primary product reason required", domain.ErrValidation) + } + return s.Repo.SetPrimaryProduct(ctx, ownerUID, opportunityID, productID, &domain.ProductPrimaryOverride{ActorUID: ownerUID, Reason: reason, At: domain.NowNano()}) +} diff --git a/apps/backend/internal/module/radar/usecase/primary_product_test.go b/apps/backend/internal/module/radar/usecase/primary_product_test.go new file mode 100644 index 0000000..3ae4a80 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/primary_product_test.go @@ -0,0 +1,64 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func TestSetPrimaryProductRequiresMatchAndReasonForWeak(t *testing.T) { + ctx := context.Background() + svc := New(repository.NewMemory()) + op, err := svc.Repo.UpsertByExternalID(ctx, integrationOpportunity(42)) + if err != nil { + t.Fatal(err) + } + weak := &domain.ProductMatch{BrandID: "b", ProductID: "weak", ProductFitScore: 0, ProductFitBand: domain.ProductFitBandWeak, Reasons: []domain.ProductFitReason{{Dimension: domain.ProductFitPain, Reason: "none"}, {Dimension: domain.ProductFitScenario, Reason: "none"}, {Dimension: domain.ProductFitAudience, Reason: "none"}, {Dimension: domain.ProductFitCapability, Reason: "none"}}} + if _, err := svc.Repo.MergeProductMatch(ctx, 42, op.ID, weak); err != nil { + t.Fatal(err) + } + if _, err := svc.SetPrimaryProduct(ctx, 42, op.ID, "missing", "no"); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("missing match err=%v", err) + } + if _, err := svc.SetPrimaryProduct(ctx, 42, op.ID, "weak", ""); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("missing reason err=%v", err) + } + selected, err := svc.SetPrimaryProduct(ctx, 42, op.ID, "weak", "人工確認特殊情境") + if err != nil { + t.Fatal(err) + } + if selected.PrimaryProductID != "weak" || !selected.PrimaryProductOverridden || selected.PrimaryProductOverride == nil || selected.PrimaryProductOverride.ActorUID != 42 { + t.Fatalf("override audit missing: %+v", selected) + } +} + +func TestSetPrimaryProductOverrideSurvivesFutureHigherMerge(t *testing.T) { + ctx := context.Background() + svc := New(repository.NewMemory()) + op, err := svc.Repo.UpsertByExternalID(ctx, integrationOpportunity(42)) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Repo.MergeProductMatch(ctx, 42, op.ID, integrationMatch("p1", 60)); err != nil { + t.Fatal(err) + } + if _, err := svc.Repo.MergeProductMatch(ctx, 42, op.ID, integrationMatch("p2", 80)); err != nil { + t.Fatal(err) + } + if _, err := svc.SetPrimaryProduct(ctx, 42, op.ID, "p1", "客戶已指定"); err != nil { + t.Fatal(err) + } + if _, err := svc.Repo.MergeProductMatch(ctx, 42, op.ID, integrationMatch("p3", 100)); err != nil { + t.Fatal(err) + } + got, err := svc.GetOpportunity(ctx, 42, op.ID) + if err != nil { + t.Fatal(err) + } + if got.PrimaryProductID != "p1" || !got.PrimaryProductOverridden { + t.Fatalf("future merge replaced override: %+v", got) + } +} diff --git a/apps/backend/internal/module/radar/usecase/priority.go b/apps/backend/internal/module/radar/usecase/priority.go new file mode 100644 index 0000000..8eabb5c --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/priority.go @@ -0,0 +1,65 @@ +package usecase + +import ( + "strings" + "unicode/utf8" + + "apps/backend/internal/module/radar/domain" +) + +type priorityResult struct { + Priority, PainFit, DemandIntent, EvidenceQuality, Freshness int + Band string + Evidence []string +} + +func scoreOpportunityPriority(cand *domain.CandidatePost, result *JudgeResult) priorityResult { + if cand == nil || result == nil { + return priorityResult{} + } + intent, freshness, fit := 0, 0, 0 + for _, reason := range result.Reasons { + switch reason.Dimension { + case domain.DimIntent: + intent = reason.Score + case domain.DimFreshness: + freshness = reason.Score + case domain.DimFit: + fit = reason.Score + } + } + if result.ProductMatch != nil { + fit = result.ProductMatch.ProductFitScore + } + intent100 := intent * 100 / domain.WeightIntent + fresh100 := freshness * 100 / domain.WeightFreshness + evidence := make([]string, 0, 4) + if strings.TrimSpace(cand.MatchedTerm) != "" { + evidence = append(evidence, "命中詞:"+strings.TrimSpace(cand.MatchedTerm)) + } + for _, reason := range result.Reasons { + if strings.TrimSpace(reason.Reason) == "" { + continue + } + text := strings.TrimSpace(reason.Reason) + if utf8.RuneCountInString(text) > 60 { + text = string([]rune(text)[:60]) + } + evidence = append(evidence, text) + if len(evidence) >= 4 { + break + } + } + evidenceQuality := len(evidence) * 20 + if evidenceQuality > 100 { + evidenceQuality = 100 + } + priority := (intent100*35 + fit*35 + evidenceQuality*20 + fresh100*10) / 100 + band := "low" + if priority >= 75 { + band = "high" + } else if priority >= 50 { + band = "review" + } + return priorityResult{Priority: priority, Band: band, PainFit: fit, DemandIntent: intent100, EvidenceQuality: evidenceQuality, Freshness: fresh100, Evidence: evidence} +} diff --git a/apps/backend/internal/module/radar/usecase/priority_test.go b/apps/backend/internal/module/radar/usecase/priority_test.go new file mode 100644 index 0000000..7eea4a5 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/priority_test.go @@ -0,0 +1,22 @@ +package usecase + +import ( + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestScoreOpportunityPriorityIncludesEvidenceAndFreshness(t *testing.T) { + got := scoreOpportunityPriority(&domain.CandidatePost{MatchedTerm: "泛紅", Text: "泛紅換季怎麼辦"}, &JudgeResult{ + IntentScore: 80, + Reasons: []domain.OpportunityReason{ + {Dimension: domain.DimIntent, Score: 30, Reason: "明確求助"}, + {Dimension: domain.DimFreshness, Score: 15, Reason: "今天發布"}, + {Dimension: domain.DimFit, Score: 10, Reason: "命中痛點"}, + {Dimension: domain.DimAuthenticity, Score: 30, Reason: "真實語氣"}, + }, + }) + if got.Priority < 50 || got.Band == "low" || got.DemandIntent != 100 || got.Freshness != 100 || len(got.Evidence) == 0 { + t.Fatalf("unexpected priority: %+v", got) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_context.go b/apps/backend/internal/module/radar/usecase/product_context.go new file mode 100644 index 0000000..7f4b598 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_context.go @@ -0,0 +1,68 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +type ProductBrand struct { + ID, DisplayName, TargetAudience string + OwnerUID, UpdatedAt int64 +} + +type ProductCatalogProduct struct { + ID, BrandID, Label, ProductContext string + PainPoints, MatchTags []string + ProviderCapabilityTerms []string + ProviderExcludeTerms []string + OwnerUID, UpdatedAt int64 +} + +// ProductContextSource is deliberately read-only. Product CRUD remains owned +// by Scout; Radar only validates ownership and takes judgement-time snapshots. +type ProductContextSource interface { + GetBrand(ctx context.Context, ownerUID int64, id string) (*ProductBrand, error) + GetProduct(ctx context.Context, ownerUID int64, id string) (*ProductCatalogProduct, error) +} + +type ProductContextSnapshot struct { + BrandID, ProductID string + BrandName, TargetAudience string + ProductLabel, ProductContext string + PainPoints, MatchTags []string + ProviderCapabilityTerms, ProviderExcludeTerms []string + BrandUpdatedAt, ProductUpdatedAt int64 +} + +func (s *Service) LoadProductContext(ctx context.Context, ownerUID int64, brandID, productID string) (*ProductContextSnapshot, error) { + if ownerUID <= 0 || strings.TrimSpace(brandID) == "" || strings.TrimSpace(productID) == "" { + return nil, fmt.Errorf("%w: owner and paired brand/product IDs required", domain.ErrValidation) + } + if s.ProductSource == nil { + return nil, fmt.Errorf("%w: product catalog unavailable", domain.ErrNotReady) + } + brand, err := s.ProductSource.GetBrand(ctx, ownerUID, brandID) + if err != nil { + return nil, err + } + product, err := s.ProductSource.GetProduct(ctx, ownerUID, productID) + if err != nil { + return nil, err + } + if brand == nil || product == nil { + return nil, domain.ErrNotFound + } + if product.BrandID != brand.ID { + return nil, domain.ErrValidation + } + return &ProductContextSnapshot{ + BrandID: brand.ID, ProductID: product.ID, BrandName: brand.DisplayName, TargetAudience: brand.TargetAudience, + ProductLabel: product.Label, ProductContext: product.ProductContext, + PainPoints: append([]string(nil), product.PainPoints...), MatchTags: append([]string(nil), product.MatchTags...), + ProviderCapabilityTerms: append([]string(nil), product.ProviderCapabilityTerms...), ProviderExcludeTerms: append([]string(nil), product.ProviderExcludeTerms...), + BrandUpdatedAt: brand.UpdatedAt, ProductUpdatedAt: product.UpdatedAt, + }, nil +} diff --git a/apps/backend/internal/module/radar/usecase/product_context_test.go b/apps/backend/internal/module/radar/usecase/product_context_test.go new file mode 100644 index 0000000..390b2f3 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_context_test.go @@ -0,0 +1,54 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +type productSourceFixture struct { + brand *ProductBrand + product *ProductCatalogProduct +} + +func (f productSourceFixture) GetBrand(_ context.Context, owner int64, id string) (*ProductBrand, error) { + if f.brand == nil || f.brand.ID != id { + return nil, domain.ErrNotFound + } + if f.brand.OwnerUID != owner { + return nil, domain.ErrForbidden + } + return f.brand, nil +} +func (f productSourceFixture) GetProduct(_ context.Context, owner int64, id string) (*ProductCatalogProduct, error) { + if f.product == nil || f.product.ID != id { + return nil, domain.ErrNotFound + } + if f.product.OwnerUID != owner { + return nil, domain.ErrForbidden + } + return f.product, nil +} + +func TestLoadProductContextValidatesParentAndSnapshotsVersion(t *testing.T) { + source := productSourceFixture{brand: &ProductBrand{ID: "b1", OwnerUID: 1, DisplayName: "品牌", UpdatedAt: 10}, product: &ProductCatalogProduct{ID: "p1", BrandID: "b1", OwnerUID: 1, Label: "產品", PainPoints: []string{"漏水"}, UpdatedAt: 20}} + svc := New(nil) + svc.ProductSource = source + snap, err := svc.LoadProductContext(context.Background(), 1, "b1", "p1") + if err != nil { + t.Fatal(err) + } + source.product.Label = "新版產品" + if snap.ProductLabel != "產品" || snap.ProductUpdatedAt != 20 { + t.Fatalf("snapshot changed after source update: %#v", snap) + } + if _, err := svc.LoadProductContext(context.Background(), 1, "b1", "missing"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing product: %v", err) + } + source.product.BrandID = "other" + if _, err := svc.LoadProductContext(context.Background(), 1, "b1", "p1"); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("parent mismatch: %v", err) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_domain_integration_test.go b/apps/backend/internal/module/radar/usecase/product_domain_integration_test.go new file mode 100644 index 0000000..bee69ee --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_domain_integration_test.go @@ -0,0 +1,68 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func integrationOpportunity(owner int64) *domain.Opportunity { + return &domain.Opportunity{OwnerUID: owner, ExternalID: "threads:same", Source: domain.OppSourceThreads, Status: domain.OppQualified, IntentScore: 82, IntentBand: domain.BandHigh, RegionMatch: domain.RegionUnknown, Reasons: []domain.OpportunityReason{ + {Dimension: domain.DimAuthenticity, Score: 20, Reason: "公開個人需求"}, {Dimension: domain.DimIntent, Score: 20, Reason: "明確詢問"}, {Dimension: domain.DimRegion, Score: 10, Reason: "未知地區"}, {Dimension: domain.DimFreshness, Score: 20, Reason: "近期"}, {Dimension: domain.DimFit, Score: 12, Reason: "可處理"}, + }, MatchedTerms: []string{"需求"}} +} + +func integrationMatch(id string, score int) *domain.ProductMatch { + audience := score - 60 + if audience < 0 { + audience = 0 + } + if audience > 20 { + audience = 20 + } + return &domain.ProductMatch{BrandID: "brand-1", ProductID: id, ProductFitScore: score, ProductFitBand: domain.ProductFitBandFromScore(score), Reasons: []domain.ProductFitReason{ + {Dimension: domain.ProductFitPain, Score: 35, Reason: "痛點", CandidateExcerpt: "漏水", ProductBasis: "漏水"}, {Dimension: domain.ProductFitScenario, Score: 25, Reason: "情境", CandidateExcerpt: "找人", ProductBasis: "居家"}, {Dimension: domain.ProductFitAudience, Score: audience, Reason: "受眾", CandidateExcerpt: "家裡", ProductBasis: "家庭"}, {Dimension: domain.ProductFitCapability, Score: score - 60 - audience, Reason: "能力", CandidateExcerpt: "抓漏", ProductBasis: "抓漏"}, + }} +} + +func TestProductDomainLegacyOwnerAndSingleOpportunity(t *testing.T) { + ctx := context.Background() + mem := repository.NewMemory() + legacy := &domain.RadarWatch{ID: "legacy-watch", OwnerUID: 1, Terms: []string{"找人"}, Status: domain.WatchActive} + if err := mem.SaveWatch(ctx, legacy); err != nil { + t.Fatal(err) + } + gotWatch, err := mem.GetWatch(ctx, legacy.ID) + if err != nil || gotWatch.ContextMode != domain.WatchContextGeneric { + t.Fatalf("legacy read: %#v %v", gotWatch, err) + } + opportunity, err := mem.UpsertByExternalID(ctx, integrationOpportunity(1)) + if err != nil { + t.Fatal(err) + } + if _, err := mem.MergeProductMatch(ctx, 2, opportunity.ID, integrationMatch("p1", 60)); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("cross-owner match: %v", err) + } + if _, err := mem.MergeProductMatch(ctx, 1, opportunity.ID, integrationMatch("p1", 60)); err != nil { + t.Fatal(err) + } + if _, err := mem.MergeProductMatch(ctx, 1, opportunity.ID, integrationMatch("p2", 80)); err != nil { + t.Fatal(err) + } + if _, err := mem.MergeProductMatch(ctx, 1, opportunity.ID, integrationMatch("p1", 90)); err != nil { + t.Fatal(err) + } + got, err := mem.GetOpportunity(ctx, opportunity.ID) + if err != nil { + t.Fatal(err) + } + if len(got.ProductMatches) != 2 || got.PrimaryProductID != "p2" { + t.Fatalf("single opportunity or primary arbitration failed: %#v", got) + } + if got.ProductMatches[0].ProductLabelSnapshot != "" { + t.Fatalf("unexpected snapshot mutation: %#v", got.ProductMatches[0]) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_fit.go b/apps/backend/internal/module/radar/usecase/product_fit.go new file mode 100644 index 0000000..8ae0ff8 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_fit.go @@ -0,0 +1,166 @@ +package usecase + +import ( + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +// ScoreProductFit evaluates one public candidate against one immutable product +// snapshot. It never treats a brand/product name as demand evidence. +func ScoreProductFit(cand *domain.CandidatePost, product *ProductContextSnapshot) (*domain.ProductMatch, error) { + if cand == nil { + return nil, fmt.Errorf("%w: candidate required", domain.ErrValidation) + } + if product == nil || strings.TrimSpace(product.BrandID) == "" || strings.TrimSpace(product.ProductID) == "" { + return nil, fmt.Errorf("%w: product context required", domain.ErrValidation) + } + corpus := strings.TrimSpace(cand.Text) + if strings.TrimSpace(cand.Title) != "" { + if corpus != "" { + corpus += " " + } + corpus += strings.TrimSpace(cand.Title) + } + + type dimensionInput struct { + dimension string + terms []string + zero string + } + inputs := []dimensionInput{ + {domain.ProductFitPain, product.PainPoints, "貼文沒有明確提到產品痛點"}, + {domain.ProductFitScenario, append(append([]string(nil), product.MatchTags...), product.ProductContext), "貼文沒有明確提到使用或購買情境"}, + {domain.ProductFitAudience, []string{product.TargetAudience}, "貼文沒有明確支持目標受眾,維持 0 分"}, + {domain.ProductFitCapability, product.ProviderCapabilityTerms, "貼文沒有出現可由產品處理的能力詞"}, + } + reasons := make([]domain.ProductFitReason, 0, len(inputs)) + matchedTerms := make([]string, 0, 4) + scores := make(map[string]int, len(inputs)) + for _, input := range inputs { + basis, excerpt := firstProductEvidence(corpus, input.terms, product) + score := 0 + reason := input.zero + if basis != "" && excerpt != "" { + score = domain.ProductFitDimensionWeight(input.dimension) + reason = fmt.Sprintf("貼文片段「%s」對應產品%s「%s」", excerpt, productDimensionLabel(input.dimension), basis) + matchedTerms = append(matchedTerms, basis) + } + scores[input.dimension] = score + reasons = append(reasons, domain.ProductFitReason{ + Dimension: input.dimension, Score: score, Reason: reason, + CandidateExcerpt: excerpt, ProductBasis: basis, + }) + } + + excluded, excludeReason := productCandidateExclusion(corpus, cand, product) + risks := make([]string, 0, 2) + if excluded { + risks = append(risks, excludeReason) + } + if scores[domain.ProductFitAudience] == 0 { + risks = append(risks, "受眾證據不足,未猜測貼文作者身分") + } + if scores[domain.ProductFitPain] == 0 && scores[domain.ProductFitScenario] == 0 { + risks = append(risks, "只有產品/品牌名稱或搜尋詞命中,不能視為需求證據") + } + total := 0 + for _, reason := range reasons { + total += reason.Score + } + match := &domain.ProductMatch{ + BrandID: product.BrandID, ProductID: product.ProductID, + BrandNameSnapshot: product.BrandName, ProductLabelSnapshot: product.ProductLabel, + BrandUpdatedAt: product.BrandUpdatedAt, ProductUpdatedAt: product.ProductUpdatedAt, + ProductFitScore: total, ProductFitBand: domain.ProductFitBandFromScore(total), + Excluded: excludeReason != "" && excluded, ExcludeReason: excludeReason, + Reasons: reasons, Risks: risks, MatchedTerms: matchedTerms, MatchedAt: domain.NowNano(), + } + if err := match.ValidateForWrite(); err != nil { + return nil, err + } + return match, nil +} + +// Method form is convenient for sweep/pipeline callers and keeps the scorer +// independent of repository state. +func (s *Service) ScoreProductFit(cand *domain.CandidatePost, product *ProductContextSnapshot) (*domain.ProductMatch, error) { + return ScoreProductFit(cand, product) +} + +func firstProductEvidence(corpus string, terms []string, product *ProductContextSnapshot) (basis, excerpt string) { + for _, raw := range terms { + term := strings.TrimSpace(raw) + if term == "" || productNameOnly(term, product) { + continue + } + if found := findProductExcerpt(corpus, term); found != "" { + return term, found + } + } + return "", "" +} + +func productNameOnly(term string, product *ProductContextSnapshot) bool { + term = strings.TrimSpace(term) + if term == "" { + return true + } + return strings.EqualFold(term, strings.TrimSpace(product.ProductLabel)) || strings.EqualFold(term, strings.TrimSpace(product.BrandName)) +} + +func findProductExcerpt(corpus, term string) string { + corpus = strings.TrimSpace(corpus) + term = strings.TrimSpace(term) + if corpus == "" || term == "" { + return "" + } + lowerCorpus, lowerTerm := strings.ToLower(corpus), strings.ToLower(term) + idx := strings.Index(lowerCorpus, lowerTerm) + if idx < 0 { + return "" + } + return strings.TrimSpace(corpus[idx : idx+len(term)]) +} + +func productDimensionLabel(dimension string) string { + switch dimension { + case domain.ProductFitPain: + return "痛點" + case domain.ProductFitScenario: + return "情境/標籤" + case domain.ProductFitAudience: + return "目標受眾" + case domain.ProductFitCapability: + return "能力" + default: + return "上下文" + } +} + +func productCandidateExclusion(corpus string, cand *domain.CandidatePost, product *ProductContextSnapshot) (bool, string) { + for _, raw := range product.ProviderExcludeTerms { + term := strings.TrimSpace(raw) + if term != "" && findProductExcerpt(corpus, term) != "" { + return true, fmt.Sprintf("貼文命中產品排除詞「%s」", term) + } + } + classification := strings.ToLower(strings.TrimSpace(cand.Classification)) + switch classification { + case "provider_offer", "announcement", "noise": + return true, "貼文分類為" + classification + ",不是產品需求" + } + // Catch only explicit provider/announcement markers; generic words such as + // "推薦" are intentionally not hard exclusions. + for _, marker := range []string{"歡迎洽詢", "接案中", "品牌公告"} { + if containsProductMarker(corpus, marker) { + return true, "貼文呈現同業供給或品牌公告語氣" + } + } + return false, "" +} + +func containsProductMarker(corpus, marker string) bool { + return strings.Contains(strings.ToLower(corpus), strings.ToLower(marker)) +} diff --git a/apps/backend/internal/module/radar/usecase/product_fit_ai.go b/apps/backend/internal/module/radar/usecase/product_fit_ai.go new file mode 100644 index 0000000..741b8ef --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_fit_ai.go @@ -0,0 +1,162 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" + usageDomain "apps/backend/internal/module/usage/domain" + "github.com/zeromicro/go-zero/core/logx" +) + +type productFitAIResult struct { + ProductFitScore int `json:"product_fit_score"` + ProductFitBand string `json:"product_fit_band"` + Excluded bool `json:"excluded"` + ExcludeReason string `json:"exclude_reason"` + Reasons []domain.ProductFitReason `json:"reasons"` + Risks []string `json:"risks"` +} + +func productFitPrompt(product *ProductContextSnapshot, cand *domain.CandidatePost) string { + return fmt.Sprintf(`你是產品適配判定器,只輸出 JSON,不要猜測產品承諾。 +四維權重固定為 pain=35、scenario=25、audience=20、capability=20。 +產品上下文:品牌=%q;產品=%q;受眾=%q;痛點=%q;情境=%q;標籤=%q;能力=%q;排除詞=%q。 + 候選公開貼文(excerpt 必須逐字取自這段文字):%s +每一維都必須輸出一筆 reason。正分時 candidate_excerpt 必須是候選文字原文短片段,product_basis 必須逐字來自對應產品上下文;找不到證據就 score=0。單純產品/品牌名稱命中不得給 pain 或 scenario 分。受眾不確定給 0。命中排除詞、同業供給、公告或雜訊時 excluded=true 並填 exclude_reason。 + 格式:{"product_fit_score":0,"product_fit_band":"strong|possible|weak","excluded":false,"exclude_reason":"","reasons":[{"dimension":"pain|scenario|audience|capability","score":0,"reason":"人話","candidate_excerpt":"","product_basis":""}],"risks":[]}`, product.BrandName, product.ProductLabel, product.TargetAudience, strings.Join(product.PainPoints, "、"), product.ProductContext, strings.Join(product.MatchTags, "、"), strings.Join(product.ProviderCapabilityTerms, "、"), strings.Join(product.ProviderExcludeTerms, "、"), cand.Text+" "+cand.Title) +} + +func (s *Service) judgeProductCandidate(ctx context.Context, ownerUID int64, profile *domain.ServiceProfile, watch *domain.RadarWatch, cand *domain.CandidatePost, product *ProductContextSnapshot) (*JudgeResult, int, error) { + base, credits, err := s.judgeCandidateGeneric(ctx, ownerUID, profile, watch, cand) + if err != nil || base == nil { + return base, credits, err + } + match, fitCredits, ferr := s.scoreProductFit(ctx, ownerUID, product, cand) + credits += fitCredits + if ferr != nil { + return nil, credits, ferr + } + if watch != nil && watch.ID != "" { + match.WatchIDs = []string{watch.ID} + if err := match.ValidateForWrite(); err != nil { + return nil, credits, err + } + } + base.ProductMatch = match + return base, credits, nil +} + +func (s *Service) scoreProductFit(ctx context.Context, ownerUID int64, product *ProductContextSnapshot, cand *domain.CandidatePost) (_ *domain.ProductMatch, credits int, err error) { + charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAIResearch, "雷達產品適配判定", "radar.product_fit") + if err != nil { + return nil, 0, err + } + defer charge.Settle(ctx, &err) + credits = usageDomain.MeterCost(usageDomain.MeterAIResearch) + + if raw, aiErr := s.completeAI(ctx, ownerUID, productFitPrompt(product, cand)); aiErr == nil { + if parsed, perr := parseProductFitJSON(raw); perr == nil { + if match, verr := validateProductFitAI(parsed, product, cand); verr == nil { + return match, credits, nil + } + logx.Errorf("radar product fit: invalid AI result uid=%d; using deterministic fallback", ownerUID) + } else { + logx.Errorf("radar product fit: AI JSON parse failed uid=%d: %v; using deterministic fallback", ownerUID, perr) + } + } else { + logx.Errorf("radar product fit: provider unavailable uid=%d: %v; using deterministic fallback", ownerUID, aiErr) + } + + match, ferr := ScoreProductFit(cand, product) + if ferr != nil { + return nil, credits, ferr + } + return match, credits, nil +} + +func parseProductFitJSON(raw string) (*productFitAIResult, error) { + start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}") + if start < 0 || end <= start { + return nil, fmt.Errorf("product fit JSON object missing") + } + var parsed productFitAIResult + if err := json.Unmarshal([]byte(raw[start:end+1]), &parsed); err != nil { + return nil, err + } + return &parsed, nil +} + +func validateProductFitAI(in *productFitAIResult, product *ProductContextSnapshot, cand *domain.CandidatePost) (*domain.ProductMatch, error) { + if in == nil || len(in.Reasons) != len(domain.ProductFitDimensionOrder) { + return nil, fmt.Errorf("product fit requires four reasons") + } + for _, reason := range in.Reasons { + if reason.Score > 0 { + if strings.TrimSpace(reason.CandidateExcerpt) == "" || findProductExcerpt(cand.Text+" "+cand.Title, reason.CandidateExcerpt) == "" { + return nil, fmt.Errorf("candidate excerpt is not from source") + } + if !productBasisExists(reason.Dimension, reason.ProductBasis, product) { + return nil, fmt.Errorf("product basis is not in snapshot") + } + } + if strings.TrimSpace(reason.Reason) == "" { + return nil, fmt.Errorf("empty product fit reason") + } + } + match := &domain.ProductMatch{ + BrandID: product.BrandID, ProductID: product.ProductID, + BrandNameSnapshot: product.BrandName, ProductLabelSnapshot: product.ProductLabel, + BrandUpdatedAt: product.BrandUpdatedAt, ProductUpdatedAt: product.ProductUpdatedAt, + ProductFitScore: in.ProductFitScore, ProductFitBand: in.ProductFitBand, + Excluded: in.Excluded, ExcludeReason: strings.TrimSpace(in.ExcludeReason), + Reasons: append([]domain.ProductFitReason(nil), in.Reasons...), Risks: append([]string(nil), in.Risks...), MatchedAt: domain.NowNano(), + } + for _, reason := range match.Reasons { + if reason.Score > 0 && strings.TrimSpace(reason.ProductBasis) != "" { + match.MatchedTerms = append(match.MatchedTerms, strings.TrimSpace(reason.ProductBasis)) + } + } + if match.Risks == nil { + match.Risks = []string{} + } + if err := match.ValidateForWrite(); err != nil { + return nil, err + } + // Hard exclusions are deterministic even when a model forgets to set the flag. + if excluded, reason := productCandidateExclusion(cand.Text+" "+cand.Title, cand, product); excluded { + match.Excluded, match.ExcludeReason = true, reason + if err := match.ValidateForWrite(); err != nil { + return nil, err + } + } + return match, nil +} + +func productBasisExists(dimension, basis string, product *ProductContextSnapshot) bool { + basis = strings.TrimSpace(basis) + if basis == "" { + return false + } + var values []string + switch dimension { + case domain.ProductFitPain: + values = product.PainPoints + case domain.ProductFitScenario: + values = append(append([]string(nil), product.MatchTags...), product.ProductContext) + case domain.ProductFitAudience: + values = []string{product.TargetAudience} + case domain.ProductFitCapability: + values = product.ProviderCapabilityTerms + default: + return false + } + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), basis) { + return true + } + } + return false +} diff --git a/apps/backend/internal/module/radar/usecase/product_fit_ai_test.go b/apps/backend/internal/module/radar/usecase/product_fit_ai_test.go new file mode 100644 index 0000000..c615c84 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_fit_ai_test.go @@ -0,0 +1,75 @@ +package usecase + +import ( + "context" + "errors" + "strings" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +const validProductFitReply = `{ + "product_fit_score": 100, + "product_fit_band": "strong", + "excluded": false, + "reasons": [ + {"dimension":"pain","score":35,"reason":"貼文描述相同困擾","candidate_excerpt":"泛紅不適","product_basis":"泛紅不適"}, + {"dimension":"scenario","score":25,"reason":"貼文是換季保養情境","candidate_excerpt":"換季保養","product_basis":"換季保養"}, + {"dimension":"audience","score":20,"reason":"貼文明確提到受眾","candidate_excerpt":"敏感肌","product_basis":"敏感肌"}, + {"dimension":"capability","score":20,"reason":"需求可由能力處理","candidate_excerpt":"舒緩修護","product_basis":"舒緩修護"} + ], + "risks": [] +}` + +func productJudgeFixture(reply string) (*Service, *domain.RadarWatch, *domain.CandidatePost) { + svc := New(nil) + svc.ProductSource = productSourceFixture{brand: &ProductBrand{ID: "b1", OwnerUID: 42, DisplayName: "清透品牌", TargetAudience: "敏感肌"}, product: &ProductCatalogProduct{ID: "p1", BrandID: "b1", OwnerUID: 42, Label: "溫和保養", PainPoints: []string{"泛紅不適"}, MatchTags: []string{"換季保養"}, ProductContext: "換季保養", ProviderCapabilityTerms: []string{"舒緩修護"}}} + svc.AI = &stubAI{reply: reply} + return svc, &domain.RadarWatch{ID: "w1", OwnerUID: 42, Status: domain.WatchActive, ContextMode: domain.WatchContextProduct, BrandID: "b1", ProductID: "p1"}, &domain.CandidatePost{ExternalID: "e1", Text: "敏感肌換季保養泛紅不適,想找舒緩修護", Classification: "demand"} +} + +func TestJudgeProductCandidateAcceptsOnlyStructuredEvidence(t *testing.T) { + svc, watch, cand := productJudgeFixture(validProductFitReply) + result, _, err := svc.JudgeCandidate(context.Background(), 42, nil, watch, cand) + if err != nil { + t.Fatal(err) + } + if result.ProductMatch == nil || result.ProductMatch.ProductFitScore != 100 || !result.ProductMatch.Eligible { + t.Fatalf("product fit not attached: %+v", result) + } + for _, reason := range result.ProductMatch.Reasons { + if reason.Score > 0 && !strings.Contains(cand.Text, reason.CandidateExcerpt) { + t.Fatalf("invented excerpt: %+v", reason) + } + } +} + +func TestJudgeProductCandidateInvalidAIExcerptFallsBack(t *testing.T) { + bad := strings.Replace(validProductFitReply, `"candidate_excerpt":"泛紅不適"`, `"candidate_excerpt":"模型腦補的症狀"`, 1) + svc, watch, cand := productJudgeFixture(bad) + result, _, err := svc.JudgeCandidate(context.Background(), 42, nil, watch, cand) + if err != nil { + t.Fatal(err) + } + if result.ProductMatch == nil || result.ProductMatch.ProductFitScore != 100 { + t.Fatalf("deterministic fallback did not score source evidence: %+v", result.ProductMatch) + } + for _, reason := range result.ProductMatch.Reasons { + if reason.Score > 0 && !strings.Contains(cand.Text, reason.CandidateExcerpt) { + t.Fatalf("fallback invented excerpt: %+v", reason) + } + } +} + +func TestJudgeProductCandidateProviderFailureStillReturnsFourReasons(t *testing.T) { + svc, watch, cand := productJudgeFixture("") + svc.AI.(*stubAI).err = errors.New("provider down") + result, _, err := svc.JudgeCandidate(context.Background(), 42, nil, watch, cand) + if err != nil { + t.Fatal(err) + } + if result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 { + t.Fatalf("provider failure returned empty product reasons: %+v", result) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_fit_integration_test.go b/apps/backend/internal/module/radar/usecase/product_fit_integration_test.go new file mode 100644 index 0000000..5ac5a5f --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_fit_integration_test.go @@ -0,0 +1,51 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func TestProductFitStaleCandidateStillPersistsCompleteMatch(t *testing.T) { + svc, watch, cand := productJudgeFixture("") + cand.PostedAt = time.Now().Add(-20 * 24 * time.Hour).UnixNano() + result, _, err := svc.JudgeCandidate(context.Background(), 42, nil, watch, cand) + if err != nil { + t.Fatal(err) + } + if result.Status != domain.OppRejected || result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 { + t.Fatalf("stale candidate lost product evidence: %+v", result) + } + if result.ProductMatch.ProductFitScore == 0 { + t.Fatalf("stale candidate should retain fit evidence: %+v", result.ProductMatch) + } +} + +func TestProductMatchSnapshotRemainsStaleAfterProductUpdate(t *testing.T) { + ctx := context.Background() + mem := repository.NewMemory() + op, err := mem.UpsertByExternalID(ctx, integrationOpportunity(1)) + if err != nil { + t.Fatal(err) + } + old := integrationMatch("p1", 60) + old.ProductLabelSnapshot, old.ProductUpdatedAt = "舊產品", 10 + if _, err := mem.MergeProductMatch(ctx, 1, op.ID, old); err != nil { + t.Fatal(err) + } + updated := integrationMatch("p1", 100) + updated.ProductLabelSnapshot, updated.ProductUpdatedAt = "新版產品", 20 + if _, err := mem.MergeProductMatch(ctx, 1, op.ID, updated); err != nil { + t.Fatal(err) + } + got, err := mem.GetOpportunity(ctx, op.ID) + if err != nil { + t.Fatal(err) + } + if got.ProductMatches[0].ProductLabelSnapshot != "舊產品" || got.ProductMatches[0].ProductUpdatedAt != 10 || got.ProductMatches[0].ProductFitScore != 60 { + t.Fatalf("old product match was rewritten: %+v", got.ProductMatches[0]) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_fit_test.go b/apps/backend/internal/module/radar/usecase/product_fit_test.go new file mode 100644 index 0000000..83b4d38 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_fit_test.go @@ -0,0 +1,83 @@ +package usecase + +import ( + "strings" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func productFitSnapshot() *ProductContextSnapshot { + return &ProductContextSnapshot{ + BrandID: "b1", ProductID: "p1", BrandName: "清透品牌", ProductLabel: "溫和保養", + TargetAudience: "敏感肌", PainPoints: []string{"泛紅不適"}, + MatchTags: []string{"換季保養"}, ProductContext: "換季保養", + ProviderCapabilityTerms: []string{"舒緩修護"}, ProviderExcludeTerms: []string{"徵才"}, + } +} + +func TestScoreProductFitDimensionBoundaries(t *testing.T) { + tests := []struct { + name string + text string + want int + band string + eligible bool + wantDimension string + }{ + {"pain only", "我最近泛紅不適,想問怎麼辦", 35, domain.ProductFitBandWeak, false, domain.ProductFitPain}, + {"pain scenario", "換季保養時泛紅不適,想找舒緩修護", 80, domain.ProductFitBandStrong, true, domain.ProductFitPain}, + {"all dimensions", "敏感肌換季保養泛紅不適,想找舒緩修護", 100, domain.ProductFitBandStrong, true, domain.ProductFitAudience}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, err := ScoreProductFit(&domain.CandidatePost{Text: tt.text}, productFitSnapshot()) + if err != nil { + t.Fatal(err) + } + if match.ProductFitScore != tt.want || match.ProductFitBand != tt.band || match.Eligible != tt.eligible { + t.Fatalf("score=%d band=%s eligible=%v, want %d/%s/%v; %+v", match.ProductFitScore, match.ProductFitBand, match.Eligible, tt.want, tt.band, tt.eligible, match) + } + for _, reason := range match.Reasons { + if reason.Dimension == tt.wantDimension && reason.Score > 0 { + if !strings.Contains(tt.text, reason.CandidateExcerpt) || reason.ProductBasis == "" { + t.Fatalf("positive reason lacks source evidence: %+v", reason) + } + } + } + }) + } +} + +func TestScoreProductFitNameOnlyAndUnknownAudience(t *testing.T) { + p := productFitSnapshot() + nameOnly, err := ScoreProductFit(&domain.CandidatePost{Text: "有人用過溫和保養嗎?"}, p) + if err != nil { + t.Fatal(err) + } + if nameOnly.ProductFitScore != 0 || nameOnly.Eligible || !strings.Contains(strings.Join(nameOnly.Risks, " "), "名稱") { + t.Fatalf("name-only candidate was treated as demand: %+v", nameOnly) + } + unknownAudience, err := ScoreProductFit(&domain.CandidatePost{Text: "我有泛紅不適,想找換季保養"}, p) + if err != nil { + t.Fatal(err) + } + for _, reason := range unknownAudience.Reasons { + if reason.Dimension == domain.ProductFitAudience && reason.Score != 0 { + t.Fatalf("audience guessed from unknown text: %+v", reason) + } + } +} + +func TestScoreProductFitExcludeHasReasonAndIsIneligible(t *testing.T) { + match, err := ScoreProductFit(&domain.CandidatePost{Text: "徵才:找人一起做舒緩修護", Classification: "provider_offer"}, productFitSnapshot()) + if err != nil { + t.Fatal(err) + } + if !match.Excluded || match.ExcludeReason == "" || match.Eligible { + t.Fatalf("exclude guard missing: %+v", match) + } + if err := match.ValidateForWrite(); err != nil { + t.Fatalf("excluded match should still validate: %v", err) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_lifecycle.go b/apps/backend/internal/module/radar/usecase/product_lifecycle.go new file mode 100644 index 0000000..639e8c8 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_lifecycle.go @@ -0,0 +1,58 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +// PauseProductUnavailable is idempotent and keeps the historical watch. It is +// used by sweep validation and by Scout's product-delete lifecycle bridge. +func (s *Service) PauseProductUnavailable(ctx context.Context, ownerUID int64, watchID, reason string) (*domain.RadarWatch, error) { + w, err := s.GetWatch(ctx, ownerUID, watchID) + if err != nil { + return nil, err + } + if reason == "" { + reason = domain.PauseReasonProductUnavailable + } + if reason != domain.PauseReasonProductUnavailable && reason != domain.PauseReasonBrandUnavailable { + return nil, fmt.Errorf("%w: invalid unavailable pause reason", domain.ErrValidation) + } + if w.Status == domain.WatchArchived { + return w, nil + } + w.Status = domain.WatchPaused + w.PauseReason = strings.TrimSpace(reason) + w.UpdatedAt = domain.NowNano() + if err := s.Repo.SaveWatch(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +// PauseProductWatches pauses all active watches bound to a product before the +// catalog owner deletes it. The operation is idempotent for already-paused or +// archived history. +func (s *Service) PauseProductWatches(ctx context.Context, ownerUID int64, productID string) (int, error) { + if ownerUID <= 0 || strings.TrimSpace(productID) == "" { + return 0, fmt.Errorf("%w: owner and product required", domain.ErrValidation) + } + watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{ProductID: strings.TrimSpace(productID), Page: 1, PageSize: 100}) + if err != nil { + return 0, err + } + count := 0 + for _, watch := range watches { + if watch.Status != domain.WatchActive { + continue + } + if _, err := s.PauseProductUnavailable(ctx, ownerUID, watch.ID, domain.PauseReasonProductUnavailable); err != nil { + return count, err + } + count++ + } + return count, nil +} diff --git a/apps/backend/internal/module/radar/usecase/product_lifecycle_test.go b/apps/backend/internal/module/radar/usecase/product_lifecycle_test.go new file mode 100644 index 0000000..4a0aa4f --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_lifecycle_test.go @@ -0,0 +1,40 @@ +package usecase + +import ( + "context" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestPauseProductWatchesIsIdempotentAndPreservesHistory(t *testing.T) { + ctx := context.Background() + svc, _ := productWatchService() + active, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + archived, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: false, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + if err := svc.ArchiveWatch(ctx, 42, archived.ID); err != nil { + t.Fatal(err) + } + count, err := svc.PauseProductWatches(ctx, 42, "p1") + if err != nil || count != 1 { + t.Fatalf("pause count=%d err=%v", count, err) + } + count, err = svc.PauseProductWatches(ctx, 42, "p1") + if err != nil || count != 0 { + t.Fatalf("retry count=%d err=%v", count, err) + } + got, _ := svc.GetWatch(ctx, 42, active.ID) + if got.Status != domain.WatchPaused || got.PauseReason != domain.PauseReasonProductUnavailable { + t.Fatalf("active history not paused: %+v", got) + } + got, _ = svc.GetWatch(ctx, 42, archived.ID) + if got.Status != domain.WatchArchived { + t.Fatalf("archived history changed: %+v", got) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_match_merge.go b/apps/backend/internal/module/radar/usecase/product_match_merge.go new file mode 100644 index 0000000..cc5c9df --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_match_merge.go @@ -0,0 +1,71 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +// mergeProductMatchIntoOpportunity is the in-memory decision used before a +// repository write. Same-product hits keep the first judgement/snapshot and +// only accumulate source watch IDs and matched terms. +func mergeProductMatchIntoOpportunity(o *domain.Opportunity, incoming *domain.ProductMatch) error { + if o == nil || incoming == nil { + return fmt.Errorf("%w: opportunity and product match required", domain.ErrValidation) + } + if err := incoming.ValidateForWrite(); err != nil { + return err + } + for _, current := range o.ProductMatches { + if current != nil && current.ProductID == incoming.ProductID { + current.WatchIDs = domain.MergeMatchedTerms(current.WatchIDs, incoming.WatchIDs) + current.MatchedTerms = domain.MergeMatchedTerms(current.MatchedTerms, incoming.MatchedTerms) + domain.ApplyPrimaryProduct(o) + return nil + } + } + o.ProductMatches = append(o.ProductMatches, domain.CloneProductMatch(incoming)) + domain.ApplyPrimaryProduct(o) + return nil +} + +func productMatchFor(o *domain.Opportunity, productID string) *domain.ProductMatch { + if o == nil { + return nil + } + for _, match := range o.ProductMatches { + if match != nil && match.ProductID == productID { + return match + } + } + return nil +} + +// mergeExistingProductCandidate adds a new product judgement to an existing +// Opportunity without rerunning the five-question judge. Existing same-product +// matches intentionally consume no AI call. +func (s *Service) mergeExistingProductCandidate(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, existing *domain.Opportunity) (credits int, err error) { + if watch == nil || watch.ContextMode != domain.WatchContextProduct || existing == nil { + return 0, nil + } + if productMatchFor(existing, watch.ProductID) != nil { + return 0, nil + } + product, err := s.LoadProductContext(ctx, ownerUID, watch.BrandID, watch.ProductID) + if err != nil { + return 0, err + } + match, credits, err := s.scoreProductFit(ctx, ownerUID, product, cand) + if err != nil { + return credits, err + } + match.WatchIDs = []string{watch.ID} + if strings.TrimSpace(cand.MatchedTerm) != "" { + match.MatchedTerms = []string{cand.MatchedTerm} + } + match.MatchedAt = domain.NowNano() + _, err = s.Repo.MergeProductMatch(ctx, ownerUID, existing.ID, match) + return credits, err +} diff --git a/apps/backend/internal/module/radar/usecase/product_match_merge_test.go b/apps/backend/internal/module/radar/usecase/product_match_merge_test.go new file mode 100644 index 0000000..ff51bfa --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_match_merge_test.go @@ -0,0 +1,78 @@ +package usecase + +import ( + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestMergeProductMatchArbitratesAndRecomputesOnlyFit(t *testing.T) { + o := integrationOpportunity(1) + first := integrationMatch("p1", 60) + first.BrandNameSnapshot, first.ProductLabelSnapshot = "品牌", "產品一" + if err := mergeProductMatchIntoOpportunity(o, first); err != nil { + t.Fatal(err) + } + if o.PrimaryProductID != "p1" || o.PrimaryProductFitScore != 60 || o.IntentScore != 76 { + t.Fatalf("first primary/fit: %+v", o) + } + // The other four reasons remain exactly as supplied. + if o.Reasons[0].Score != 20 || o.Reasons[1].Score != 20 || o.Reasons[2].Score != 10 || o.Reasons[3].Score != 20 { + t.Fatalf("non-fit reasons changed: %+v", o.Reasons) + } + + higher := integrationMatch("p2", 80) + higher.MatchedAt = first.MatchedAt + 1 + if err := mergeProductMatchIntoOpportunity(o, higher); err != nil { + t.Fatal(err) + } + if o.PrimaryProductID != "p2" || o.PrimaryProductFitScore != 80 || o.IntentScore != 78 { + t.Fatalf("higher match did not replace primary: %+v", o) + } +} + +func TestMergeProductMatchTieAndManualOverride(t *testing.T) { + o := integrationOpportunity(1) + a, b := integrationMatch("a", 80), integrationMatch("b", 80) + a.MatchedAt, b.MatchedAt = 20, 10 + if err := mergeProductMatchIntoOpportunity(o, a); err != nil { + t.Fatal(err) + } + if err := mergeProductMatchIntoOpportunity(o, b); err != nil { + t.Fatal(err) + } + if o.PrimaryProductID != "b" { + t.Fatalf("earlier tie should win, got %q", o.PrimaryProductID) + } + o.PrimaryProductOverridden = true + o.PrimaryProductID = "a" + domain.ApplyPrimaryProduct(o) + c := integrationMatch("c", 100) + if err := mergeProductMatchIntoOpportunity(o, c); err != nil { + t.Fatal(err) + } + if o.PrimaryProductID != "a" { + t.Fatalf("manual override was replaced: %q", o.PrimaryProductID) + } +} + +func TestMergeProductMatchSameProductOnlyMergesSources(t *testing.T) { + o := integrationOpportunity(1) + first := integrationMatch("p1", 60) + first.WatchIDs, first.MatchedTerms = []string{"w1"}, []string{"痛點"} + if err := mergeProductMatchIntoOpportunity(o, first); err != nil { + t.Fatal(err) + } + second := integrationMatch("p1", 100) + second.BrandNameSnapshot, second.ProductLabelSnapshot = "新版品牌", "新版產品" + second.WatchIDs, second.MatchedTerms = []string{"w2"}, []string{"情境"} + if err := mergeProductMatchIntoOpportunity(o, second); err != nil { + t.Fatal(err) + } + if len(o.ProductMatches) != 1 || o.ProductMatches[0].ProductFitScore != 60 || o.ProductMatches[0].ProductLabelSnapshot != "" { + t.Fatalf("same product was rejudged/replaced: %+v", o.ProductMatches) + } + if len(o.ProductMatches[0].WatchIDs) != 2 || len(o.ProductMatches[0].MatchedTerms) != 2 { + t.Fatalf("source merge missing: %+v", o.ProductMatches[0]) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_pipeline_test.go b/apps/backend/internal/module/radar/usecase/product_pipeline_test.go new file mode 100644 index 0000000..1811956 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_pipeline_test.go @@ -0,0 +1,117 @@ +package usecase + +import ( + "context" + "errors" + "testing" + "time" + + "apps/backend/internal/module/radar/domain" +) + +func TestProductSweepWorksWithoutServiceProfileAndPersistsMatch(t *testing.T) { + svc, source := productWatchService() + svc.HitFetch = HitFetcherFunc(func(context.Context, int64, []string, int) ([]ThreadHit, string, error) { + return []ThreadHit{{URL: "https://threads.net/post/product-1", Snippet: "敏感肌最近泛紅不適,想找舒緩修護", PublishedAt: time.Now().UnixNano()}}, "crawler", nil + }) + w, err := svc.CreateWatch(context.Background(), 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + result, err := svc.RunSweep(context.Background(), 42, w.ID, "product-job-1") + if err != nil { + t.Fatal(err) + } + if result.Created != 1 { + t.Fatalf("created=%d, want one", result.Created) + } + opps, _, err := svc.Repo.ListOpportunities(context.Background(), 42, domain.OpportunityListFilter{}) + if err != nil || len(opps) != 1 || len(opps[0].ProductMatches) != 1 { + t.Fatalf("product pipeline output: %+v err=%v", opps, err) + } + _ = source +} + +func TestProductSweepInvalidContextPausesWatchAndDoesNotSucceed(t *testing.T) { + svc, source := productWatchService() + w, err := svc.CreateWatch(context.Background(), 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + source.product = nil + if _, err := svc.RunSweep(context.Background(), 42, w.ID, "product-job-invalid"); err == nil || !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("invalid product sweep err=%v", err) + } + got, err := svc.GetWatch(context.Background(), 42, w.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != domain.WatchPaused || got.PauseReason != domain.PauseReasonProductUnavailable { + t.Fatalf("invalid context did not pause watch: %+v", got) + } +} + +func TestProductExploreUsesSameJudgementPipeline(t *testing.T) { + svc, _ := productWatchService() + svc.HitFetch = HitFetcherFunc(func(context.Context, int64, []string, int) ([]ThreadHit, string, error) { + return []ThreadHit{{URL: "https://threads.net/post/product-explore", Snippet: "敏感肌有人推薦舒緩修護嗎", PublishedAt: time.Now().UnixNano()}}, "crawler", nil + }) + res, err := svc.ExploreProductOpportunities(context.Background(), 42, []string{"敏感肌"}, "b1", "p1") + if err != nil { + t.Fatal(err) + } + if res.CreatedCount != 1 { + t.Fatalf("explore created=%d, want one", res.CreatedCount) + } +} + +func TestProductImportCarriesContextAndPreservesPartialRows(t *testing.T) { + svc, _ := productWatchService() + results, err := svc.ImportManualProductOpportunities(context.Background(), 42, []ManualImportItem{ + {URL: "https://threads.net/post/import-product", Text: "敏感肌想找舒緩修護"}, + {URL: "not-a-url", Text: "這列故意失敗"}, + }, "b1", "p1") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 || results[0].Status == ImportStatusFailed || results[1].Status != ImportStatusFailed { + t.Fatalf("partial import statuses: %+v", results) + } + opps, _, err := svc.Repo.ListOpportunities(context.Background(), 42, domain.OpportunityListFilter{}) + if err != nil || len(opps) != 1 || len(opps[0].ProductMatches) != 1 { + t.Fatalf("import product match: %+v err=%v", opps, err) + } +} + +func TestProductSweepCountersSeparateEvaluatedMergedAndFitRejected(t *testing.T) { + svc, source := productWatchService() + svc.HitFetch = HitFetcherFunc(func(context.Context, int64, []string, int) ([]ThreadHit, string, error) { + return []ThreadHit{{URL: "https://threads.net/post/counter", Snippet: "敏感肌泛紅不適", PublishedAt: time.Now().UnixNano()}}, "crawler", nil + }) + ctx := context.Background() + w1, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + first, err := svc.RunSweep(ctx, 42, w1.ID, "counter-1") + if err != nil { + t.Fatal(err) + } + if first.Sweep.MatchEvaluatedCount != 1 || first.Sweep.FitRejectedCount != 1 || first.Sweep.MatchMergedCount != 0 { + t.Fatalf("first counters: %+v", first.Sweep) + } + // The same public post is now evaluated for a second product and merged + // into the existing Opportunity rather than consuming a new-opportunity slot. + source.product.ID, source.product.Label = "p2", "第二產品" + w2, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p2"}) + if err != nil { + t.Fatal(err) + } + second, err := svc.RunSweep(ctx, 42, w2.ID, "counter-2") + if err != nil { + t.Fatal(err) + } + if second.Created != 0 || second.Sweep.MatchEvaluatedCount != 1 || second.Sweep.MatchMergedCount != 1 { + t.Fatalf("second counters: created=%d sweep=%+v", second.Created, second.Sweep) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_suggest_test.go b/apps/backend/internal/module/radar/usecase/product_suggest_test.go new file mode 100644 index 0000000..66c5686 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_suggest_test.go @@ -0,0 +1,93 @@ +package usecase + +import ( + "context" + "errors" + "strings" + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestSuggestProductWatchTermsReturnsTraceableAIItems(t *testing.T) { + svc := New(nil) + svc.ProductSource = productSourceFixture{ + brand: &ProductBrand{ID: "b1", OwnerUID: 42, DisplayName: "清透品牌", TargetAudience: "大人"}, + product: &ProductCatalogProduct{ID: "p1", BrandID: "b1", OwnerUID: 42, Label: "溫和保養", ProductContext: "換季容易不舒服", PainPoints: []string{"敏感肌"}, MatchTags: []string{"求推薦"}}, + } + svc.AI = &stubAI{reply: `[{"term":"敏感肌","reason":"可找到正在處理敏感肌困擾的人","usage":"include","basis_kind":"pain","basis_text":"敏感肌"}]`} + + list, err := svc.SuggestProductWatchTerms(context.Background(), 42, "b1", "p1", 8) + if err != nil { + t.Fatalf("suggest: %v", err) + } + if len(list) != 1 { + t.Fatalf("got %d suggestions, want one", len(list)) + } + item := list[0] + if item.BasisKind != domain.SuggestBasisPain || item.BasisText != "敏感肌" { + t.Fatalf("basis not preserved: %+v", item) + } + if !domain.IsThreadsSearchable(item.Term) || strings.TrimSpace(item.Reason) == "" { + t.Fatalf("invalid suggestion: %+v", item) + } +} + +func TestSuggestProductWatchTermsFallsBackToCatalogWithBasis(t *testing.T) { + svc := New(nil) + svc.ProductSource = productSourceFixture{ + brand: &ProductBrand{ID: "b1", OwnerUID: 42, DisplayName: "清透品牌", TargetAudience: "大人"}, + product: &ProductCatalogProduct{ + ID: "p1", BrandID: "b1", OwnerUID: 42, Label: "溫和保養", + ProductContext: "保養", PainPoints: []string{"敏感肌"}, MatchTags: []string{"求推薦"}, + ProviderCapabilityTerms: []string{"溫和保養"}, ProviderExcludeTerms: []string{"徵才"}, + }, + } + // No AI client is configured: this must still return a non-empty, auditable fallback. + list, err := svc.SuggestProductWatchTerms(context.Background(), 42, "b1", "p1", 20) + if err != nil { + t.Fatalf("fallback suggest: %v", err) + } + if len(list) == 0 { + t.Fatal("fallback returned no suggestions") + } + hasExclude := false + for _, item := range list { + if strings.TrimSpace(item.BasisKind) == "" || strings.TrimSpace(item.BasisText) == "" || strings.TrimSpace(item.Reason) == "" { + t.Fatalf("untraceable suggestion: %+v", item) + } + if item.Usage == domain.SuggestUsageInclude { + if !domain.IsThreadsSearchable(item.Term) { + t.Fatalf("include violates short-term rule: %+v", item) + } + if item.BasisKind == domain.SuggestBasisExclude { + t.Fatalf("include has exclude basis: %+v", item) + } + } else if item.Usage == domain.SuggestUsageExclude { + hasExclude = true + if item.BasisKind != domain.SuggestBasisExclude { + t.Fatalf("exclude basis mismatch: %+v", item) + } + } + } + if !hasExclude { + t.Fatal("fallback did not retain provider exclude term") + } +} + +func TestSuggestProductWatchTermsRequiresPairedOwnedContext(t *testing.T) { + svc := New(nil) + svc.ProductSource = productSourceFixture{ + brand: &ProductBrand{ID: "b1", OwnerUID: 42}, + product: &ProductCatalogProduct{ID: "p1", BrandID: "b1", OwnerUID: 42, PainPoints: []string{"敏感肌"}}, + } + if _, err := svc.SuggestProductWatchTerms(context.Background(), 42, "b1", "", 8); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("unpaired IDs err=%v, want validation", err) + } + if _, err := svc.SuggestProductWatchTerms(context.Background(), 42, "b1", "other", 8); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing product err=%v, want not found", err) + } + if _, err := svc.SuggestProductWatchTerms(context.Background(), 42, "b1", "p1", 8); err != nil { + t.Fatalf("valid paired IDs: %v", err) + } +} diff --git a/apps/backend/internal/module/radar/usecase/product_watch_test.go b/apps/backend/internal/module/radar/usecase/product_watch_test.go new file mode 100644 index 0000000..224490f --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/product_watch_test.go @@ -0,0 +1,83 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func productWatchService() (*Service, *productSourceFixture) { + source := &productSourceFixture{brand: &ProductBrand{ID: "b1", OwnerUID: 42, DisplayName: "品牌", UpdatedAt: 1}, product: &ProductCatalogProduct{ID: "p1", BrandID: "b1", OwnerUID: 42, Label: "產品", PainPoints: []string{"敏感肌"}, UpdatedAt: 2}} + svc := New(repository.NewMemory()) + svc.ProductSource = source + svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 20} + return svc, source +} + +func TestCreateProductWatchDoesNotRequireServiceProfile(t *testing.T) { + svc, _ := productWatchService() + w, err := svc.CreateWatch(context.Background(), 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + if w.ContextMode != domain.WatchContextProduct || w.BrandID != "b1" || w.ProductID != "p1" || w.Status != domain.WatchActive { + t.Fatalf("product watch context: %+v", w) + } +} + +func TestGenericWatchKeepsProfileGateAndAssignIsOneWay(t *testing.T) { + svc, _ := productWatchService() + ctx := context.Background() + w, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: false}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("generic watch without profile err=%v", err) + } + assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1") + if err != nil { + t.Fatal(err) + } + if assigned.ContextMode != domain.WatchContextProduct { + t.Fatalf("assign did not bind product: %+v", assigned) + } + if _, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1"); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("second assign err=%v, want validation", err) + } + if _, err := svc.ResumeWatch(ctx, 42, w.ID); err != nil { + t.Fatalf("product watch resume should not require profile: %v", err) + } +} + +func TestUnavailableProductWatchCanResumeAfterContextIsRestored(t *testing.T) { + svc, _ := productWatchService() + ctx := context.Background() + w, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"敏感肌"}, Enabled: true, BrandID: "b1", ProductID: "p1"}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.PauseProductUnavailable(ctx, 42, w.ID, domain.PauseReasonProductUnavailable); err != nil { + t.Fatal(err) + } + resumed, err := svc.ResumeWatch(ctx, 42, w.ID) + if err != nil { + t.Fatalf("resume restored product watch: %v", err) + } + if resumed.Status != domain.WatchActive || resumed.PauseReason != "" { + t.Fatalf("stale unavailable guard was not cleared: %+v", resumed) + } +} + +func TestProductWatchRequiresPairedOwnedIDs(t *testing.T) { + svc, _ := productWatchService() + if _, err := svc.CreateWatch(context.Background(), 42, WatchInput{Terms: []string{"敏感肌"}, BrandID: "b1", Enabled: false}); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("unpaired create err=%v", err) + } + if _, err := svc.CreateWatch(context.Background(), 42, WatchInput{Terms: []string{"敏感肌"}, BrandID: "b1", ProductID: "missing"}); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("missing product err=%v", err) + } +} diff --git a/apps/backend/internal/module/radar/usecase/query_plan.go b/apps/backend/internal/module/radar/usecase/query_plan.go new file mode 100644 index 0000000..20868a2 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/query_plan.go @@ -0,0 +1,115 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/radar/domain" +) + +func (s *Service) BuildProductQueryPlan(ctx context.Context, ownerUID int64, productID string) (*domain.QueryPlan, error) { + m, err := s.GetDemandMap(ctx, ownerUID, productID) + if err != nil { + return nil, err + } + label := "" + if s.ProductSource != nil { + if product, perr := s.ProductSource.GetProduct(ctx, ownerUID, productID); perr != nil { + return nil, perr + } else if product != nil { + label = product.Label + } + } + return BuildQueryPlan(m, label) +} + +// BuildQueryPlan compiles only demand language into short, independent +// searches. Product/brand names are context and can never become a query by +// themselves. The result is deterministic, which makes a plan safe to cache +// and easy to audit alongside its DemandMap versions. +func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan, error) { + if m == nil { + return nil, fmt.Errorf("%w: demand map required", domain.ErrValidation) + } + if m.State != "ready" { + return nil, fmt.Errorf("%w: demand map is %s", domain.ErrValidation, m.State) + } + productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel)) + include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase { + out := make([]domain.DemandMapPhrase, 0, len(list)) + for _, p := range list { + if !p.Enabled { + continue + } + text := domain.NormalizeSearchTerm(p.Text) + if text == "" || strings.ToLower(text) == productLabel || !domain.IsThreadsSearchable(text) { + continue + } + p.Text = text + out = append(out, p) + } + return out + } + pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases) + outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals) + exclusions := include(m.ExclusionSignals) + + groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms) + seen := map[string]bool{} + add := func(parts ...domain.DemandMapPhrase) { + terms := make([]string, 0, len(parts)) + basisKinds := make([]string, 0, len(parts)) + basisTexts := make([]string, 0, len(parts)) + for _, p := range parts { + if p.Text == "" { + continue + } + terms = append(terms, p.Text) + basisKinds = append(basisKinds, p.Kind) + basisTexts = append(basisTexts, p.BasisText) + } + query := domain.NormalizeSearchTerm(strings.Join(terms, " ")) + if !domain.IsThreadsSearchable(query) || seen[strings.ToLower(query)] { + return + } + seen[strings.ToLower(query)] = true + exclude := make([]string, 0, len(exclusions)) + for _, p := range exclusions { + exclude = append(exclude, p.Text) + } + groups = append(groups, domain.QueryPlanGroup{Query: query, Include: append([]string(nil), terms...), Exclude: exclude, BasisKinds: basisKinds, BasisTexts: basisTexts}) + } + for _, pain := range pains { + add(pain) + for _, scenario := range scenarios { + add(pain, scenario) + if len(groups) >= domain.MaxExploreTerms { + break + } + } + for _, outcome := range outcomes { + add(pain, outcome) + if len(groups) >= domain.MaxExploreTerms { + break + } + } + if len(groups) >= domain.MaxExploreTerms { + break + } + } + if len(groups) == 0 { + for _, scenario := range scenarios { + for _, solution := range solutions { + add(scenario, solution) + if len(groups) >= domain.MaxExploreTerms { + break + } + } + } + } + if len(groups) == 0 { + return nil, fmt.Errorf("%w: demand map has no searchable phrases", domain.ErrValidation) + } + return &domain.QueryPlan{DemandInputVersion: m.DemandInputVersion, MapVersion: m.MapVersion, Groups: groups}, nil +} diff --git a/apps/backend/internal/module/radar/usecase/query_plan_test.go b/apps/backend/internal/module/radar/usecase/query_plan_test.go new file mode 100644 index 0000000..4dbd9ec --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/query_plan_test.go @@ -0,0 +1,40 @@ +package usecase + +import ( + "testing" + + "apps/backend/internal/module/radar/domain" +) + +func TestBuildQueryPlanUsesDemandAndCapsGroups(t *testing.T) { + m := &domain.DemandMap{ + ProductID: "p1", DemandInputVersion: "demand-a", MapVersion: 3, State: "ready", + PainPhrases: []domain.DemandMapPhrase{{Text: "泛紅不適", Kind: "pain", BasisText: "泛紅不適", Enabled: true}, {Text: "舒緩精華", Kind: "pain", Enabled: true}}, + ScenarioPhrases: []domain.DemandMapPhrase{{Text: "換季", Kind: "scenario", Enabled: true}}, + DesiredOutcomes: []domain.DemandMapPhrase{{Text: "舒緩", Kind: "outcome", Enabled: true}}, + SolutionSignals: []domain.DemandMapPhrase{{Text: "修護", Kind: "solution", Enabled: true}}, + ExclusionSignals: []domain.DemandMapPhrase{{Text: "團購", Kind: "exclusion", Enabled: true}}, + } + plan, err := BuildQueryPlan(m, "舒緩精華") + if err != nil { + t.Fatal(err) + } + if len(plan.Groups) == 0 || len(plan.Groups) > domain.MaxExploreTerms { + t.Fatalf("unexpected group count: %d", len(plan.Groups)) + } + for _, group := range plan.Groups { + if group.Query == "舒緩精華" || !domain.IsThreadsSearchable(group.Query) { + t.Fatalf("invalid product-only/long query: %+v", group) + } + if len(group.Exclude) != 1 || group.Exclude[0] != "團購" { + t.Fatalf("exclusions not carried: %+v", group) + } + } +} + +func TestBuildQueryPlanRejectsIncomplete(t *testing.T) { + _, err := BuildQueryPlan(&domain.DemandMap{State: "incomplete"}, "") + if err == nil { + t.Fatal("incomplete map must not produce a plan") + } +} diff --git a/apps/backend/internal/module/radar/usecase/service_profile.go b/apps/backend/internal/module/radar/usecase/service_profile.go index bc3cdb1..c4a6618 100644 --- a/apps/backend/internal/module/radar/usecase/service_profile.go +++ b/apps/backend/internal/module/radar/usecase/service_profile.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "apps/backend/internal/module/ai" "apps/backend/internal/module/radar/domain" @@ -11,7 +12,8 @@ import ( ) type Service struct { - Repo domain.Repository + Repo domain.Repository + ProductSource ProductContextSource // Quota 未接時退回最低方案上限,見 watch_quota.go。 Quota PlanQuota // Usage 為 nil 時不扣點(單元測試路徑)。 @@ -36,7 +38,9 @@ type Service struct { // CRM bridge for accept → contact (optional until M4). CRM ContactBinder // Health gates auto-send of public replies (AccountHealth throttle). - Health HealthGate + Health HealthGate + judgeCacheMu sync.Mutex + judgeCache map[string]cachedJudge // ReplyQueue 是既有 Outbox 佇列(studio.QueueExternalReply);nil 時 outbox 標記 // 只記錄不真送,讓離線測試/demo 環境仍能跑(見 MarkReplyUsed)。 ReplyQueue ReplyQueue @@ -45,13 +49,18 @@ type Service struct { MediaResolver MediaResolver } +type cachedJudge struct { + result *JudgeResult + createdAt int64 +} + // ContactBinder creates or binds a CRM contact when accepting an opportunity. type ContactBinder interface { BindOpportunity(ctx context.Context, ownerUID int64, opp *domain.Opportunity) (contactID string, err error) } func New(repo domain.Repository) *Service { - return &Service{Repo: repo} + return &Service{Repo: repo, judgeCache: map[string]cachedJudge{}} } /* diff --git a/apps/backend/internal/module/radar/usecase/suggest.go b/apps/backend/internal/module/radar/usecase/suggest.go index a189b22..3c12476 100644 --- a/apps/backend/internal/module/radar/usecase/suggest.go +++ b/apps/backend/internal/module/radar/usecase/suggest.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "unicode" "apps/backend/internal/module/radar/domain" usageDomain "apps/backend/internal/module/usage/domain" @@ -13,6 +14,32 @@ import ( "github.com/zeromicro/go-zero/core/logx" ) +// productSuggestPrompt keeps the product path traceable: the model may suggest +// wording, but every item has to point back to one concrete catalog field. +func productSuggestPrompt(p *ProductContextSnapshot, limit int) string { + var b strings.Builder + b.WriteString("你是產品需求巡邏的關鍵字助理。請找正在描述需求、困擾或求助的人,不要找品牌宣傳或同業廣告。\n") + b.WriteString("只根據以下產品上下文產出短搜尋詞,不要只用品牌名或產品名:\n") + f := func(label string, values []string) { + if len(values) == 0 { + return + } + b.WriteString(label + ":" + strings.Join(values, "、") + "\n") + } + b.WriteString("品牌(僅供語境):「" + strings.TrimSpace(p.BrandName) + "」\n") + b.WriteString("產品(僅供語境):「" + strings.TrimSpace(p.ProductLabel) + "」\n") + f("目標受眾", []string{p.TargetAudience}) + f("痛點", p.PainPoints) + f("情境", []string{p.ProductContext}) + f("標籤", p.MatchTags) + f("可提供能力", p.ProviderCapabilityTerms) + f("排除詞", p.ProviderExcludeTerms) + b.WriteString(fmt.Sprintf("最多輸出 %d 則 JSON 陣列,每則格式:", limit)) + b.WriteString(`[{"term":"短詞","reason":"為什麼能找到需求者","usage":"include 或 exclude","basis_kind":"audience/pain/context/tag/capability/exclude","basis_text":"對應的原始上下文"}]`) + b.WriteString("\n規則:include 每則都必須有 basis_kind、basis_text;短詞最多兩個 token、中文每 token 2–4 字、去空格後最多 12 字;禁止標點、引號、AND/OR、emoji、#。exclude 的 basis_kind 必須是 exclude。品牌名與產品名不能是唯一理由。\n") + return b.String() +} + /* suggestPrompt 用服務檔案組建議關鍵字的提示。 @@ -152,6 +179,187 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i return out, nil } +// SuggestProductWatchTerms suggests demand terms from a paired Brand/Product +// snapshot. If the AI provider is unavailable, the catalog's own structured +// fields are used as a deterministic, traceable fallback. +func (s *Service) SuggestProductWatchTerms(ctx context.Context, ownerUID int64, brandID, productID string, limit int) (_ []domain.WatchTermSuggestion, err error) { + if ownerUID <= 0 { + return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) + } + brandID = strings.TrimSpace(brandID) + productID = strings.TrimSpace(productID) + if brandID == "" || productID == "" { + return nil, fmt.Errorf("%w: paired brand_id and product_id required", domain.ErrValidation) + } + if limit <= 0 { + limit = domain.DefaultSuggestions + } + if limit > domain.MaxSuggestions { + limit = domain.MaxSuggestions + } + ctxSnapshot, err := s.LoadProductContext(ctx, ownerUID, brandID, productID) + if err != nil { + return nil, err + } + + charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "產品需求詞建議", "radar.suggest") + if err != nil { + return nil, err + } + defer charge.Settle(ctx, &err) + + var out []domain.WatchTermSuggestion + if raw, aiErr := s.completeAI(ctx, ownerUID, productSuggestPrompt(ctxSnapshot, limit)); aiErr == nil { + out = cleanProductSuggestions(domain.CleanSuggestions(parseSuggestions(raw), limit), limit) + if len(out) == 0 { + logx.Errorf("radar product suggest: AI returned no traceable terms uid=%d; using catalog fallback", ownerUID) + } + } else { + logx.Errorf("radar product suggest: AI unavailable uid=%d: %v; using catalog fallback", ownerUID, aiErr) + } + if len(out) == 0 { + out = productSuggestionFallback(ctxSnapshot, limit) + } + if len(out) == 0 { + return nil, fmt.Errorf("%w: 產品上下文沒有可搜尋的需求詞,請先補充痛點、情境、標籤或能力", domain.ErrValidation) + } + return out, nil +} + +func cleanProductSuggestions(in []domain.WatchTermSuggestion, limit int) []domain.WatchTermSuggestion { + out := make([]domain.WatchTermSuggestion, 0, len(in)) + for _, item := range in { + item.BasisKind = strings.ToLower(strings.TrimSpace(item.BasisKind)) + item.BasisText = strings.TrimSpace(item.BasisText) + if !domain.IsSuggestionBasisKind(item.BasisKind) || item.BasisText == "" { + continue + } + if item.Usage == domain.SuggestUsageInclude && item.BasisKind == domain.SuggestBasisExclude { + continue + } + if item.Usage == domain.SuggestUsageExclude && item.BasisKind != domain.SuggestBasisExclude { + continue + } + out = append(out, item) + if len(out) >= limit { + break + } + } + return out +} + +type productSuggestionField struct { + kind, label string + terms []string + usage string +} + +func productSuggestionFallback(p *ProductContextSnapshot, limit int) []domain.WatchTermSuggestion { + fields := []productSuggestionField{ + {domain.SuggestBasisPain, "痛點", p.PainPoints, domain.SuggestUsageInclude}, + {domain.SuggestBasisTag, "標籤", p.MatchTags, domain.SuggestUsageInclude}, + {domain.SuggestBasisCapability, "能力", p.ProviderCapabilityTerms, domain.SuggestUsageInclude}, + {domain.SuggestBasisAudience, "受眾", []string{p.TargetAudience}, domain.SuggestUsageInclude}, + {domain.SuggestBasisContext, "情境", []string{p.ProductContext}, domain.SuggestUsageInclude}, + {domain.SuggestBasisExclude, "排除詞", p.ProviderExcludeTerms, domain.SuggestUsageExclude}, + } + out := make([]domain.WatchTermSuggestion, 0, limit) + seen := map[string]bool{} + for _, field := range fields { + for _, raw := range field.terms { + basis := strings.TrimSpace(raw) + if basis == "" { + continue + } + for _, term := range productSearchTermVariants(basis, field.usage == domain.SuggestUsageInclude) { + key := strings.ToLower(term + "\x00" + field.usage) + if seen[key] { + continue + } + seen[key] = true + reason := fmt.Sprintf("依產品設定的%s「%s」,可找相關需求貼文", field.label, basis) + if field.usage == domain.SuggestUsageExclude { + reason = fmt.Sprintf("產品設定將「%s」列為排除詞,避免混入非目標貼文", basis) + } + out = append(out, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: field.usage, BasisKind: field.kind, BasisText: basis}) + if len(out) >= limit { + return out + } + } + } + } + return out +} + +// productSearchTermVariants keeps fallback terms short without inventing +// product names. Exact short fields win; longer CJK fields yield small windows. +func productSearchTermVariants(raw string, include bool) []string { + raw = domain.NormalizeSearchTerm(raw) + if raw == "" { + return nil + } + parts := splitProductSearchParts(raw) + if len(parts) == 0 { + parts = []string{raw} + } + seen := map[string]bool{} + out := make([]string, 0, 8) + add := func(term string) { + term = domain.NormalizeSearchTerm(term) + if term == "" || seen[term] { + return + } + if include { + if !domain.IsThreadsSearchable(term) { + return + } + } else if n := len([]rune(term)); n < domain.MinTermLen || n > domain.MaxTermLen { + return + } + seen[term] = true + out = append(out, term) + } + if domain.IsThreadsSearchable(raw) { + add(raw) + } + for _, part := range parts { + if domain.IsThreadsSearchable(part) { + add(part) + continue + } + runes := []rune(part) + for width := 4; width >= 2; width-- { + if len(runes) < width { + continue + } + for start := 0; start+width <= len(runes) && len(out) < 8; start++ { + add(string(runes[start : start+width])) + } + } + } + return out +} + +func splitProductSearchParts(raw string) []string { + var b strings.Builder + parts := make([]string, 0, 4) + flush := func() { + if value := strings.TrimSpace(b.String()); value != "" { + parts = append(parts, value) + } + b.Reset() + } + for _, r := range raw { + if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) { + b.WriteRune(r) + } else { + flush() + } + } + flush() + return parts +} + /* parseSuggestions 容忍模型在 JSON 前後多寫字或包上 code fence。 diff --git a/apps/backend/internal/module/radar/usecase/sweep_run.go b/apps/backend/internal/module/radar/usecase/sweep_run.go index 969c7a8..c2ecce9 100644 --- a/apps/backend/internal/module/radar/usecase/sweep_run.go +++ b/apps/backend/internal/module/radar/usecase/sweep_run.go @@ -10,13 +10,13 @@ import ( // SweepRunResult is the outcome of one radar_sweep job execution. type SweepRunResult struct { - Sweep *domain.RadarSweep - Created int - Judged int - Truncated int - FailedJudges int - FetchFailed bool - FailedReason string + Sweep *domain.RadarSweep + Created int + Judged int + Truncated int + FailedJudges int + FetchFailed bool + FailedReason string } // Notifier sends in-app alerts for sweep failures. @@ -46,9 +46,24 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s return nil, fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status) } - profile, err := s.Repo.GetServiceProfile(ctx, ownerUID) - if err != nil { - return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation) + var profile *domain.ServiceProfile + var productContext *ProductContextSnapshot + if w.ContextMode == domain.WatchContextProduct { + productContext, err = s.LoadProductContext(ctx, ownerUID, w.BrandID, w.ProductID) + if err != nil { + reason := "產品上下文不可用,已暫停巡邏:" + err.Error() + _, _ = s.PauseProductUnavailable(ctx, ownerUID, watchID, domain.PauseReasonProductUnavailable) + _ = s.notifySweepFailed(ctx, ownerUID, "", watchID, reason) + return nil, err + } + // Product watches do not require a service profile; it remains optional + // for regional/freshness context when present. + profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID) + } else { + profile, err = s.Repo.GetServiceProfile(ctx, ownerUID) + if err != nil { + return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation) + } } // Resume: if a sweep already exists for this job, reuse it. @@ -70,7 +85,20 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s already[id] = true } - cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, 40) + fetchWatch := w + if w.ContextMode == domain.WatchContextProduct { + if plan, perr := s.BuildProductQueryPlan(ctx, ownerUID, w.ProductID); perr == nil && plan != nil && len(plan.Groups) > 0 { + planned := *w + planned.Terms = make([]string, 0, len(plan.Groups)) + planned.ExcludeTerms = append([]string(nil), w.ExcludeTerms...) + for _, group := range plan.Groups { + planned.Terms = append(planned.Terms, group.Query) + planned.ExcludeTerms = append(planned.ExcludeTerms, group.Exclude...) + } + fetchWatch = &planned + } + } + cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, fetchWatch, 40) if ferr != nil { reason := humanFetchError(ferr) end := domain.NowNano() @@ -84,9 +112,17 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s return &SweepRunResult{Sweep: sw, FetchFailed: true, FailedReason: reason}, ferr } + rawHitCount := len(cands) + filtered, prefilter := PrefilterCandidates(cands, w, productContext) + cands = filtered _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ - HitCount: len(cands), - CreditsUsed: fetchCredits, + HitCount: rawHitCount, + CreditsUsed: fetchCredits, + CreditSearch: fetchCredits, + DedupedCount: prefilter.Deduped, + PrefilterPassCount: prefilter.Pass, + PrefilterReviewCount: prefilter.Review, + PrefilterRejectedCount: prefilter.Rejected, }) // set path on record if path != "" && sw.Path != path { diff --git a/apps/backend/internal/module/radar/usecase/testdata/opportunity_inbox_accuracy.json b/apps/backend/internal/module/radar/usecase/testdata/opportunity_inbox_accuracy.json new file mode 100644 index 0000000..56665c0 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/testdata/opportunity_inbox_accuracy.json @@ -0,0 +1,12 @@ +[ + {"id":"case-01","text":"有人推薦敏感肌泛紅修護嗎","product_terms":["敏感肌","泛紅"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-02","text":"換季刺癢怎麼辦,想找舒緩方法","product_terms":["換季","刺癢"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-03","text":"請問台北有沒有保母推薦","product_terms":["保母"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-04","text":"有人知道長途搬家怎麼估價嗎","product_terms":["搬家","估價"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-05","text":"孩子睡不好,想找兒童睡眠顧問","product_terms":["睡不好","顧問"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-06","text":"請推薦小店記帳工具,月底好忙","product_terms":["記帳","推薦"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-07","text":"有人遇過網站速度很慢嗎,怎麼改善","product_terms":["網站","速度"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-08","text":"求推薦搬家紙箱,這週要用","product_terms":["紙箱","推薦"],"expected_demand":true,"expected_product_evidence":true,"expected_exclude":false,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-09","text":"限時優惠立即購買,私訊下單","product_terms":[],"expected_demand":false,"expected_product_evidence":false,"expected_exclude":true,"expected_stale":false,"expected_unknown_time":false}, + {"id":"case-10","text":"本週活動公告,歡迎報名參加","product_terms":[],"expected_demand":false,"expected_product_evidence":false,"expected_exclude":true,"expected_stale":true,"expected_unknown_time":true} +] diff --git a/apps/backend/internal/module/radar/usecase/today.go b/apps/backend/internal/module/radar/usecase/today.go index c1351a5..03bae8e 100644 --- a/apps/backend/internal/module/radar/usecase/today.go +++ b/apps/backend/internal/module/radar/usecase/today.go @@ -38,6 +38,10 @@ type TodayResult struct { var todayListStatuses = []string{domain.OppQualified, domain.OppAccepted, domain.OppDismissed} func (s *Service) GetToday(ctx context.Context, ownerUID int64) (*TodayResult, error) { + return s.GetTodayFiltered(ctx, ownerUID, domain.OpportunityListFilter{}) +} + +func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productFilter domain.OpportunityListFilter) (*TodayResult, error) { if ownerUID <= 0 { return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation) } @@ -66,6 +70,9 @@ func (s *Service) GetToday(ctx context.Context, ownerUID int64) (*TodayResult, e list, _, err := s.Repo.ListOpportunities(ctx, ownerUID, domain.OpportunityListFilter{ Statuses: todayListStatuses, + BrandID: productFilter.BrandID, + ProductID: productFilter.ProductID, + FitBand: productFilter.FitBand, CreatedFrom: start, CreatedTo: end, Page: 1, @@ -74,9 +81,16 @@ func (s *Service) GetToday(ctx context.Context, ownerUID int64) (*TodayResult, e if err != nil { return nil, err } + productFiltered := productFilter.BrandID != "" || productFilter.ProductID != "" || productFilter.FitBand != "" high, mid, low := []TodayOpportunity{}, []TodayOpportunity{}, []TodayOpportunity{} for _, o := range list { + if len(o.ProductMatches) > 0 && !todayHasEligibleProduct(o, productFilter) { + continue + } + if productFiltered && len(o.ProductMatches) == 0 { + continue + } card := TodayOpportunity{Opportunity: o} if replies, rerr := s.Repo.ListReplies(ctx, ownerUID, o.ID); rerr == nil { for _, r := range replies { @@ -119,11 +133,34 @@ func (s *Service) GetToday(ctx context.Context, ownerUID int64) (*TodayResult, e LastSweptAt: lastSwept, } if total == 0 { - out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start) + if productFiltered { + out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。" + } else { + out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start) + } } return out, nil } +func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilter) bool { + for _, match := range o.ProductMatches { + if match == nil || !match.Eligible || match.Excluded { + continue + } + if f.BrandID != "" && match.BrandID != f.BrandID { + continue + } + if f.ProductID != "" && match.ProductID != f.ProductID { + continue + } + if f.FitBand != "" && match.ProductFitBand != f.FitBand { + continue + } + return true + } + return false +} + func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) { if noProfile { return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。" diff --git a/apps/backend/internal/module/radar/usecase/today_product_test.go b/apps/backend/internal/module/radar/usecase/today_product_test.go new file mode 100644 index 0000000..4b89f67 --- /dev/null +++ b/apps/backend/internal/module/radar/usecase/today_product_test.go @@ -0,0 +1,53 @@ +package usecase + +import ( + "context" + "testing" + + "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/radar/repository" +) + +func TestTodayExcludesWeakProductMatchesAndSupportsSelectedProduct(t *testing.T) { + ctx := context.Background() + svc := New(repository.NewMemory()) + weak := integrationOpportunity(42) + weak.ID, weak.ExternalID = "weak", "weak" + weak.CreatedAt, weak.UpdatedAt = domain.NowNano(), domain.NowNano() + weak.ProductMatches = []*domain.ProductMatch{{BrandID: "brand-1", ProductID: "p1", ProductFitScore: 0, ProductFitBand: domain.ProductFitBandWeak, Reasons: []domain.ProductFitReason{{Dimension: domain.ProductFitPain, Score: 0, Reason: "無痛點證據"}, {Dimension: domain.ProductFitScenario, Score: 0, Reason: "無情境證據"}, {Dimension: domain.ProductFitAudience, Score: 0, Reason: "無受眾證據"}, {Dimension: domain.ProductFitCapability, Score: 0, Reason: "無能力證據"}}}} + if _, err := svc.Repo.UpsertByExternalID(ctx, weak); err != nil { + t.Fatal(err) + } + strong := integrationOpportunity(42) + strong.ID, strong.ExternalID = "strong", "strong" + strong.CreatedAt, strong.UpdatedAt = domain.NowNano(), domain.NowNano() + strong.ProductMatches = []*domain.ProductMatch{integrationMatch("p2", 80)} + if _, err := svc.Repo.UpsertByExternalID(ctx, strong); err != nil { + t.Fatal(err) + } + all, err := svc.GetTodayFiltered(ctx, 42, domain.OpportunityListFilter{}) + if err != nil { + t.Fatal(err) + } + if all.Stats.Total != 1 || all.EmptyReason != "" { + t.Fatalf("weak product leaked into today: %+v", all) + } + selected, err := svc.GetTodayFiltered(ctx, 42, domain.OpportunityListFilter{ProductID: "p2", FitBand: domain.ProductFitBandStrong}) + if err != nil { + t.Fatal(err) + } + selectedID := "" + for _, card := range append(append(selected.High, selected.Mid...), selected.Low...) { + selectedID = card.Opportunity.ExternalID + } + if selected.Stats.Total != 1 || selectedID != "strong" { + t.Fatalf("selected product filter: %+v", selected) + } + noMatch, err := svc.GetTodayFiltered(ctx, 42, domain.OpportunityListFilter{ProductID: "missing"}) + if err != nil { + t.Fatal(err) + } + if noMatch.EmptyReason != "no_eligible_product_match" { + t.Fatalf("empty selected reason=%q", noMatch.EmptyReason) + } +} diff --git a/apps/backend/internal/module/radar/usecase/watch.go b/apps/backend/internal/module/radar/usecase/watch.go index 62f9c08..dfcedc6 100644 --- a/apps/backend/internal/module/radar/usecase/watch.go +++ b/apps/backend/internal/module/radar/usecase/watch.go @@ -3,6 +3,7 @@ package usecase import ( "context" "fmt" + "strings" "apps/backend/internal/module/radar/domain" ) @@ -11,6 +12,8 @@ type WatchInput struct { Terms []string ExcludeTerms []string Regions []string + BrandID string + ProductID string // Enabled=false 代表建立成 paused,可先備好關鍵字再開。 Enabled bool } @@ -40,12 +43,25 @@ func (s *Service) CreateWatch(ctx context.Context, ownerUID int64, in WatchInput CreatedAt: now, UpdatedAt: now, } + brandID, productID := strings.TrimSpace(in.BrandID), strings.TrimSpace(in.ProductID) + if (brandID == "") != (productID == "") { + return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation) + } + productWatch := brandID != "" + if productWatch { + product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID) + if err != nil { + return nil, err + } + w.ContextMode, w.BrandID, w.ProductID = domain.WatchContextProduct, product.BrandID, product.ProductID + w.BrandNameSnapshot, w.ProductLabelSnapshot, w.ContextBoundAt = product.BrandName, product.ProductLabel, now + } if err := w.Normalize(); err != nil { return nil, err } firstEverWatch := false if status == domain.WatchActive { - if err := s.assertCanActivate(ctx, ownerUID, ""); err != nil { + if err := s.assertCanActivateForWatch(ctx, ownerUID, "", productWatch); err != nil { return nil, err } // 只在使用者從未建過任何訂閱時才判定「首巡」,避免每次新增都額外燒一次巡檢成本。 @@ -145,19 +161,70 @@ func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (* return nil, err } if w.Status != domain.WatchActive { - if err := s.assertCanActivate(ctx, ownerUID, w.ID); err != nil { + if err := s.assertCanActivateForWatch(ctx, ownerUID, w.ID, w.ContextMode == domain.WatchContextProduct); err != nil { return nil, err } + // An unavailable-product pause is normally terminal, but a transient + // catalog/worker wiring outage must not strand a valid watch forever. + // Revalidate the current owned Brand/Product before clearing the guard; + // a genuinely deleted or mismatched product still cannot resume. + if w.PauseReason == domain.PauseReasonProductUnavailable || w.PauseReason == domain.PauseReasonBrandUnavailable { + if w.ContextMode != domain.WatchContextProduct { + return nil, fmt.Errorf("%w: unavailable watch has no product context", domain.ErrValidation) + } + if _, err := s.LoadProductContext(ctx, ownerUID, w.BrandID, w.ProductID); err != nil { + return nil, err + } + w.PauseReason = "" + } } return s.applyTransition(ctx, w, domain.WatchActive) } +// AssignWatchProduct is the one-way migration from a generic watch to an +// owned Brand/Product context. An already-bound product cannot be replaced. +func (s *Service) AssignWatchProduct(ctx context.Context, ownerUID int64, id, brandID, productID string) (*domain.RadarWatch, error) { + w, err := s.GetWatch(ctx, ownerUID, id) + if err != nil { + return nil, err + } + brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID) + if brandID == "" || productID == "" { + return nil, fmt.Errorf("%w: brand_id and product_id required", domain.ErrValidation) + } + product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID) + if err != nil { + return nil, err + } + if err := w.BindProduct(product.BrandID, product.ProductID, product.BrandName, product.ProductLabel, domain.NowNano()); err != nil { + return nil, err + } + if err := s.Repo.SaveWatch(ctx, w); err != nil { + return nil, err + } + return w, nil +} + // ArchiveWatch 是軟刪:歷史商機與統計都留著。 func (s *Service) ArchiveWatch(ctx context.Context, ownerUID int64, id string) error { _, err := s.transition(ctx, ownerUID, id, domain.WatchArchived) return err } +// DeleteArchivedWatch permanently removes only the archived watch definition. +// Sweeps, opportunities, and replies are deliberately retained as history; +// deleting a watch must never erase business records produced by it. +func (s *Service) DeleteArchivedWatch(ctx context.Context, ownerUID int64, id string) error { + w, err := s.GetWatch(ctx, ownerUID, id) + if err != nil { + return err + } + if w.Status != domain.WatchArchived { + return fmt.Errorf("%w: only archived watch can be permanently deleted", domain.ErrValidation) + } + return s.Repo.DeleteWatch(ctx, id) +} + func (s *Service) MarkWatchSwept(ctx context.Context, id string, at int64) error { if at <= 0 { at = domain.NowNano() diff --git a/apps/backend/internal/module/radar/usecase/watch_quota.go b/apps/backend/internal/module/radar/usecase/watch_quota.go index b5c2130..93e88f1 100644 --- a/apps/backend/internal/module/radar/usecase/watch_quota.go +++ b/apps/backend/internal/module/radar/usecase/watch_quota.go @@ -78,6 +78,13 @@ exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不 既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。 */ func (s *Service) assertCanActivate(ctx context.Context, ownerUID int64, exceptWatchID string) error { + return s.assertCanActivateForWatch(ctx, ownerUID, exceptWatchID, false) +} + +func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error { + if productWatch { + return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID) + } hasProfile, err := s.HasServiceProfile(ctx, ownerUID) if err != nil { return err @@ -112,3 +119,28 @@ func (s *Service) assertCanActivate(ctx context.Context, ownerUID int64, exceptW } return nil } + +func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error { + maxActive, err := s.MaxActiveWatches(ctx, ownerUID) + if err != nil { + return err + } + active, err := s.Repo.CountActiveWatches(ctx, ownerUID) + if err != nil { + return err + } + if exceptWatchID != "" { + if w, err := s.Repo.GetWatch(ctx, exceptWatchID); err == nil && w.Status == domain.WatchActive { + active-- + } else if err != nil && !errors.Is(err, domain.ErrNotFound) { + return err + } + } + if active >= int64(maxActive) { + return fmt.Errorf( + "%w: active watch limit reached (%d of %d on your plan); pause an existing watch or upgrade your plan", + domain.ErrValidation, active, maxActive, + ) + } + return nil +} diff --git a/apps/backend/internal/module/radar/usecase/watch_test.go b/apps/backend/internal/module/radar/usecase/watch_test.go index 02f02da..647407a 100644 --- a/apps/backend/internal/module/radar/usecase/watch_test.go +++ b/apps/backend/internal/module/radar/usecase/watch_test.go @@ -142,6 +142,49 @@ func TestArchivedWatchIsTerminal(t *testing.T) { } } +func TestDeleteArchivedWatchRemovesDefinitionKeepsHistoryBoundary(t *testing.T) { + svc, ctx := serviceWithProfile(t, 5) + + w, err := svc.CreateWatch(ctx, 42, watchInput()) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := svc.ArchiveWatch(ctx, 42, w.ID); err != nil { + t.Fatalf("archive: %v", err) + } + if err := svc.DeleteArchivedWatch(ctx, 42, w.ID); err != nil { + t.Fatalf("delete archived: %v", err) + } + + if _, err := svc.GetWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("deleted watch lookup err = %v, want ErrNotFound", err) + } + list, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{}) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if total != 0 || len(list) != 0 { + t.Fatalf("deleted watch still listed: list=%+v total=%d", list, total) + } + // Sweeps/opportunities are separate repository records and are intentionally + // not touched by deleting the watch definition. +} + +func TestDeleteArchivedWatchRejectsNonArchived(t *testing.T) { + svc, ctx := serviceWithProfile(t, 5) + + w, err := svc.CreateWatch(ctx, 42, watchInput()) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := svc.DeleteArchivedWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) { + t.Fatalf("delete active err = %v, want ErrValidation", err) + } + if _, err := svc.GetWatch(ctx, 42, w.ID); err != nil { + t.Fatalf("active watch disappeared after rejected delete: %v", err) + } +} + func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go index 892764e..6c19947 100644 --- a/apps/backend/internal/module/scout/domain/domain.go +++ b/apps/backend/internal/module/scout/domain/domain.go @@ -2,6 +2,7 @@ package domain import ( "errors" + "strings" "time" ) @@ -12,6 +13,7 @@ var ( ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled") ErrTopicRemoved = errors.New("ScoutTopic CRUD removed") ErrHasProducts = errors.New("brand has products; remove products first") + ErrIllegalRunStatus = errors.New("illegal scout run status transition") ) func NowNano() int64 { return time.Now().UTC().UnixNano() } @@ -32,6 +34,18 @@ const ( ModeDemand = "demand" ModeProvider = "provider" + RunQueued = "queued" + RunRunning = "running" + RunSucceeded = "succeeded" + RunFailed = "failed" + RunCancelled = "cancelled" + + ShortfallSourceExhausted = "source_exhausted" + ShortfallDuplicateExhausted = "duplicate_exhausted" + ShortfallRelevanceExhausted = "relevance_exhausted" + ShortfallSourceUnavailable = "source_unavailable" + ShortfallLimitReached = "limit_reached" + ClassificationSeekingHelp = "seeking_help" ClassificationSeekingRecommendation = "seeking_recommendation" ClassificationProviderOffer = "provider_offer" @@ -43,6 +57,26 @@ const ( ClassificationProviderRecommended = "provider_recommended" ) +const LegacyRunPrefix = "legacy:" + +func LegacyRunID(themeKey string) string { + if themeKey == "" { + themeKey = "uncategorized" + } + return LegacyRunPrefix + themeKey +} + +func LegacyThemeKey(runID string) (string, bool) { + if !strings.HasPrefix(runID, LegacyRunPrefix) { + return "", false + } + key := strings.TrimPrefix(runID, LegacyRunPrefix) + if key == "uncategorized" { + return "", true + } + return key, true +} + type Brand struct { ID string `bson:"_id" json:"id"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` @@ -100,8 +134,91 @@ type RunBrief struct { TargetCount int `json:"target_count,omitempty"` } +// Run is one immutable scan attempt. theme_key groups intent only; ID keeps +// repeated scans of the same theme independent and auditable. +type Run struct { + ID string `bson:"_id" json:"id"` + OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` + JobID string `bson:"job_id" json:"job_id"` + ThemeKey string `bson:"theme_key" json:"theme_key"` + ThemeLabel string `bson:"theme_label" json:"theme_label"` + Intent string `bson:"intent" json:"intent"` + Mode string `bson:"mode" json:"mode"` + BrandID string `bson:"brand_id,omitempty" json:"brand_id,omitempty"` + TargetCount int `bson:"target_count" json:"target_count"` + Status string `bson:"status" json:"status"` + SearchedCount int `bson:"searched_count" json:"searched_count"` + DuplicateCount int `bson:"duplicate_count" json:"duplicate_count"` + IrrelevantCount int `bson:"irrelevant_count" json:"irrelevant_count"` + EligibleCount int `bson:"eligible_count" json:"eligible_count"` + PendingCount int `bson:"pending_count" json:"pending_count"` + ShortfallCount int `bson:"shortfall_count" json:"shortfall_count"` + ShortfallReasons []string `bson:"shortfall_reasons" json:"shortfall_reasons"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + StartedAt int64 `bson:"started_at,omitempty" json:"started_at,omitempty"` + CompletedAt int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` +} + +func NormalizeTargetCount(n int) int { + if n <= 0 { + return 20 + } + if n > 40 { + return 40 + } + return n +} + +func NewRun(id, jobID string, ownerUID int64, brief RunBrief, now int64) *Run { + if now <= 0 { + now = NowNano() + } + return &Run{ + ID: id, OwnerUID: ownerUID, JobID: jobID, ThemeKey: brief.ThemeKey, + ThemeLabel: brief.ThemeLabel, Intent: brief.Intent, Mode: brief.Mode, + BrandID: brief.BrandID, TargetCount: NormalizeTargetCount(brief.TargetCount), + Status: RunQueued, ShortfallReasons: []string{}, CreatedAt: now, + } +} + +func IsRunTerminal(status string) bool { + return status == RunSucceeded || status == RunFailed || status == RunCancelled +} + +func CanTransitionRun(from, to string) bool { + switch from { + case RunQueued: + return to == RunRunning || to == RunFailed || to == RunCancelled + case RunRunning: + return to == RunSucceeded || to == RunFailed || to == RunCancelled + case RunSucceeded, RunFailed, RunCancelled: + return false + default: + return false + } +} + +func (r *Run) Transition(to string, now int64) error { + if r == nil || !CanTransitionRun(r.Status, to) { + return ErrIllegalRunStatus + } + if now <= 0 { + now = NowNano() + } + r.Status = to + if to == RunRunning && r.StartedAt == 0 { + r.StartedAt = now + } + if IsRunTerminal(to) { + r.CompletedAt = now + } + return nil +} + type Post struct { ID string `bson:"_id" json:"id"` + RunID string `bson:"run_id,omitempty" json:"run_id,omitempty"` ExternalID string `bson:"external_id,omitempty" json:"external_id,omitempty"` Permalink string `bson:"permalink,omitempty" json:"permalink,omitempty"` OwnerUID int64 `bson:"owner_uid" json:"owner_uid"` @@ -123,7 +240,8 @@ type Post struct { ThemeKey string `bson:"theme_key,omitempty" json:"theme_key,omitempty"` ThemeLabel string `bson:"theme_label,omitempty" json:"theme_label,omitempty"` ScanPath string `bson:"scan_path,omitempty" json:"scan_path,omitempty"` // api|crawler - // PostedAt = 原文發文時間(unix ns);未知時為 0,列表以 PostedAt 優先、再 CreatedAt + // PostedAt = 原文發文時間(unix ns);未知時為 0。列表以 score 優先, + // 再用 PostedAt/CreatedAt/ID 做穩定 tie-breaker。 PostedAt int64 `bson:"posted_at,omitempty" json:"posted_at,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` } diff --git a/apps/backend/internal/module/scout/domain/pagination.go b/apps/backend/internal/module/scout/domain/pagination.go new file mode 100644 index 0000000..8d7c428 --- /dev/null +++ b/apps/backend/internal/module/scout/domain/pagination.go @@ -0,0 +1,53 @@ +package domain + +const ( + DefaultPageSize = 10 + MaxPageSize = 50 +) + +type PageInfo struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` +} + +func NormalizePage(page, pageSize int) PageInfo { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = DefaultPageSize + } + if pageSize > MaxPageSize { + pageSize = MaxPageSize + } + return PageInfo{Page: page, PageSize: pageSize} +} + +func (p PageInfo) WithTotal(total int64) PageInfo { + p.Total = total + if total > 0 { + p.TotalPages = int((total + int64(p.PageSize) - 1) / int64(p.PageSize)) + } + return p +} + +type RunFilter struct { + OwnerUID int64 + BrandID string + Mode string + Page int + PageSize int +} + +type RunPage struct { + Items []*Run + Pagination PageInfo +} + +type RunPostPage struct { + Run *Run + Items []*Post + Pagination PageInfo +} diff --git a/apps/backend/internal/module/scout/domain/repository.go b/apps/backend/internal/module/scout/domain/repository.go index c820a56..cd8ad83 100644 --- a/apps/backend/internal/module/scout/domain/repository.go +++ b/apps/backend/internal/module/scout/domain/repository.go @@ -3,6 +3,16 @@ package domain import "context" type Repository interface { + CreateRun(ctx context.Context, r *Run) error + GetRun(ctx context.Context, ownerUID int64, id string) (*Run, error) + ListRuns(ctx context.Context, filter RunFilter) (RunPage, error) + ListRunPosts(ctx context.Context, ownerUID int64, runID string, page, pageSize int) (RunPostPage, error) + ReplaceRunGuarded(ctx context.Context, ownerUID int64, id string, expected []string, replacement *Run) error + DeleteRun(ctx context.Context, ownerUID int64, id string) error + PublishRunPosts(ctx context.Context, ownerUID int64, runID string, posts []*Post) error + HasSeenIdentity(ctx context.Context, ownerUID int64, identity string) (bool, error) + MarkSeenIdentity(ctx context.Context, ownerUID int64, identity, postID string, seenAt int64) error + SaveBrand(ctx context.Context, b *Brand) error GetBrand(ctx context.Context, id string) (*Brand, error) ListBrands(ctx context.Context, ownerUID int64) ([]*Brand, error) diff --git a/apps/backend/internal/module/scout/domain/run_test.go b/apps/backend/internal/module/scout/domain/run_test.go new file mode 100644 index 0000000..5fd725c --- /dev/null +++ b/apps/backend/internal/module/scout/domain/run_test.go @@ -0,0 +1,60 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestNormalizeTargetCount(t *testing.T) { + for _, tc := range []struct { + input, want int + }{ + {0, 20}, {-2, 20}, {1, 1}, {40, 40}, {41, 40}, + } { + if got := NormalizeTargetCount(tc.input); got != tc.want { + t.Fatalf("NormalizeTargetCount(%d) = %d, want %d", tc.input, got, tc.want) + } + } +} + +func TestRunTransitionGuarded(t *testing.T) { + run := NewRun("run-1", "job-1", 9, RunBrief{Intent: "topic", TargetCount: 3}, 1_700_000_000_000_000_000) + if run.Status != RunQueued || run.CreatedAt == 0 { + t.Fatalf("new run not queued with timestamp: %+v", run) + } + if err := run.Transition(RunSucceeded, 2); !errors.Is(err, ErrIllegalRunStatus) { + t.Fatalf("queued -> succeeded should be guarded, got %v", err) + } + if err := run.Transition(RunRunning, 2); err != nil { + t.Fatalf("queued -> running: %v", err) + } + if run.StartedAt != 2 { + t.Fatalf("started_at = %d, want 2", run.StartedAt) + } + if err := run.Transition(RunSucceeded, 3); err != nil { + t.Fatalf("running -> succeeded: %v", err) + } + if run.CompletedAt != 3 || !IsRunTerminal(run.Status) { + t.Fatalf("terminal timestamps/status not set: %+v", run) + } + if err := run.Transition(RunRunning, 4); !errors.Is(err, ErrIllegalRunStatus) { + t.Fatalf("terminal -> running should be guarded, got %v", err) + } +} + +func TestRunShortfallReasonsAreStableConstants(t *testing.T) { + reasons := []string{ + ShortfallSourceExhausted, + ShortfallDuplicateExhausted, + ShortfallRelevanceExhausted, + ShortfallSourceUnavailable, + ShortfallLimitReached, + } + seen := map[string]bool{} + for _, reason := range reasons { + if reason == "" || seen[reason] { + t.Fatalf("invalid duplicate reason %q", reason) + } + seen[reason] = true + } +} diff --git a/apps/backend/internal/module/scout/repository/memory.go b/apps/backend/internal/module/scout/repository/memory.go index 9347ab1..b10ad34 100644 --- a/apps/backend/internal/module/scout/repository/memory.go +++ b/apps/backend/internal/module/scout/repository/memory.go @@ -2,6 +2,7 @@ package repository import ( "context" + "sort" "sync" "apps/backend/internal/module/scout/domain" @@ -13,6 +14,8 @@ type MemoryStore struct { products map[string]*domain.Product active map[int64]string posts map[string]*domain.Post + runs map[string]*domain.Run + seen map[string]struct{} hw map[string]*domain.Homework // key owner|theme crawler map[int64]*domain.CrawlerSession } @@ -21,10 +24,116 @@ func NewMemory() *MemoryStore { return &MemoryStore{ brands: map[string]*domain.Brand{}, products: map[string]*domain.Product{}, active: map[int64]string{}, posts: map[string]*domain.Post{}, + runs: map[string]*domain.Run{}, seen: map[string]struct{}{}, hw: map[string]*domain.Homework{}, crawler: map[int64]*domain.CrawlerSession{}, } } +func cloneRun(r *domain.Run) *domain.Run { + if r == nil { + return nil + } + cp := *r + cp.ShortfallReasons = append([]string(nil), r.ShortfallReasons...) + return &cp +} + +func clonePost(p *domain.Post) *domain.Post { + if p == nil { + return nil + } + cp := *p + return &cp +} + +func compareRuns(a, b *domain.Run) bool { + if a.CreatedAt != b.CreatedAt { + return a.CreatedAt > b.CreatedAt + } + return a.ID > b.ID +} + +func comparePosts(a, b *domain.Post) bool { + if a.Score != b.Score { + return a.Score > b.Score + } + aKnown := a.PostedAt > 0 + bKnown := b.PostedAt > 0 + if aKnown != bKnown { + return aKnown + } + if aKnown && a.PostedAt != b.PostedAt { + return a.PostedAt > b.PostedAt + } + if a.CreatedAt != b.CreatedAt { + return a.CreatedAt > b.CreatedAt + } + return a.ID > b.ID +} + +func postIdentity(p *domain.Post) string { + if p.ExternalID != "" { + return "external:" + p.ExternalID + } + if p.Permalink != "" { + return "url:" + p.Permalink + } + return "id:" + p.ID +} + +func uniquePosts(items []*domain.Post) []*domain.Post { + sort.Slice(items, func(i, j int) bool { return comparePosts(items[i], items[j]) }) + seen := make(map[string]struct{}, len(items)) + out := make([]*domain.Post, 0, len(items)) + for _, p := range items { + identity := postIdentity(p) + if _, exists := seen[identity]; exists { + continue + } + seen[identity] = struct{}{} + out = append(out, p) + } + return out +} + +func legacyRunFromPosts(ownerUID int64, themeKey string, posts []*domain.Post) *domain.Run { + var latest int64 + var label string + var brandID, mode string + for _, p := range posts { + if p.CreatedAt > latest { + latest = p.CreatedAt + label = p.ThemeLabel + brandID = p.BrandID + mode = p.ScoutMode + } + } + if latest == 0 { + latest = domain.NowNano() + } + return &domain.Run{ + ID: domain.LegacyRunID(themeKey), OwnerUID: ownerUID, JobID: "legacy", + ThemeKey: themeKey, ThemeLabel: label, Intent: label, Mode: mode, + BrandID: brandID, TargetCount: len(posts), Status: domain.RunSucceeded, + SearchedCount: len(posts), EligibleCount: len(posts), PendingCount: countPending(posts), + CreatedAt: latest, CompletedAt: latest, + } +} + +func countPending(posts []*domain.Post) int { + n := 0 + for _, p := range posts { + if p.OutreachStatus == domain.OutreachNew || p.OutreachStatus == domain.OutreachDrafted { + n++ + } + } + return n +} + +func identityKey(uid int64, identity string) string { + return formatUID(uid) + "|" + identity +} + func key(uid int64, theme string) string { return formatUID(uid) + "|" + theme } @@ -201,6 +310,7 @@ func (s *MemoryStore) ListPosts(_ context.Context, ownerUID int64, brandID strin cp := *p out = append(out, &cp) } + sort.Slice(out, func(i, j int) bool { return comparePosts(out[i], out[j]) }) return out, nil } func (s *MemoryStore) DeletePost(_ context.Context, id string) error { @@ -287,4 +397,227 @@ func (s *MemoryStore) ClearCrawlerSession(_ context.Context, ownerUID int64) err return nil } +func (s *MemoryStore) CreateRun(_ context.Context, r *domain.Run) error { + if r == nil || r.ID == "" || r.OwnerUID == 0 { + return domain.ErrValidation + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.runs[r.ID]; exists { + return domain.ErrValidation + } + s.runs[r.ID] = cloneRun(r) + return nil +} + +func (s *MemoryStore) GetRun(_ context.Context, ownerUID int64, id string) (*domain.Run, error) { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.runs[id] + if ok && r.OwnerUID == ownerUID { + return cloneRun(r), nil + } + if themeKey, legacy := domain.LegacyThemeKey(id); legacy { + items := make([]*domain.Post, 0) + for _, p := range s.posts { + if p.OwnerUID == ownerUID && p.RunID == "" && p.ThemeKey == themeKey { + items = append(items, clonePost(p)) + } + } + items = uniquePosts(items) + if len(items) > 0 { + return legacyRunFromPosts(ownerUID, themeKey, items), nil + } + } + return nil, domain.ErrNotFound +} + +func (s *MemoryStore) ListRuns(_ context.Context, filter domain.RunFilter) (domain.RunPage, error) { + s.mu.Lock() + defer s.mu.Unlock() + page := domain.NormalizePage(filter.Page, filter.PageSize) + items := make([]*domain.Run, 0) + for _, r := range s.runs { + if r.OwnerUID != filter.OwnerUID || (filter.BrandID != "" && r.BrandID != filter.BrandID) || (filter.Mode != "" && r.Mode != filter.Mode) { + continue + } + items = append(items, cloneRun(r)) + } + legacyGroups := map[string][]*domain.Post{} + for _, p := range s.posts { + if p.OwnerUID != filter.OwnerUID || p.RunID != "" { + continue + } + if filter.BrandID != "" && p.BrandID != filter.BrandID { + continue + } + if filter.Mode != "" && p.ScoutMode != filter.Mode { + continue + } + legacyGroups[p.ThemeKey] = append(legacyGroups[p.ThemeKey], clonePost(p)) + } + for themeKey, posts := range legacyGroups { + items = append(items, legacyRunFromPosts(filter.OwnerUID, themeKey, uniquePosts(posts))) + } + sort.Slice(items, func(i, j int) bool { return compareRuns(items[i], items[j]) }) + total := int64(len(items)) + page = page.WithTotal(total) + start := (page.Page - 1) * page.PageSize + if start >= len(items) { + return domain.RunPage{Items: []*domain.Run{}, Pagination: page}, nil + } + end := start + page.PageSize + if end > len(items) { + end = len(items) + } + return domain.RunPage{Items: items[start:end], Pagination: page}, nil +} + +func (s *MemoryStore) ListRunPosts(_ context.Context, ownerUID int64, runID string, requestedPage, requestedSize int) (domain.RunPostPage, error) { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.runs[runID] + legacyTheme, isLegacy := domain.LegacyThemeKey(runID) + if isLegacy { + items := make([]*domain.Post, 0) + for _, p := range s.posts { + if p.OwnerUID == ownerUID && p.RunID == "" && p.ThemeKey == legacyTheme { + items = append(items, clonePost(p)) + } + } + items = uniquePosts(items) + if len(items) == 0 { + return domain.RunPostPage{}, domain.ErrNotFound + } + r = legacyRunFromPosts(ownerUID, legacyTheme, items) + ok = true + } + if !ok || r.OwnerUID != ownerUID { + return domain.RunPostPage{}, domain.ErrNotFound + } + page := domain.NormalizePage(requestedPage, requestedSize) + if r.Status != domain.RunSucceeded { + return domain.RunPostPage{Run: cloneRun(r), Items: []*domain.Post{}, Pagination: page.WithTotal(0)}, nil + } + items := make([]*domain.Post, 0) + for _, p := range s.posts { + if p.OwnerUID != ownerUID { + continue + } + if (isLegacy && p.RunID == "" && p.ThemeKey == legacyTheme) || (!isLegacy && p.RunID == runID) { + items = append(items, clonePost(p)) + } + } + items = uniquePosts(items) + page = page.WithTotal(int64(len(items))) + start := (page.Page - 1) * page.PageSize + if start >= len(items) { + return domain.RunPostPage{Run: cloneRun(r), Items: []*domain.Post{}, Pagination: page}, nil + } + end := start + page.PageSize + if end > len(items) { + end = len(items) + } + return domain.RunPostPage{Run: cloneRun(r), Items: items[start:end], Pagination: page}, nil +} + +func (s *MemoryStore) ReplaceRunGuarded(_ context.Context, ownerUID int64, id string, expected []string, replacement *domain.Run) error { + s.mu.Lock() + defer s.mu.Unlock() + current, ok := s.runs[id] + if !ok || current.OwnerUID != ownerUID { + return domain.ErrNotFound + } + if len(expected) > 0 { + allowed := false + for _, status := range expected { + if current.Status == status { + allowed = true + break + } + } + if !allowed { + return domain.ErrIllegalRunStatus + } + } + if replacement == nil || replacement.ID != id { + return domain.ErrValidation + } + if replacement.Status != current.Status && !domain.CanTransitionRun(current.Status, replacement.Status) { + return domain.ErrIllegalRunStatus + } + cp := cloneRun(replacement) + cp.OwnerUID = ownerUID + s.runs[id] = cp + return nil +} + +func (s *MemoryStore) DeleteRun(_ context.Context, ownerUID int64, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.runs[id] + if !ok { + if themeKey, legacy := domain.LegacyThemeKey(id); legacy { + found := false + for postID, p := range s.posts { + if p.OwnerUID == ownerUID && p.RunID == "" && p.ThemeKey == themeKey { + delete(s.posts, postID) + found = true + } + } + if found { + return nil + } + } + } + if !ok || r.OwnerUID != ownerUID { + return domain.ErrNotFound + } + delete(s.runs, id) + for postID, p := range s.posts { + if p.OwnerUID == ownerUID && p.RunID == id { + delete(s.posts, postID) + } + } + return nil +} + +func (s *MemoryStore) PublishRunPosts(_ context.Context, ownerUID int64, runID string, posts []*domain.Post) error { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.runs[runID] + if !ok || r.OwnerUID != ownerUID { + return domain.ErrNotFound + } + for _, p := range posts { + if p == nil || p.ID == "" || p.OwnerUID != ownerUID || p.RunID != runID { + return domain.ErrValidation + } + } + for _, p := range posts { + s.posts[p.ID] = clonePost(p) + } + return nil +} + +func (s *MemoryStore) HasSeenIdentity(_ context.Context, ownerUID int64, identity string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if identity == "" { + return false, domain.ErrValidation + } + _, ok := s.seen[identityKey(ownerUID, identity)] + return ok, nil +} + +func (s *MemoryStore) MarkSeenIdentity(_ context.Context, ownerUID int64, identity, _ string, _ int64) error { + s.mu.Lock() + defer s.mu.Unlock() + if identity == "" { + return domain.ErrValidation + } + s.seen[identityKey(ownerUID, identity)] = struct{}{} + return nil +} + var _ domain.Repository = (*MemoryStore)(nil) diff --git a/apps/backend/internal/module/scout/repository/memory_run_test.go b/apps/backend/internal/module/scout/repository/memory_run_test.go new file mode 100644 index 0000000..a30060b --- /dev/null +++ b/apps/backend/internal/module/scout/repository/memory_run_test.go @@ -0,0 +1,144 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "testing" + + "apps/backend/internal/module/scout/domain" +) + +func memoryRun(id string, owner int64, created int64, status string) *domain.Run { + r := domain.NewRun(id, "job-"+id, owner, domain.RunBrief{ + Intent: "same topic", Mode: domain.ModeTheme, ThemeKey: "theme|same", ThemeLabel: "Same", + }, created) + r.Status = status + if status == domain.RunSucceeded { + r.EligibleCount = 3 + } + return r +} + +func TestMemoryRunSortingPagingAndOwnerIsolation(t *testing.T) { + ctx := context.Background() + store := NewMemory() + if err := store.CreateRun(ctx, memoryRun("run-a", 1, 100, domain.RunSucceeded)); err != nil { + t.Fatal(err) + } + if err := store.CreateRun(ctx, memoryRun("run-b", 1, 200, domain.RunSucceeded)); err != nil { + t.Fatal(err) + } + if err := store.CreateRun(ctx, memoryRun("run-other", 2, 300, domain.RunSucceeded)); err != nil { + t.Fatal(err) + } + page, err := store.ListRuns(ctx, domain.RunFilter{OwnerUID: 1, Page: 1, PageSize: 1}) + if err != nil || len(page.Items) != 1 || page.Items[0].ID != "run-b" || page.Pagination.Total != 2 { + t.Fatalf("unexpected owner page: %+v, %v", page, err) + } + pageAgain, err := store.ListRuns(ctx, domain.RunFilter{OwnerUID: 1, Page: 1, PageSize: 1}) + if err != nil || pageAgain.Items[0].ID != page.Items[0].ID { + t.Fatalf("non deterministic page: %+v, %v", pageAgain, err) + } + if _, err := store.GetRun(ctx, 2, "run-a"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("cross-owner run leaked: %v", err) + } +} + +func TestMemoryRunPostsHideStagedAndSortByPostedTime(t *testing.T) { + ctx := context.Background() + store := NewMemory() + run := memoryRun("run-posts", 1, 100, domain.RunRunning) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + posts := []*domain.Post{ + {ID: "p-unknown", RunID: run.ID, OwnerUID: 1, PostedAt: 0, CreatedAt: 50}, + {ID: "p-old", RunID: run.ID, OwnerUID: 1, PostedAt: 100, CreatedAt: 10}, + {ID: "p-new", RunID: run.ID, OwnerUID: 1, PostedAt: 200, CreatedAt: 1}, + } + if err := store.PublishRunPosts(ctx, 1, run.ID, posts); err != nil { + t.Fatal(err) + } + hidden, err := store.ListRunPosts(ctx, 1, run.ID, 1, 2) + if err != nil || hidden.Pagination.Total != 0 || len(hidden.Items) != 0 { + t.Fatalf("staged posts leaked: %+v, %v", hidden, err) + } + run.Status = domain.RunSucceeded + if err := store.ReplaceRunGuarded(ctx, 1, run.ID, []string{domain.RunRunning}, run); err != nil { + t.Fatal(err) + } + page, err := store.ListRunPosts(ctx, 1, run.ID, 1, 2) + if err != nil || len(page.Items) != 2 || page.Items[0].ID != "p-new" || page.Items[1].ID != "p-old" || page.Pagination.Total != 3 { + t.Fatalf("unexpected post page: %+v, %v", page, err) + } + last, err := store.ListRunPosts(ctx, 1, run.ID, 2, 2) + if err != nil || len(last.Items) != 1 || last.Items[0].ID != "p-unknown" { + t.Fatalf("unknown-time ordering failed: %+v, %v", last, err) + } +} + +func TestMemoryRunGuardAndSeenIdentity(t *testing.T) { + ctx := context.Background() + store := NewMemory() + run := memoryRun("run-guard", 1, 1, domain.RunQueued) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + run.Status = domain.RunSucceeded + if err := store.ReplaceRunGuarded(ctx, 1, run.ID, []string{domain.RunQueued}, run); !errors.Is(err, domain.ErrIllegalRunStatus) { + t.Fatalf("queued -> succeeded guard failed: %v", err) + } + if err := store.MarkSeenIdentity(ctx, 1, "canonical:one", "p-1", 10); err != nil { + t.Fatal(err) + } + seen, err := store.HasSeenIdentity(ctx, 1, "canonical:one") + if err != nil || !seen { + t.Fatalf("seen identity missing: %v %v", seen, err) + } + other, err := store.HasSeenIdentity(ctx, 2, "canonical:one") + if err != nil || other { + t.Fatalf("seen identity leaked across owner: %v %v", other, err) + } +} + +func TestMemoryRunPagingLegacySyntheticAndCanonicalDedup(t *testing.T) { + ctx := context.Background() + store := NewMemory() + for i := 0; i < 27; i++ { + post := &domain.Post{ + ID: fmt.Sprintf("legacy-%02d", i), ExternalID: fmt.Sprintf("external-%02d", i), + OwnerUID: 7, ThemeKey: "theme|legacy", ThemeLabel: "Legacy topic", + OutreachStatus: domain.OutreachNew, PostedAt: int64(1_000 + i), CreatedAt: int64(2_000 + i), + } + if err := store.SavePost(ctx, post); err != nil { + t.Fatal(err) + } + } + if err := store.SavePost(ctx, &domain.Post{ + ID: "legacy-duplicate", ExternalID: "external-00", OwnerUID: 7, + ThemeKey: "theme|legacy", ThemeLabel: "Legacy topic", PostedAt: 9_999, CreatedAt: 9_999, + }); err != nil { + t.Fatal(err) + } + runs, err := store.ListRuns(ctx, domain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10}) + if err != nil || len(runs.Items) != 1 || runs.Items[0].ID != domain.LegacyRunID("theme|legacy") || runs.Pagination.Total != 1 { + t.Fatalf("legacy run not synthesized: %+v %v", runs, err) + } + seen := map[string]struct{}{} + for page := 1; page <= 3; page++ { + result, err := store.ListRunPosts(ctx, 7, runs.Items[0].ID, page, 10) + if err != nil { + t.Fatal(err) + } + for _, post := range result.Items { + if _, exists := seen[post.ExternalID]; exists { + t.Fatalf("duplicate crossed pages: %s", post.ExternalID) + } + seen[post.ExternalID] = struct{}{} + } + } + if len(seen) != 27 { + t.Fatalf("legacy unique total = %d, want 27", len(seen)) + } +} diff --git a/apps/backend/internal/module/scout/repository/mongo.go b/apps/backend/internal/module/scout/repository/mongo.go index c21af32..e247018 100644 --- a/apps/backend/internal/module/scout/repository/mongo.go +++ b/apps/backend/internal/module/scout/repository/mongo.go @@ -16,6 +16,8 @@ type MonStore struct { products *mon.Model active *mon.Model posts *mon.Model + runs *mon.Model + seen *mon.Model hw *mon.Model crawler *mon.Model } @@ -27,6 +29,8 @@ func NewMonStore(uri, database string) *MonStore { products: mon.MustNewModel(uri, database, "scout_products"), active: mon.MustNewModel(uri, database, "scout_active_brand"), posts: mon.MustNewModel(uri, database, "scout_posts"), + runs: mon.MustNewModel(uri, database, "scout_runs"), + seen: mon.MustNewModel(uri, database, "scout_seen_identities"), hw: mon.MustNewModel(uri, database, "scout_homework"), crawler: mon.MustNewModel(uri, database, "scout_crawler_session"), } @@ -136,10 +140,12 @@ func (s *MonStore) ListPosts(ctx context.Context, ownerUID int64, brandID string filter["brand_id"] = brandID } var list []*domain.Post - // 發文時間優先(新→舊),再掃入時間 + // score 是品質主排序;時間只作相同分數的 deterministic tie-breaker。 err := s.posts.Find(ctx, &list, filter, options.Find().SetSort(bson.D{ + {Key: "score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "created_at", Value: -1}, + {Key: "_id", Value: -1}, })) return list, err } @@ -223,6 +229,139 @@ func (s *MonStore) ClearCrawlerSession(ctx context.Context, ownerUID int64) erro return err } +func (s *MonStore) CreateRun(ctx context.Context, r *domain.Run) error { + if r == nil || r.ID == "" || r.OwnerUID == 0 { + return domain.ErrValidation + } + _, err := s.runs.InsertOne(ctx, r) + return err +} + +func (s *MonStore) GetRun(ctx context.Context, ownerUID int64, id string) (*domain.Run, error) { + var r domain.Run + err := s.runs.FindOne(ctx, &r, bson.M{"_id": id, "owner_uid": ownerUID}) + if err != nil { + if err == mon.ErrNotFound { + return nil, domain.ErrNotFound + } + return nil, err + } + return &r, nil +} + +func (s *MonStore) ListRuns(ctx context.Context, filter domain.RunFilter) (domain.RunPage, error) { + page := domain.NormalizePage(filter.Page, filter.PageSize) + q := bson.M{"owner_uid": filter.OwnerUID} + if filter.BrandID != "" { + q["brand_id"] = filter.BrandID + } + if filter.Mode != "" { + q["mode"] = filter.Mode + } + total, err := s.runs.CountDocuments(ctx, q) + if err != nil { + return domain.RunPage{}, err + } + page = page.WithTotal(total) + var list []*domain.Run + err = s.runs.Find(ctx, &list, q, options.Find().SetSort(bson.D{ + {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}, + }).SetSkip(int64((page.Page-1)*page.PageSize)).SetLimit(int64(page.PageSize))) + return domain.RunPage{Items: list, Pagination: page}, err +} + +func (s *MonStore) ListRunPosts(ctx context.Context, ownerUID int64, runID string, requestedPage, requestedSize int) (domain.RunPostPage, error) { + r, err := s.GetRun(ctx, ownerUID, runID) + if err != nil { + return domain.RunPostPage{}, err + } + page := domain.NormalizePage(requestedPage, requestedSize) + q := bson.M{"owner_uid": ownerUID, "run_id": runID} + total, err := s.posts.CountDocuments(ctx, q) + if err != nil { + return domain.RunPostPage{}, err + } + page = page.WithTotal(total) + var list []*domain.Post + err = s.posts.Find(ctx, &list, q, options.Find().SetSort(bson.D{ + {Key: "score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}, + }).SetSkip(int64((page.Page-1)*page.PageSize)).SetLimit(int64(page.PageSize))) + return domain.RunPostPage{Run: r, Items: list, Pagination: page}, err +} + +func (s *MonStore) ReplaceRunGuarded(ctx context.Context, ownerUID int64, id string, expected []string, replacement *domain.Run) error { + if replacement == nil || replacement.ID != id { + return domain.ErrValidation + } + filter := bson.M{"_id": id, "owner_uid": ownerUID} + if len(expected) > 0 { + filter["status"] = bson.M{"$in": expected} + } + res, err := s.runs.ReplaceOne(ctx, filter, replacement) + if err != nil { + return err + } + if res.MatchedCount == 0 { + if _, getErr := s.GetRun(ctx, ownerUID, id); getErr != nil { + return getErr + } + return domain.ErrIllegalRunStatus + } + return nil +} + +func (s *MonStore) DeleteRun(ctx context.Context, ownerUID int64, id string) error { + res, err := s.runs.DeleteOne(ctx, bson.M{"_id": id, "owner_uid": ownerUID}) + if err != nil { + return err + } + if res == 0 { + return domain.ErrNotFound + } + _, err = s.posts.DeleteMany(ctx, bson.M{"owner_uid": ownerUID, "run_id": id}) + return err +} + +func (s *MonStore) PublishRunPosts(ctx context.Context, ownerUID int64, runID string, posts []*domain.Post) error { + if _, err := s.GetRun(ctx, ownerUID, runID); err != nil { + return err + } + for _, p := range posts { + if p == nil || p.ID == "" || p.OwnerUID != ownerUID || p.RunID != runID { + return domain.ErrValidation + } + } + for _, p := range posts { + if _, err := s.posts.ReplaceOne(ctx, bson.M{"_id": p.ID, "owner_uid": ownerUID}, p, options.Replace().SetUpsert(true)); err != nil { + return err + } + } + return nil +} + +func (s *MonStore) HasSeenIdentity(ctx context.Context, ownerUID int64, identity string) (bool, error) { + if identity == "" { + return false, domain.ErrValidation + } + var doc struct { + ID string `bson:"_id"` + } + err := s.seen.FindOne(ctx, &doc, bson.M{"_id": identityKey(ownerUID, identity)}) + if err == mon.ErrNotFound { + return false, nil + } + return err == nil, err +} + +func (s *MonStore) MarkSeenIdentity(ctx context.Context, ownerUID int64, identity, postID string, seenAt int64) error { + if identity == "" { + return domain.ErrValidation + } + doc := bson.M{"_id": identityKey(ownerUID, identity), "owner_uid": ownerUID, "identity": identity, "post_id": postID, "seen_at": seenAt} + _, err := s.seen.ReplaceOne(ctx, bson.M{"_id": identityKey(ownerUID, identity)}, doc, options.Replace().SetUpsert(true)) + return err +} + func formatKey(uid int64, theme string) string { return formatUID(uid) + "|" + theme } diff --git a/apps/backend/internal/module/scout/repository/mongo_indexes.go b/apps/backend/internal/module/scout/repository/mongo_indexes.go new file mode 100644 index 0000000..28b4002 --- /dev/null +++ b/apps/backend/internal/module/scout/repository/mongo_indexes.go @@ -0,0 +1,28 @@ +package repository + +import ( + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// RunIndexModels is consumed by cmd/init. Keeping index definitions outside +// request paths makes startup idempotent and keeps owner-scoped paging fast. +func RunIndexModels() []mongo.IndexModel { + return []mongo.IndexModel{ + {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}}, Options: options.Index().SetName("scout_runs_owner_created")}, + {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "mode", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("scout_runs_owner_filter")}, + } +} + +func RunPostIndexModels() []mongo.IndexModel { + return []mongo.IndexModel{ + {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "run_id", Value: 1}, {Key: "score", Value: -1}, {Key: "posted_at", Value: -1}, {Key: "created_at", Value: -1}, {Key: "_id", Value: -1}}, Options: options.Index().SetName("scout_posts_run_score")}, + } +} + +func SeenIdentityIndexModels() []mongo.IndexModel { + return []mongo.IndexModel{ + {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "identity", Value: 1}}, Options: options.Index().SetName("scout_seen_owner_identity")}, + } +} diff --git a/apps/backend/internal/module/scout/repository/mongo_run_test.go b/apps/backend/internal/module/scout/repository/mongo_run_test.go new file mode 100644 index 0000000..8bdeeeb --- /dev/null +++ b/apps/backend/internal/module/scout/repository/mongo_run_test.go @@ -0,0 +1,33 @@ +package repository + +import ( + "context" + "os" + "testing" + "time" + + "apps/backend/internal/module/scout/domain" +) + +func TestMongoRunIntegration(t *testing.T) { + uri := os.Getenv("SCOUT_MONGO_URI") + database := os.Getenv("SCOUT_MONGO_DATABASE") + if uri == "" || database == "" { + t.Skip("SCOUT_MONGO_URI and SCOUT_MONGO_DATABASE are required for MongoRun integration") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + store := NewMonStore(uri, database) + run := memoryRun("mongo-run-test", 900001, domain.NowNano(), domain.RunSucceeded) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.DeleteRun(context.Background(), run.OwnerUID, run.ID) }) + page, err := store.ListRuns(ctx, domain.RunFilter{OwnerUID: run.OwnerUID, Page: 1, PageSize: 10}) + if err != nil || page.Pagination.Total < 1 { + t.Fatalf("run not listed: %+v %v", page, err) + } + if err := store.ReplaceRunGuarded(ctx, run.OwnerUID, run.ID, []string{domain.RunSucceeded}, run); err != nil { + t.Fatalf("guarded replacement failed: %v", err) + } +} diff --git a/apps/backend/internal/module/scout/usecase/activity_terms.go b/apps/backend/internal/module/scout/usecase/activity_terms.go index 6061f2e..c6922fe 100644 --- a/apps/backend/internal/module/scout/usecase/activity_terms.go +++ b/apps/backend/internal/module/scout/usecase/activity_terms.go @@ -15,6 +15,18 @@ var activityAnchors = []string{ "求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論", } +// 搜尋修飾詞不算主題核;它們用來要求 provider 偏向近期/熱門結果, +// 但正文只要真的提到使用者的主題即可,不要求作者自己寫出「熱門」。 +var activityDiscoveryModifiers = []string{"熱門", "最新", "近期"} + +var activityWorkMarkers = []string{ + "外包", "接案", "案件", "工作", "職缺", "徵人", "求職", "freelance", "contract", +} + +var activityRoleSpecialties = []string{ + "後端", "前端", "全端", "網站", "網頁", "app", "設計", "剪輯", "行銷", "文案", +} + // 台灣常見地名(作前綴 token,2–3 字) var activityRegions = []string{ "台北", "新北", "桃園", "台中", "台南", "高雄", "新竹", "基隆", @@ -26,9 +38,9 @@ var activityRegions = []string{ planActivityTerms 依使用者意圖**產生**可搜的短詞變體,不是只把原文拆開。 策略: - 1. 抽出主題核(2–4 字 CJK 或 2+ 英數)與地區 - 2. 核本身 + 核×口語錨點 + 地區×核 - 3. 一律過 Threads 短詞規則(與商機 suggest/explore 同一契約) + 1. 抽出主題核(2–4 字 CJK 或 2+ 英數)與地區 + 2. 核本身 + 核×口語錨點 + 地區×核 + 3. 一律過 Threads 短詞規則(與商機 suggest/explore 同一契約) */ func planActivityTerms(intent string) []string { intent = radarDomain.NormalizeSearchTerm(intent) @@ -45,16 +57,37 @@ func planActivityTerms(intent string) []string { candidates = append(candidates, intent) } + // 先放「整體意圖」查詢,再放單核變體。舊邏輯逐核塞滿 8 組,三詞輸入 + // 會只剩第一詞(例:外包),後面的「工程師/後端」完全遺失。 + candidates = append(candidates, inferActivityIntentTerms(intent, cores)...) + candidates = append(candidates, titleActivityTerms(intent)...) + + // 先 round-robin 放每個主題核,確保每個概念至少保留一次。 for _, core := range cores { if radarDomain.IsThreadsSearchable(core) { candidates = append(candidates, core) } - for _, anchor := range activityAnchors { + } + + // 近期/熱門是可選搜尋修飾;先於一般口語變體,讓作品、人物、事件詞 + // 能直接形成「鬼滅之刃 熱門」這類 discovery query。 + for _, modifier := range activityDiscoveryModifiers { + for _, core := range cores { + candidates = appendSearchable(candidates, core+" "+modifier) + } + } + + // 再按錨點 round-robin 擴充,避免第一個 core 的變體吃完整個上限。 + for _, anchor := range activityAnchors { + for _, core := range cores { pair := core + " " + anchor if radarDomain.IsThreadsSearchable(pair) { candidates = append(candidates, pair) } } + } + + for _, core := range cores { for _, region := range regions { pair := region + " " + core if radarDomain.IsThreadsSearchable(pair) { @@ -78,12 +111,119 @@ func planActivityTerms(intent string) []string { return capTerms(dedupeTerms(candidates), 8) } +// inferActivityIntentTerms 把使用者的「幾個概念」組回真正想找的 query。 +// activity 頁仍是話題探索,但工作/推薦類輸入也應找得到相關討論與公開需求。 +func inferActivityIntentTerms(intent string, cores []string) []string { + if !containsAnyFold(intent, activityWorkMarkers) { + return adjacentCorePairs(cores) + } + + var specialties []string + for _, specialty := range activityRoleSpecialties { + if strings.Contains(strings.ToLower(intent), specialty) { + specialties = append(specialties, specialty) + } + } + if len(specialties) == 0 { + for _, core := range cores { + if !containsAnyFold(core, activityWorkMarkers) && core != "工程師" { + specialties = append(specialties, core) + } + } + } + + var out []string + for _, specialty := range specialties { + out = appendSearchable(out, + specialty+" 外包", + specialty+" 接案", + specialty+" 工程師", + specialty+" 推薦", + ) + } + out = appendSearchable(out, "外包 案件", "工程師 外包") + if containsAnyFold(intent, []string{"後端"}) { + out = appendSearchable(out, "徵人 後端", "尋找 後端") + } + return dedupeTerms(out, adjacentCorePairs(cores)) +} + +func adjacentCorePairs(cores []string) []string { + var out []string + for i := 0; i+1 < len(cores); i++ { + out = appendSearchable(out, cores[i]+" "+cores[i+1]) + } + return out +} + +func titleActivityTerms(intent string) []string { + parts := splitTitleCores(intent) + if len(parts) != 2 { + return nil + } + var out []string + out = appendSearchable(out, parts[0]+" "+parts[1], parts[0]) + for _, modifier := range activityDiscoveryModifiers { + out = appendSearchable(out, parts[0]+" "+modifier) + } + return out +} + +// splitTitleCores 保留 5+ 字作品/專名的連接語意,例如: +// 「進擊的巨人」→「進擊 的巨人」。四字內專名則保留原名。 +func splitTitleCores(intent string) []string { + intent = radarDomain.NormalizeSearchTerm(intent) + if intent == "" || strings.Contains(intent, " ") { + return nil + } + runes := []rune(intent) + if len(runes) < 5 || len(runes) > 12 { + return nil + } + for i, r := range runes { + if i < 2 || i >= len(runes)-1 || (r != '之' && r != '的' && r != '與') { + continue + } + left, right := string(runes[:i]), string(runes[i:]) + if radarDomain.IsThreadsSearchable(left) && radarDomain.IsThreadsSearchable(right) && + radarDomain.IsThreadsSearchable(left+" "+right) { + return []string{left, right} + } + } + return nil +} + +func appendSearchable(out []string, terms ...string) []string { + for _, term := range terms { + if radarDomain.IsThreadsSearchable(term) { + out = append(out, radarDomain.NormalizeSearchTerm(term)) + } + } + return out +} + +func containsAnyFold(text string, terms []string) bool { + text = strings.ToLower(text) + for _, term := range terms { + if strings.Contains(text, strings.ToLower(term)) { + return true + } + } + return false +} + // extractTopicCores 從意圖抽出可當搜尋主詞的 2–4 字核。 func extractTopicCores(intent string) []string { intent = radarDomain.NormalizeSearchTerm(intent) if intent == "" { return nil } + if !strings.Contains(intent, " ") && radarDomain.IsThreadsSearchable(intent) { + return []string{intent} + } + if titleParts := splitTitleCores(intent); len(titleParts) > 0 { + return titleParts + } var cores []string // 已有空白:每段當候選(再壓成 2–4 字) @@ -248,16 +388,17 @@ func filterThreadsSearchableTerms(in []string) []string { return dedupeTerms(out) } -// mergeActivityTerms 優先 AI 產詞,規則變體補足到 max。 +// mergeActivityTerms 先保留可解釋、可重現的使用者意圖查詢,再由 AI 補變體。 +// AI 不得把主查詢換成另一個意思。 func mergeActivityTerms(aiTerms, ruleTerms []string, max int) []string { ai := filterThreadsSearchableTerms(aiTerms) rules := filterThreadsSearchableTerms(ruleTerms) - return capTerms(dedupeTerms(ai, rules), max) + return capTerms(dedupeTerms(rules, ai), max) } func activityTermsPrompt(intent string, limit int) string { var b strings.Builder - b.WriteString("你是台灣 Threads 話題搜尋助理。使用者想找「可以跟風討論/留言的活躍話題」貼文,不是找客戶。\n") + b.WriteString("你是台灣 Threads 搜尋意圖助理。使用者想找與輸入真正相關、近期且有討論動能的貼文。\n") b.WriteString("主題意圖:") b.WriteString(intent) b.WriteString("\n\n") @@ -269,9 +410,10 @@ func activityTermsPrompt(intent string, limit int) string { b.WriteString(itoaASCII(limit)) b.WriteString(" 組。\n") b.WriteString("2. 【Threads 短詞硬約束】每組最多 2 個詞(半形空格分隔);中文每詞 2–4 字;整組去掉空格後 ≤12 字;禁止標點、引號、AND/OR、emoji、#。\n") - b.WriteString("3. 用台灣口語:求推薦、分享、心得、活動、怎麼辦、有人知道、討論。\n") - b.WriteString("4. 每個意圖給多組短變體(例:「市集 分享」「週末 市集」「文青 市集」),不要只拆使用者原句。\n") - b.WriteString("5. 不要輸出價格、連結、帳號。\n") + b.WriteString("3. 先判斷意圖,不可只拆字:工作型輸入要保留職種+外包/接案/職缺語意;作品或人物名稱要保留專名,可加熱門/最新。\n") + b.WriteString("4. 用台灣口語:求推薦、分享、心得、活動、怎麼辦、有人知道、討論。\n") + b.WriteString("5. 每個意圖給多組短變體(例:「後端 外包」「後端 接案」;「鬼滅之刃」「鬼滅之刃 最新」),不要只拆使用者原句。\n") + b.WriteString("6. 不要輸出價格、連結、帳號。\n") return b.String() } diff --git a/apps/backend/internal/module/scout/usecase/activity_terms_test.go b/apps/backend/internal/module/scout/usecase/activity_terms_test.go index ccfea7d..021059f 100644 --- a/apps/backend/internal/module/scout/usecase/activity_terms_test.go +++ b/apps/backend/internal/module/scout/usecase/activity_terms_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "apps/backend/internal/module/scout/domain" radarDomain "apps/backend/internal/module/radar/domain" + "apps/backend/internal/module/scout/domain" ) func TestPrepareActivityBriefUsesGeneratedTerms(t *testing.T) { @@ -77,15 +77,51 @@ func TestPlanActivityTermsNotJustTokenizeIntent(t *testing.T) { } } -func TestMergeActivityTermsPrefersAI(t *testing.T) { +func TestPlanActivityTermsInfersBackendFreelanceIntent(t *testing.T) { + got := planActivityTerms("外包 工程師 後端") + if len(got) == 0 || got[0] != "後端 外包" { + t.Fatalf("terms=%v want 後端 外包 as primary intent query", got) + } + joined := "|" + strings.Join(got, "|") + "|" + for _, want := range []string{"|後端 外包|", "|後端 接案|", "|後端 工程師|"} { + if !strings.Contains(joined, want) { + t.Fatalf("terms=%v missing inferred query %s", got, want) + } + } + for _, term := range got { + if !radarDomain.IsThreadsSearchable(term) { + t.Fatalf("term %q not Threads-searchable", term) + } + } +} + +func TestPlanActivityTermsPreservesExactTitleIntent(t *testing.T) { + got := planActivityTerms("鬼滅之刃") + if len(got) == 0 || got[0] != "鬼滅之刃" { + t.Fatalf("terms=%v want exact title as primary query", got) + } + joined := "|" + strings.Join(got, "|") + "|" + for _, want := range []string{"|鬼滅之刃 最新|", "|鬼滅之刃 熱門|"} { + if !strings.Contains(joined, want) { + t.Fatalf("terms=%v missing discovery query %s", got, want) + } + } + for _, bad := range []string{"鬼滅之", "滅之刃"} { + if strings.Contains(joined, "|"+bad+"|") { + t.Fatalf("broken title fragment %q leaked: %v", bad, got) + } + } +} + +func TestMergeActivityTermsKeepsDeterministicIntentFirst(t *testing.T) { ai := []string{"市集 分享", "文青 市集", "太長了吧這整句不行"} rules := []string{"市集 推薦", "市集"} got := mergeActivityTerms(ai, rules, 6) if len(got) == 0 { t.Fatal("empty merge") } - if got[0] != "市集 分享" { - t.Fatalf("AI term should come first, got %v", got) + if got[0] != "市集 推薦" { + t.Fatalf("deterministic intent term should come first, got %v", got) } // 長詞應被濾掉 for _, t2 := range got { diff --git a/apps/backend/internal/module/scout/usecase/candidate.go b/apps/backend/internal/module/scout/usecase/candidate.go new file mode 100644 index 0000000..09f708b --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/candidate.go @@ -0,0 +1,234 @@ +package usecase + +import ( + "strings" + + "apps/backend/internal/module/scout/domain" +) + +type CandidateDecision string + +const ( + CandidateEligible CandidateDecision = "eligible" + CandidateDuplicate CandidateDecision = "duplicate" + CandidateIrrelevant CandidateDecision = "irrelevant" +) + +// CandidateStats describes mutually exclusive decisions made by one +// evaluator. Searched is incremented for every raw candidate presented to it. +type CandidateStats struct { + Searched int + Duplicate int + Irrelevant int + Eligible int +} + +type CandidateEvaluation struct { + Decision CandidateDecision + Identity string + Permalink string + Text string + SearchTag string + PostedAt int64 + Classification string + Score int + Reason string + TopicMatch TopicMatch +} + +// CandidateEvaluator is the single relevance gate used before persistence. +// Historical identity checks are intentionally left to T042; this evaluator +// only handles canonical duplicates in the current candidate set. +type CandidateEvaluator struct { + brief *domain.RunBrief + signature TopicSignature + seen map[string]struct{} + historicalSeen func(identity string) (bool, error) + stats CandidateStats +} + +func NewCandidateEvaluator(brief *domain.RunBrief) *CandidateEvaluator { + if brief == nil { + brief = &domain.RunBrief{} + } + signature := NewTopicSignature(brief.Intent, brief.ScanTerms) + // Provider/demand briefs intentionally replace Intent with a product label; + // their approved pain/capability terms are the real evidence. Keep their + // established mode-specific gates until those modes get their own signature + // contract, while activity/product/theme retain the original intent gate. + if brief.Mode == domain.ModeProvider || brief.Mode == domain.ModeDemand { + signature = TopicSignature{} + } + return &CandidateEvaluator{brief: brief, signature: signature, seen: make(map[string]struct{})} +} + +func (e *CandidateEvaluator) Stats() CandidateStats { + if e == nil { + return CandidateStats{} + } + return e.stats +} + +// SetHistoricalSeenLookup injects the owner-scoped repository check. The +// callback receives the canonical identity, never a raw provider URL. +func (e *CandidateEvaluator) SetHistoricalSeenLookup(check func(identity string) (bool, error)) { + if e != nil { + e.historicalSeen = check + } +} + +func (e *CandidateEvaluator) Evaluate(hit ThreadSearchResult) CandidateEvaluation { + return e.EvaluateAt(hit, domain.NowNano()) +} + +func (e *CandidateEvaluator) EvaluateAt(hit ThreadSearchResult, now int64) CandidateEvaluation { + if e == nil { + return CandidateEvaluation{Decision: CandidateIrrelevant, Reason: "evaluator unavailable"} + } + e.stats.Searched++ + text := strings.TrimSpace(hit.Snippet) + if text == "" { + // Some providers return a title without highlights/text. It is still + // usable evidence when the title carries the complete topic phrase. + text = strings.TrimSpace(hit.Title) + } + permalink := canonicalPermalink(hit.URL) + identity := canonicalPostIdentity(hit.URL) + result := CandidateEvaluation{ + Decision: CandidateIrrelevant, + Identity: identity, + Permalink: permalink, + Text: text, + PostedAt: hit.PublishedAt, + } + if text == "" || permalink == "" || identity == "" { + e.stats.Irrelevant++ + result.Reason = "missing canonical URL or post text" + return result + } + if _, exists := e.seen[identity]; exists { + e.stats.Duplicate++ + result.Decision = CandidateDuplicate + result.Reason = "duplicate canonical identity: " + identity + return result + } + if e.historicalSeen != nil { + seen, err := e.historicalSeen(identity) + if err != nil { + e.stats.Irrelevant++ + result.Reason = "historical identity lookup failed" + return result + } + if seen { + // Historical duplicates are terminal. Mark them locally so a provider + // returning the same post twice does not repeat the repository lookup. + e.seen[identity] = struct{}{} + e.stats.Duplicate++ + result.Decision = CandidateDuplicate + result.Reason = "duplicate historical identity: " + identity + return result + } + } + body := text + " " + strings.TrimSpace(hit.Title) + if len(e.signature.Concepts) > 0 { + result.TopicMatch = e.signature.Match(body) + if !result.TopicMatch.Matched { + e.stats.Irrelevant++ + result.Reason = result.TopicMatch.Reason + return result + } + } else { + // Compatibility for briefs that only carry approved scan terms. A + // signature with no concepts still must not admit an empty query or turn + // a generic anchor such as "推薦" into a topic. + if e.brief.Mode == domain.ModeActivity && len(semanticTopicParts(e.brief.Intent)) == 0 { + var hasTopicPart bool + for _, term := range e.brief.ScanTerms { + if len(semanticTopicParts(term)) > 0 { + hasTopicPart = true + break + } + } + if !hasTopicPart { + e.stats.Irrelevant++ + result.Reason = "no approved topic core matched" + return result + } + } + matched := matchingSearchTerm(body, e.brief.ScanTerms) + if matched == "" { + e.stats.Irrelevant++ + result.Reason = "no approved topic core matched" + return result + } + result.TopicMatch = TopicMatch{Matched: true, MatchedCores: []string{matched}, Reason: "matched core: " + matched} + } + + classified := classifyPost(e.brief.Mode, body, e.brief.ScanTerms) + if e.brief.Mode == domain.ModeProvider { + classified = classifyProvider(body, e.brief.Pains, e.brief.Tags, e.brief.Periphery) + } + if e.brief.Mode == domain.ModeDemand { + classified = classifyDemand(body, e.brief.Pains, e.brief.Periphery) + } + if classified.classification == domain.ClassificationNoise || + ((e.brief.Mode == domain.ModeProduct || e.brief.Mode == domain.ModeTheme) && classified.classification == domain.ClassificationProviderOffer) || + (e.brief.Mode == domain.ModeProvider && classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) { + e.stats.Irrelevant++ + result.Reason = classified.reason + return result + } + + term := strings.TrimSpace(hit.MatchedQuery) + if term == "" || !textMatchesSearchTerm(body, term) { + term = result.TopicMatch.MatchedCores[0] + } + score, reason := classified.score, classified.reason + if result.TopicMatch.Reason != "" { + reason += "; " + result.TopicMatch.Reason + } + if hit.Track == "both" { + boost := 6 + if e.brief.Mode == domain.ModeActivity { + boost = 35 + } + score = minInt(100, score+boost) + reason += "; track: both" + } else if hit.Track == "recent" { + boost := 3 + if e.brief.Mode == domain.ModeActivity { + boost = 8 + } + score = minInt(100, score+boost) + reason += "; track: recent" + } else if hit.Track == "top" { + if e.brief.Mode == domain.ModeActivity { + score = minInt(100, score+12) + } + reason += "; track: top" + } + if hit.SerpRank > 0 { + reason += "; serp_rank: " + itoaASCII(hit.SerpRank) + } + if hit.PublishedAt > 0 && isSoftAged(hit.PublishedAt, defaultScoutSoftAgeDays) { + score = maxInt(1, score-12) + reason += "; soft_aged" + } + // Only an eligible candidate occupies the local identity. Providers often + // return a sparse/short first hit followed by a richer snippet for the same + // post; rejecting the first hit must not prevent the second from being + // evaluated. This is deliberately after every relevance and classification + // gate, while historical duplicates were marked above as terminal. + e.seen[identity] = struct{}{} + if now <= 0 { + now = domain.NowNano() + } + _ = now // reserved for the run-level created_at assignment + result.Decision = CandidateEligible + result.SearchTag = term + result.Classification = classified.classification + result.Score = score + result.Reason = reason + e.stats.Eligible++ + return result +} diff --git a/apps/backend/internal/module/scout/usecase/candidate_test.go b/apps/backend/internal/module/scout/usecase/candidate_test.go new file mode 100644 index 0000000..7114f43 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/candidate_test.go @@ -0,0 +1,161 @@ +package usecase + +import ( + "testing" + "time" + + "apps/backend/internal/module/scout/domain" +) + +func TestCandidateEvaluatorHasOneDecisionAndCanonicalDuplicate(t *testing.T) { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "外包 後端工程師", ScanTerms: []string{"後端 外包"}, + }) + good := ThreadSearchResult{ + URL: "https://threads.net/@builder/post/ABC123?share=1", + Snippet: "想找後端工程師做外包合作,有推薦的接案夥伴嗎?", + } + first := evaluator.EvaluateAt(good, time.Now().UnixNano()) + if first.Decision != CandidateEligible || first.Identity != "threads-post:ABC123" { + t.Fatalf("first evaluation=%+v", first) + } + if first.SearchTag == "" || first.Score <= 0 || first.Reason == "" { + t.Fatalf("eligible metadata incomplete: %+v", first) + } + dup := evaluator.EvaluateAt(ThreadSearchResult{ + URL: "https://www.threads.com/@renamed/post/ABC123", Snippet: good.Snippet, + }, time.Now().UnixNano()) + if dup.Decision != CandidateDuplicate || dup.Reason == "" { + t.Fatalf("duplicate evaluation=%+v", dup) + } + stats := evaluator.Stats() + if stats.Searched != 2 || stats.Eligible != 1 || stats.Duplicate != 1 || stats.Irrelevant != 0 { + t.Fatalf("stats=%+v", stats) + } +} + +func TestCandidateEvaluatorSeparatesIrrelevanceReasons(t *testing.T) { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "鬼滅之刃", ScanTerms: []string{"鬼滅之刃 最新"}, + }) + for _, tc := range []struct { + name string + hit ThreadSearchResult + want string + }{ + {name: "missing topic", hit: ThreadSearchResult{URL: "https://threads.net/@a/post/missing", Snippet: "最近想看動畫,有推薦熱門作品嗎"}, want: "missing core"}, + {name: "noise", hit: ThreadSearchResult{URL: "https://threads.net/@a/post/noise", Snippet: "鬼滅之刃抽獎,互追拿好禮"}, want: "noise signal"}, + {name: "empty", hit: ThreadSearchResult{URL: "https://threads.net/@a/post/empty", Snippet: " "}, want: "missing canonical"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := evaluator.Evaluate(tc.hit) + if got.Decision != CandidateIrrelevant || got.Reason == "" || !containsReason(got.Reason, tc.want) { + t.Fatalf("evaluation=%+v want reason containing %q", got, tc.want) + } + }) + } + stats := evaluator.Stats() + if stats.Searched != 3 || stats.Irrelevant != 3 || stats.Eligible != 0 || stats.Duplicate != 0 { + t.Fatalf("stats=%+v", stats) + } +} + +func TestCandidateEvaluatorAllowsOldPublishedTime(t *testing.T) { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "鬼滅之刃", ScanTerms: []string{"鬼滅之刃"}, + }) + got := evaluator.Evaluate(ThreadSearchResult{ + URL: "https://threads.net/@a/post/old", Snippet: "鬼滅之刃舊文", + PublishedAt: time.Now().Add(-3 * 365 * 24 * time.Hour).UnixNano(), + }) + if got.Decision != CandidateEligible { + t.Fatalf("old relevant candidate=%+v", got) + } +} + +func TestCandidateEvaluatorAllowsRicherRetryAfterRejectedIdentity(t *testing.T) { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "外包 後端工程師", ScanTerms: []string{"後端 外包"}, + }) + url := "https://threads.net/@builder/post/RICHER" + first := evaluator.EvaluateAt(ThreadSearchResult{URL: url, Snippet: "外包合作正在找人"}, time.Now().UnixNano()) + if first.Decision != CandidateIrrelevant { + t.Fatalf("sparse first hit=%+v, want irrelevant", first) + } + second := evaluator.EvaluateAt(ThreadSearchResult{URL: url, Snippet: "想找後端工程師做外包合作,歡迎推薦接案夥伴"}, time.Now().UnixNano()) + if second.Decision != CandidateEligible { + t.Fatalf("richer retry=%+v, want eligible", second) + } + third := evaluator.EvaluateAt(ThreadSearchResult{URL: url, Snippet: second.Text}, time.Now().UnixNano()) + if third.Decision != CandidateDuplicate { + t.Fatalf("eligible identity should be terminal duplicate: %+v", third) + } +} + +func TestCandidateEvaluatorRejectsAnchorOnlyActivityBrief(t *testing.T) { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "推薦 分享", ScanTerms: []string{"推薦"}, + }) + got := evaluator.Evaluate(ThreadSearchResult{ + URL: "https://threads.net/@a/post/ANCHOR", Snippet: "推薦一間好吃的餐廳給大家", + }) + if got.Decision != CandidateIrrelevant || got.Reason != "no approved topic core matched" { + t.Fatalf("anchor-only activity=%+v", got) + } +} + +func TestCandidateEvaluatorUsesTitleWhenProviderOmitsSnippet(t *testing.T) { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, + }) + got := evaluator.Evaluate(ThreadSearchResult{ + URL: "https://threads.net/@a/post/TITLE_ONLY", Title: "台北市集週末推薦", Snippet: "", + }) + if got.Decision != CandidateEligible { + t.Fatalf("title-only provider hit=%+v", got) + } +} + +func TestCandidateEvaluatorAcceptsNaturalActivityIntentFromApprovedVariants(t *testing.T) { + brief := &domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "找後端工程師接案", + ScanTerms: []string{"後端 外包", "後端 接案", "後端 工程師"}, + } + evaluator := NewCandidateEvaluator(brief) + got := evaluator.Evaluate(ThreadSearchResult{ + URL: "https://threads.net/@a/post/NATURAL_WORK", + Snippet: "正在找後端工程師做外包合作,歡迎推薦接案夥伴", + MatchedQuery: "後端 外包", + }) + if got.Decision != CandidateEligible { + t.Fatalf("natural activity candidate=%+v", got) + } + // Unchecking variants must not discard the explicit role/work concepts + // carried by the original sentence. + oneTerm := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: brief.Intent, ScanTerms: []string{"後端 外包"}, + }) + if got := oneTerm.Evaluate(ThreadSearchResult{ + URL: "https://threads.net/@a/post/NATURAL_WORK_ONE", Snippet: "正在找後端工程師做外包合作", + }); got.Decision != CandidateEligible { + t.Fatalf("single approved term lost natural concepts: %+v", got) + } +} + +func containsReason(reason, want string) bool { + for _, part := range []string{reason} { + if len(part) >= len(want) && hasSubstring(part, want) { + return true + } + } + return false +} + +func hasSubstring(text, want string) bool { + for i := 0; i+len(want) <= len(text); i++ { + if text[i:i+len(want)] == want { + return true + } + } + return false +} diff --git a/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go b/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go index ddc0f64..c07c0c5 100644 --- a/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go +++ b/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go @@ -127,10 +127,6 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str } track := normalizeSearchTrack(post.Track) pub := parsePublishedDateNano(post.PublishedAt) - // 硬擋僅極舊(預設 180 天);45 天軟降權在 persist 做,避免誤殺 - if pub > 0 && isStalePublished(pub, defaultScoutHardMaxAgeDays) { - continue - } results = append(results, ThreadSearchResult{ URL: post.Permalink, Title: post.Author, @@ -156,23 +152,12 @@ func normalizeSearchTrack(s string) string { } } -// defaultScoutHardMaxAgeDays:僅極舊文硬擋(對齊 crawler SCOUT_MAX_AGE_DAYS 預設 180)。 -const defaultScoutHardMaxAgeDays = 180 - -// defaultScoutSoftAgeDays:超過此天數降權但不丟(對齊「防 2024」與「別砍稍舊好文」)。 +// defaultScoutSoftAgeDays:超過此天數只在 score 中降權,不淘汰候選。 const defaultScoutSoftAgeDays = 45 // minCrediblePublishedNano:早於 2020-01-01 的時間戳視為假資料/測試 stub,不套用時效過濾。 var minCrediblePublishedNano = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() -func isStalePublished(publishedAtNano int64, maxAgeDays int) bool { - if publishedAtNano < minCrediblePublishedNano || maxAgeDays <= 0 { - return false - } - cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays).UnixNano() - return publishedAtNano < cutoff -} - func isSoftAged(publishedAtNano int64, softDays int) bool { if publishedAtNano < minCrediblePublishedNano || softDays <= 0 { return false diff --git a/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go b/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go index 0bbee56..a4f0f6f 100644 --- a/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go +++ b/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go @@ -1,33 +1,45 @@ package usecase import ( + "context" + "net/http" + "net/http/httptest" "testing" "time" ) -func TestIsStalePublishedHardVsSoft(t *testing.T) { +func TestCrawlerProviderKeepsOldPublishedPosts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"posts":[{"permalink":"https://www.threads.net/@a/post/old","text":"市集舊文","published_at":"2020-01-02T00:00:00Z"}]}`)) + })) + defer server.Close() + + provider := NewHTTPCrawlerProvider(server.URL, "token") + provider.HTTP = server.Client() + got, err := provider.SearchChrome(context.Background(), "{}", []string{"市集"}, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].PublishedAt == 0 { + t.Fatalf("old crawler result=%+v", got) + } +} + +func TestPublishedTimeOnlyAffectsScore(t *testing.T) { now := time.Now().UTC() fresh := now.Add(-10 * 24 * time.Hour).UnixNano() mid := now.Add(-60 * 24 * time.Hour).UnixNano() - veryOld := now.Add(-200 * 24 * time.Hour).UnixNano() - if isStalePublished(fresh, defaultScoutHardMaxAgeDays) { - t.Fatal("10d should not be hard-stale") - } - if isStalePublished(mid, defaultScoutHardMaxAgeDays) { - t.Fatal("60d should not be hard-stale at 180d window") - } - if !isStalePublished(veryOld, defaultScoutHardMaxAgeDays) { - t.Fatal("200d should be hard-stale") - } if !isSoftAged(mid, defaultScoutSoftAgeDays) { t.Fatal("60d should be soft-aged at 45d") } if isSoftAged(fresh, defaultScoutSoftAgeDays) { t.Fatal("10d should not be soft-aged") } - if isStalePublished(0, 45) { - t.Fatal("unknown time is not stale") + if isSoftAged(0, defaultScoutSoftAgeDays) { + t.Fatal("unknown time is not soft-aged") } + // There is intentionally no hard date cutoff anymore; even very old + // candidates reach relevance/classification and are ordered by score. } func TestNormalizeSearchTrack(t *testing.T) { diff --git a/apps/backend/internal/module/scout/usecase/exa_threads_provider.go b/apps/backend/internal/module/scout/usecase/exa_threads_provider.go index f6a4173..ff7a223 100644 --- a/apps/backend/internal/module/scout/usecase/exa_threads_provider.go +++ b/apps/backend/internal/module/scout/usecase/exa_threads_provider.go @@ -78,15 +78,11 @@ func (p *ExaThreadsProvider) SearchThreads(ctx context.Context, terms []string, query = strings.Join(clean, " ") } - // 近 30 天,減少舊硬廣/過期活動 - startPublished := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339) - payload, err := json.Marshal(map[string]any{ - "query": query, - "type": "auto", - "numResults": limit, - "includeDomains": []string{"threads.net", "threads.com"}, - "startPublishedDate": startPublished, + "query": query, + "type": "auto", + "numResults": limit, + "includeDomains": []string{"threads.net", "threads.com"}, "contents": map[string]any{ "highlights": true, "text": map[string]any{"maxCharacters": 400}, diff --git a/apps/backend/internal/module/scout/usecase/exa_threads_provider_test.go b/apps/backend/internal/module/scout/usecase/exa_threads_provider_test.go index 11aefaa..fa4fa9a 100644 --- a/apps/backend/internal/module/scout/usecase/exa_threads_provider_test.go +++ b/apps/backend/internal/module/scout/usecase/exa_threads_provider_test.go @@ -17,6 +17,7 @@ func TestExaThreadsProviderSearchesThreadsDomains(t *testing.T) { var body map[string]any require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) require.Equal(t, []any{"threads.net", "threads.com"}, body["includeDomains"]) + require.NotContains(t, body, "startPublishedDate") _, _ = w.Write([]byte(`{"results":[{"title":"Not a Threads post","url":"https://example.com/post/1","highlights":["Ignore me"]},{"title":"Example","url":"https://www.threads.net/@alice/post/1","highlights":["A matching post"]}]}`)) })) defer server.Close() diff --git a/apps/backend/internal/module/scout/usecase/historical_dedupe_test.go b/apps/backend/internal/module/scout/usecase/historical_dedupe_test.go new file mode 100644 index 0000000..86940f5 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/historical_dedupe_test.go @@ -0,0 +1,50 @@ +package usecase + +import ( + "context" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" +) + +func TestCandidateEvaluatorSkipsOwnerHistoricalCanonicalIdentity(t *testing.T) { + store := repository.NewMemory() + if err := store.MarkSeenIdentity(context.Background(), 7, "threads-post:SameCode", "old-post", 1); err != nil { + t.Fatal(err) + } + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "咖啡店", ScanTerms: []string{"咖啡店"}, + }) + evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) { + return store.HasSeenIdentity(context.Background(), 7, identity) + }) + got := evaluator.Evaluate(ThreadSearchResult{ + URL: "https://www.threads.com/@renamed/post/SameCode?share=1", Snippet: "分享最近找到的咖啡店", + }) + if got.Decision != CandidateDuplicate || got.Reason == "" { + t.Fatalf("historical alias evaluation=%+v", got) + } + if stats := evaluator.Stats(); stats.Duplicate != 1 || stats.Eligible != 0 { + t.Fatalf("historical stats=%+v", stats) + } +} + +func TestCandidateEvaluatorDoesNotShareHistoryAcrossOwners(t *testing.T) { + store := repository.NewMemory() + if err := store.MarkSeenIdentity(context.Background(), 7, "threads-post:SameCode", "old-post", 1); err != nil { + t.Fatal(err) + } + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "咖啡店", ScanTerms: []string{"咖啡店"}, + }) + evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) { + return store.HasSeenIdentity(context.Background(), 8, identity) + }) + got := evaluator.Evaluate(ThreadSearchResult{ + URL: "https://www.threads.net/@alice/post/SameCode", Snippet: "分享最近找到的咖啡店", + }) + if got.Decision != CandidateEligible { + t.Fatalf("cross-owner history leaked: %+v", got) + } +} diff --git a/apps/backend/internal/module/scout/usecase/planner.go b/apps/backend/internal/module/scout/usecase/planner.go index f5b74db..1402811 100644 --- a/apps/backend/internal/module/scout/usecase/planner.go +++ b/apps/backend/internal/module/scout/usecase/planner.go @@ -1,5 +1,4 @@ - -ㄇㄠpackage usecase +package usecase import ( "crypto/sha256" @@ -320,21 +319,69 @@ func hasAny(text string, signals ...string) bool { return false } +func isThreadsHostname(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + return host == "threads.net" || strings.HasSuffix(host, ".threads.net") || + host == "threads.com" || strings.HasSuffix(host, ".threads.com") +} + +// threadsPostShortcode extracts the stable post identity from both profile +// permalinks (/@user/post/CODE) and short share links (/t/CODE). +func threadsPostShortcode(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || !isThreadsHostname(u.Hostname()) { + return "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i := 0; i+1 < len(parts); i++ { + segment := strings.ToLower(strings.TrimSpace(parts[i])) + if segment != "post" && segment != "t" { + continue + } + if code := strings.TrimSpace(parts[i+1]); code != "" { + return code + } + } + return "" +} + +// canonicalPostIdentity is the dedupe key. The shortcode stays case-sensitive; +// threads.net/.com, www/mobile hosts, query strings and author path changes do not. +func canonicalPostIdentity(raw string) string { + if code := threadsPostShortcode(raw); code != "" { + return "threads-post:" + code + } + if permalink := canonicalPermalink(raw); permalink != "" { + return "url:" + permalink + } + return "" +} + func canonicalPermalink(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Host == "" { return "" } u.Scheme = "https" - u.Host = strings.ToLower(u.Host) + if isThreadsHostname(u.Hostname()) { + u.Host = "www.threads.net" + } else { + u.Host = strings.ToLower(u.Host) + } + u.User = nil u.RawQuery = "" u.Fragment = "" u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = "" return u.String() } func permalinkID(ownerUID int64, permalink string) string { - sum := sha256.Sum256([]byte(strings.TrimSpace(permalink) + "|" + formatOwnerUID(ownerUID))) + identity := canonicalPostIdentity(permalink) + if identity == "" { + identity = strings.TrimSpace(permalink) + } + sum := sha256.Sum256([]byte(identity + "|" + formatOwnerUID(ownerUID))) return "sp_" + hex.EncodeToString(sum[:])[:20] } diff --git a/apps/backend/internal/module/scout/usecase/planner_test.go b/apps/backend/internal/module/scout/usecase/planner_test.go index 2f877b4..6802e7e 100644 --- a/apps/backend/internal/module/scout/usecase/planner_test.go +++ b/apps/backend/internal/module/scout/usecase/planner_test.go @@ -62,9 +62,9 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.queries) // product 模式略過 provider_offer(硬廣),noise 也略過 → 剩 3 require.Len(t, posts, 3) - // 依發文時間新→舊 - require.Equal(t, "https://www.threads.net/@a/post/1", posts[0].Permalink) - require.Equal(t, int64(300), posts[0].PostedAt) + // score 主排序;發文時間只在同分時決勝 + require.Equal(t, "https://www.threads.net/@b/post/2", posts[0].Permalink) + require.Equal(t, int64(200), posts[0].PostedAt) byClass := map[string]*domain.Post{} for _, post := range posts { byClass[post.Classification] = post @@ -82,7 +82,7 @@ func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { again, err := svc.RunScanFromBrief(context.Background(), 42, brief) require.NoError(t, err) - require.Equal(t, byClass[domain.ClassificationSeekingHelp].ID, again[0].ID) + require.Equal(t, byClass[domain.ClassificationSeekingRecommendation].ID, again[0].ID) persisted, err := svc.ListPosts(context.Background(), 42, "") require.NoError(t, err) require.Len(t, persisted, 3) @@ -139,8 +139,8 @@ func TestProviderScoutFindsProvenSolversAndExcludesSameCategory(t *testing.T) { 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) + require.Equal(t, domain.ClassificationProviderRecommended, posts[0].Classification) + require.Equal(t, domain.ClassificationProviderDirect, posts[1].Classification) } func TestDemandScoutKeepsHelpSignalsWithoutProviderTerms(t *testing.T) { @@ -167,8 +167,8 @@ func TestDemandScoutKeepsHelpSignalsWithoutProviderTerms(t *testing.T) { 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) + require.Equal(t, domain.ClassificationSeekingRecommendation, posts[0].Classification) + require.Equal(t, domain.ClassificationSeekingHelp, posts[1].Classification) } func TestProviderScoutPlansShortHighIntentQueries(t *testing.T) { diff --git a/apps/backend/internal/module/scout/usecase/product_lifecycle_test.go b/apps/backend/internal/module/scout/usecase/product_lifecycle_test.go new file mode 100644 index 0000000..e476d34 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/product_lifecycle_test.go @@ -0,0 +1,59 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" +) + +type productWatchLifecycleStub struct { + paused int + err error +} + +func (s *productWatchLifecycleStub) PauseProductWatches(context.Context, int64, string) (int, error) { + if s.err != nil { + return 0, s.err + } + s.paused++ + return s.paused, nil +} + +func TestRemoveProductPausesRadarBeforeDelete(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + if err := store.SaveProduct(ctx, &domain.Product{ID: "p1", OwnerUID: 42, BrandID: "b1", Label: "產品"}); err != nil { + t.Fatal(err) + } + bridge := &productWatchLifecycleStub{} + svc := New(store) + svc.RadarLifecycle = bridge + if err := svc.RemoveProduct(ctx, 42, "p1"); err != nil { + t.Fatal(err) + } + if bridge.paused != 1 { + t.Fatalf("pause bridge calls=%d", bridge.paused) + } + if _, err := svc.GetProduct(ctx, 42, "p1"); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("product still exists: %v", err) + } +} + +func TestRemoveProductDoesNotDeleteWhenPauseFails(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + if err := store.SaveProduct(ctx, &domain.Product{ID: "p1", OwnerUID: 42, BrandID: "b1", Label: "產品"}); err != nil { + t.Fatal(err) + } + svc := New(store) + svc.RadarLifecycle = &productWatchLifecycleStub{err: errors.New("radar unavailable")} + if err := svc.RemoveProduct(ctx, 42, "p1"); err == nil { + t.Fatal("delete succeeded after pause failure") + } + if _, err := svc.GetProduct(ctx, 42, "p1"); err != nil { + t.Fatalf("product should remain for retry: %v", err) + } +} diff --git a/apps/backend/internal/module/scout/usecase/quality_fixture_test.go b/apps/backend/internal/module/scout/usecase/quality_fixture_test.go new file mode 100644 index 0000000..c1f97e8 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/quality_fixture_test.go @@ -0,0 +1,68 @@ +package usecase + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +type qualityFixture struct { + ID string `json:"id"` + Intent string `json:"intent"` + Terms []string `json:"terms"` + Target int `json:"target"` + Cases []qualityCandidate `json:"cases"` +} + +type qualityCandidate struct { + URL string `json:"url"` + Text string `json:"text"` + Relevant bool `json:"relevant"` +} + +func loadQualityFixtures(t *testing.T) []qualityFixture { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "topic_quality_cases.json")) + require.NoError(t, err) + var fixtures []qualityFixture + require.NoError(t, json.Unmarshal(raw, &fixtures)) + require.NotEmpty(t, fixtures) + return fixtures +} + +func TestQualityFixturesCoverRepresentativeIntentSignals(t *testing.T) { + fixtures := loadQualityFixtures(t) + require.GreaterOrEqual(t, len(fixtures), 4) + + for _, fixture := range fixtures { + require.NotEmpty(t, fixture.ID) + require.NotEmpty(t, fixture.Intent) + require.NotEmpty(t, fixture.Terms) + require.NotEmpty(t, fixture.Cases) + + for _, candidate := range fixture.Cases { + require.NotEmpty(t, candidate.URL, fixture.ID) + require.NotEmpty(t, candidate.Text, fixture.ID) + got := textMatchesSearchTerm(candidate.Text, fixture.Terms[0]) + require.Equal(t, candidate.Relevant, got, "%s candidate=%q", fixture.ID, candidate.Text) + } + } +} + +func TestQualityFixturesCanonicalizeAliasWithoutHostnameDuplicates(t *testing.T) { + fixtures := loadQualityFixtures(t) + var alias *qualityFixture + for i := range fixtures { + if fixtures[i].ID == "alias-shortcode" { + alias = &fixtures[i] + break + } + } + require.NotNil(t, alias) + require.Len(t, alias.Cases, 2) + require.Equal(t, canonicalPostIdentity(alias.Cases[0].URL), canonicalPostIdentity(alias.Cases[1].URL)) + require.Equal(t, "threads-post:SameCode", canonicalPostIdentity(alias.Cases[0].URL)) +} diff --git a/apps/backend/internal/module/scout/usecase/relevance_test.go b/apps/backend/internal/module/scout/usecase/relevance_test.go index 723f296..b12e6c7 100644 --- a/apps/backend/internal/module/scout/usecase/relevance_test.go +++ b/apps/backend/internal/module/scout/usecase/relevance_test.go @@ -22,6 +22,18 @@ func TestTextMatchesSearchTerm(t *testing.T) { if !textMatchesSearchTerm("有人求推薦嗎", "求推薦") { t.Fatal("exact anchor term") } + if !textMatchesSearchTerm("正在找後端外包工程師協作", "後端 外包") { + t.Fatal("multi-concept query should match all cores") + } + if textMatchesSearchTerm("餐飲品牌正在找社群外包", "後端 外包") { + t.Fatal("multi-concept query must not match only one core") + } + if !textMatchesSearchTerm("鬼滅之刃最新一集討論度很高", "鬼滅之刃") { + t.Fatal("split title query should match contiguous full title") + } + if !textMatchesSearchTerm("大家最近又在聊鬼滅之刃", "鬼滅之刃 熱門") { + t.Fatal("discovery modifier should not be required in body") + } } func TestPersistSearchHitsRejectsIrrelevantAPIResult(t *testing.T) { @@ -43,7 +55,7 @@ func TestPersistSearchHitsRejectsIrrelevantAPIResult(t *testing.T) { } } -func TestPersistActivityHitsPrioritizesDiscussionMomentum(t *testing.T) { +func TestPersistActivityHitsPrioritizesScore(t *testing.T) { svc := New(repository.NewMemory()) posts, err := svc.persistSearchHits(context.Background(), 1, &domain.RunBrief{ Mode: domain.ModeActivity, ScanTerms: []string{"外包"}, @@ -58,6 +70,55 @@ func TestPersistActivityHitsPrioritizesDiscussionMomentum(t *testing.T) { t.Fatalf("posts=%d want 2", len(posts)) } if posts[0].Permalink != "https://www.threads.net/@ask/post/2" { - t.Fatalf("first post=%q want asking post", posts[0].Permalink) + t.Fatalf("first post=%q want highest score", posts[0].Permalink) + } +} + +func TestPersistActivityHitsPrioritizesScoreWithTrackBoost(t *testing.T) { + svc := New(repository.NewMemory()) + posts, err := svc.persistSearchHits(context.Background(), 1, &domain.RunBrief{ + Mode: domain.ModeActivity, ScanTerms: []string{"鬼滅之刃"}, + }, domain.PathCrawler, []ThreadSearchResult{ + { + URL: "https://www.threads.net/@ask/post/1", Snippet: "請問鬼滅之刃哪裡好看?", + Track: "recent", PublishedAt: 300, + }, + { + URL: "https://www.threads.net/@hot/post/2", Snippet: "鬼滅之刃新篇大家都在討論", + Track: "both", PublishedAt: 200, + }, + }) + if err != nil { + t.Fatalf("persistSearchHits: %v", err) + } + if len(posts) != 2 { + t.Fatalf("posts=%d want 2", len(posts)) + } + if posts[0].Permalink != "https://www.threads.net/@hot/post/2" { + t.Fatalf("first post=%q want highest score", posts[0].Permalink) + } +} + +func TestDedupePostsByIdentityHidesLegacyAliasesAndKeepsHandledState(t *testing.T) { + posts := []*domain.Post{ + { + ID: "legacy-new", Permalink: "https://threads.com/@old/post/DupeCode?share=1", + OutreachStatus: domain.OutreachNew, CreatedAt: 300, Score: 90, + }, + { + ID: "legacy-published", Permalink: "https://www.threads.net/@renamed/post/DupeCode", + OutreachStatus: domain.OutreachPublished, CreatedAt: 100, Score: 40, + }, + { + ID: "other", Permalink: "https://www.threads.net/@old/post/OtherCode", + OutreachStatus: domain.OutreachNew, + }, + } + got := dedupePostsByIdentity(posts) + if len(got) != 2 { + t.Fatalf("deduped posts=%d want 2", len(got)) + } + if got[0].ID != "legacy-published" { + t.Fatalf("kept id=%q want handled published record", got[0].ID) } } diff --git a/apps/backend/internal/module/scout/usecase/run_progress.go b/apps/backend/internal/module/scout/usecase/run_progress.go new file mode 100644 index 0000000..aeea9a3 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/run_progress.go @@ -0,0 +1,32 @@ +package usecase + +import ( + "context" + + "apps/backend/internal/module/scout/domain" +) + +// StartRun guards queued → running and is idempotent for a retry of the same +// job. A different job can never take over an active or terminal run. +func (s *Service) StartRun(ctx context.Context, ownerUID int64, runID, jobID string) (*domain.Run, error) { + if s == nil || s.Repo == nil || runID == "" || jobID == "" { + return nil, domain.ErrValidation + } + run, err := s.Repo.GetRun(ctx, ownerUID, runID) + if err != nil { + return nil, err + } + if run.JobID != jobID { + return nil, domain.ErrIllegalRunStatus + } + if run.Status == domain.RunRunning { + return run, nil + } + if err := run.Transition(domain.RunRunning, domain.NowNano()); err != nil { + return nil, err + } + if err := s.Repo.ReplaceRunGuarded(ctx, ownerUID, runID, []string{domain.RunQueued}, run); err != nil { + return nil, err + } + return run, nil +} diff --git a/apps/backend/internal/module/scout/usecase/run_progress_test.go b/apps/backend/internal/module/scout/usecase/run_progress_test.go new file mode 100644 index 0000000..a65b107 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/run_progress_test.go @@ -0,0 +1,45 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" +) + +func TestStartRunGuardsQueuedRunningAndTerminal(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + run := domain.NewRun("progress", "job-progress", 7, domain.RunBrief{Intent: "市集"}, 1) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + svc := New(store) + started, err := svc.StartRun(ctx, 7, run.ID, run.JobID) + if err != nil || started.Status != domain.RunRunning { + t.Fatalf("start=%+v err=%v", started, err) + } + retry, err := svc.StartRun(ctx, 7, run.ID, run.JobID) + if err != nil || retry.Status != domain.RunRunning { + t.Fatalf("idempotent retry=%+v err=%v", retry, err) + } + if err := svc.FailRun(ctx, 7, run.ID, "timeout"); err != nil { + t.Fatal(err) + } + if _, err := svc.StartRun(ctx, 7, run.ID, run.JobID); !errors.Is(err, domain.ErrIllegalRunStatus) { + t.Fatalf("terminal restart error=%v", err) + } +} + +func TestStartRunRejectsWrongJobBinding(t *testing.T) { + store := repository.NewMemory() + run := domain.NewRun("wrong-job", "job-a", 7, domain.RunBrief{Intent: "市集"}, 1) + if err := store.CreateRun(context.Background(), run); err != nil { + t.Fatal(err) + } + if _, err := New(store).StartRun(context.Background(), 7, run.ID, "job-b"); !errors.Is(err, domain.ErrIllegalRunStatus) { + t.Fatalf("wrong binding error=%v", err) + } +} diff --git a/apps/backend/internal/module/scout/usecase/run_publish.go b/apps/backend/internal/module/scout/usecase/run_publish.go new file mode 100644 index 0000000..5fd3c2f --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/run_publish.go @@ -0,0 +1,112 @@ +package usecase + +import ( + "context" + "fmt" + + "apps/backend/internal/module/scout/domain" +) + +// StageRunPosts writes a run's candidate set behind the repository visibility +// barrier. Repositories expose these posts only after the run is succeeded. +func (s *Service) StageRunPosts(ctx context.Context, ownerUID int64, runID string, posts []*domain.Post) error { + if s == nil || s.Repo == nil || runID == "" { + return domain.ErrValidation + } + r, err := s.Repo.GetRun(ctx, ownerUID, runID) + if err != nil { + return err + } + if r.Status != domain.RunRunning { + return domain.ErrIllegalRunStatus + } + for _, post := range posts { + if post == nil || post.ID == "" || post.OwnerUID != ownerUID || post.RunID != runID { + return fmt.Errorf("%w: staged post must belong to run", domain.ErrValidation) + } + } + return s.Repo.PublishRunPosts(ctx, ownerUID, runID, posts) +} + +// PublishRun atomically changes visibility by transitioning running → +// succeeded only after all staged posts have been written. If writing fails, +// the run remains non-visible and the caller can fail it safely. +func (s *Service) PublishRun(ctx context.Context, ownerUID int64, runID string, posts []*domain.Post) error { + if err := s.StageRunPosts(ctx, ownerUID, runID, posts); err != nil { + return err + } + r, err := s.Repo.GetRun(ctx, ownerUID, runID) + if err != nil { + return err + } + r.EligibleCount = len(posts) + r.PendingCount = countPendingRunPosts(posts) + if r.TargetCount > 0 && len(posts) < r.TargetCount { + r.ShortfallCount = r.TargetCount - len(posts) + if len(r.ShortfallReasons) == 0 { + r.ShortfallReasons = []string{domain.ShortfallSourceExhausted} + } + } else { + r.ShortfallCount = 0 + r.ShortfallReasons = []string{} + } + if err := r.Transition(domain.RunSucceeded, domain.NowNano()); err != nil { + return err + } + if err := s.Repo.ReplaceRunGuarded(ctx, ownerUID, runID, []string{domain.RunRunning}, r); err != nil { + return err + } + // Seen identities are recorded only after the visibility barrier opens. + // Marking is idempotent; a failure here must not turn a visible successful + // run into a misleading failed response. + for _, post := range posts { + identity := canonicalPostIdentity(post.Permalink) + if identity == "" { + identity = canonicalPostIdentity(post.ExternalID) + } + if identity == "" && post.ID != "" { + identity = "id:" + post.ID + } + if identity != "" { + _ = s.Repo.MarkSeenIdentity(ctx, ownerUID, identity, post.ID, domain.NowNano()) + } + } + return nil +} + +// FailRun is idempotent for terminal runs and guarded for queued/running +// states, so a retry cannot move a completed run backwards. +func (s *Service) FailRun(ctx context.Context, ownerUID int64, runID, reason string) error { + if s == nil || s.Repo == nil || runID == "" { + return domain.ErrValidation + } + r, err := s.Repo.GetRun(ctx, ownerUID, runID) + if err != nil { + return err + } + if domain.IsRunTerminal(r.Status) { + return nil + } + if err := r.Transition(domain.RunFailed, domain.NowNano()); err != nil { + return err + } + r.Error = safeRunError(reason) + return s.Repo.ReplaceRunGuarded(ctx, ownerUID, runID, []string{domain.RunQueued, domain.RunRunning}, r) +} + +func countPendingRunPosts(posts []*domain.Post) int { + n := 0 + for _, post := range posts { + if post != nil && (post.OutreachStatus == domain.OutreachNew || post.OutreachStatus == domain.OutreachDrafted) { + n++ + } + } + return n +} + +func safeRunError(reason string) string { + if len(reason) > 500 { + return reason[:500] + } + return reason +} diff --git a/apps/backend/internal/module/scout/usecase/run_publish_test.go b/apps/backend/internal/module/scout/usecase/run_publish_test.go new file mode 100644 index 0000000..a597493 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/run_publish_test.go @@ -0,0 +1,91 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" +) + +func TestRunPublishVisibilityBarrierAndCounters(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + run := domain.NewRun("run-publish", "job-publish", 7, domain.RunBrief{ + Intent: "市集", Mode: domain.ModeActivity, TargetCount: 3, + }, 100) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + if err := run.Transition(domain.RunRunning, 200); err != nil { + t.Fatal(err) + } + if err := store.ReplaceRunGuarded(ctx, 7, run.ID, []string{domain.RunQueued}, run); err != nil { + t.Fatal(err) + } + svc := New(store) + posts := []*domain.Post{ + {ID: "publish-new", RunID: run.ID, OwnerUID: 7, OutreachStatus: domain.OutreachNew, PostedAt: 200, CreatedAt: 20}, + {ID: "publish-old", RunID: run.ID, OwnerUID: 7, OutreachStatus: domain.OutreachDrafted, PostedAt: 100, CreatedAt: 10}, + } + if err := svc.StageRunPosts(ctx, 7, run.ID, posts); err != nil { + t.Fatal(err) + } + staged, err := store.ListRunPosts(ctx, 7, run.ID, 1, 10) + if err != nil || staged.Pagination.Total != 0 || len(staged.Items) != 0 { + t.Fatalf("staged posts leaked: %+v %v", staged, err) + } + if err := svc.PublishRun(ctx, 7, run.ID, posts); err != nil { + t.Fatal(err) + } + visible, err := store.ListRunPosts(ctx, 7, run.ID, 1, 10) + if err != nil || visible.Pagination.Total != 2 || len(visible.Items) != 2 || visible.Items[0].ID != "publish-new" { + t.Fatalf("published results=%+v %v", visible, err) + } + got, err := store.GetRun(ctx, 7, run.ID) + if err != nil || got.Status != domain.RunSucceeded || got.EligibleCount != 2 || got.PendingCount != 2 || got.ShortfallCount != 1 { + t.Fatalf("published run=%+v %v", got, err) + } + seen, err := store.HasSeenIdentity(ctx, 7, "id:publish-new") + if err != nil || !seen { + t.Fatalf("published identity not marked: %v %v", seen, err) + } +} + +func TestRunPublishFailureLeavesResultsHiddenAndTerminalGuard(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + run := domain.NewRun("run-fail", "job-fail", 7, domain.RunBrief{Intent: "市集", Mode: domain.ModeActivity}, 100) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + if err := run.Transition(domain.RunRunning, 200); err != nil { + t.Fatal(err) + } + if err := store.ReplaceRunGuarded(ctx, 7, run.ID, []string{domain.RunQueued}, run); err != nil { + t.Fatal(err) + } + svc := New(store) + posts := []*domain.Post{{ID: "hidden", RunID: run.ID, OwnerUID: 7}} + if err := svc.StageRunPosts(ctx, 7, run.ID, posts); err != nil { + t.Fatal(err) + } + if err := svc.FailRun(ctx, 7, run.ID, "provider timeout"); err != nil { + t.Fatal(err) + } + page, err := store.ListRunPosts(ctx, 7, run.ID, 1, 10) + if err != nil || page.Pagination.Total != 0 || len(page.Items) != 0 { + t.Fatalf("failed run exposed posts: %+v %v", page, err) + } + failed, err := store.GetRun(ctx, 7, run.ID) + if err != nil || failed.Status != domain.RunFailed || failed.Error != "provider timeout" { + t.Fatalf("failed run=%+v %v", failed, err) + } + if err := svc.FailRun(ctx, 7, run.ID, "retry"); err != nil { + t.Fatal(err) + } + if err := svc.StageRunPosts(ctx, 7, run.ID, posts); !errors.Is(err, domain.ErrIllegalRunStatus) { + t.Fatalf("terminal stage error=%v", err) + } +} diff --git a/apps/backend/internal/module/scout/usecase/runs.go b/apps/backend/internal/module/scout/usecase/runs.go new file mode 100644 index 0000000..7602568 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/runs.go @@ -0,0 +1,96 @@ +package usecase + +import ( + "context" + + "apps/backend/internal/module/scout/domain" + + "github.com/google/uuid" +) + +func (s *Service) GetRun(ctx context.Context, ownerUID int64, runID string) (*domain.Run, error) { + if s == nil || s.Repo == nil || runID == "" { + return nil, domain.ErrValidation + } + return s.Repo.GetRun(ctx, ownerUID, runID) +} + +// CreateQueuedRun creates one independent run for every scan request. The +// job_id is bound in a second guarded write after job scheduling succeeds. +func (s *Service) CreateQueuedRun(ctx context.Context, ownerUID int64, brief *domain.RunBrief) (*domain.Run, error) { + if s == nil || s.Repo == nil || brief == nil || ownerUID == 0 { + return nil, domain.ErrValidation + } + run := domain.NewRun("run_"+uuid.NewString(), "", ownerUID, *brief, domain.NowNano()) + if err := s.Repo.CreateRun(ctx, run); err != nil { + return nil, err + } + return run, nil +} + +func (s *Service) BindRunJob(ctx context.Context, ownerUID int64, runID, jobID string) (*domain.Run, error) { + if s == nil || s.Repo == nil || runID == "" || jobID == "" { + return nil, domain.ErrValidation + } + run, err := s.Repo.GetRun(ctx, ownerUID, runID) + if err != nil { + return nil, err + } + if run.JobID != "" && run.JobID != jobID { + return nil, domain.ErrIllegalRunStatus + } + run.JobID = jobID + if err := s.Repo.ReplaceRunGuarded(ctx, ownerUID, runID, []string{domain.RunQueued}, run); err != nil { + return nil, err + } + return run, nil +} + +// ListRuns returns only runs owned by ownerUID. Pagination is normalized here +// so every caller (HTTP, worker and tests) observes the same page contract. +func (s *Service) ListRuns(ctx context.Context, ownerUID int64, brandID, mode string, page, pageSize int) (domain.RunPage, error) { + if s == nil || s.Repo == nil { + return domain.RunPage{}, domain.ErrValidation + } + pageInfo := domain.NormalizePage(page, pageSize) + return s.Repo.ListRuns(ctx, domain.RunFilter{ + OwnerUID: ownerUID, + BrandID: brandID, + Mode: mode, + Page: pageInfo.Page, + PageSize: pageInfo.PageSize, + }) +} + +// ListRunPosts returns the selected run and its independently paged results. +// Repository implementations hide staged results until the run succeeds. +func (s *Service) ListRunPosts(ctx context.Context, ownerUID int64, runID string, page, pageSize int) (domain.RunPostPage, error) { + if s == nil || s.Repo == nil { + return domain.RunPostPage{}, domain.ErrValidation + } + if runID == "" { + return domain.RunPostPage{}, domain.ErrValidation + } + pageInfo := domain.NormalizePage(page, pageSize) + return s.Repo.ListRunPosts(ctx, ownerUID, runID, pageInfo.Page, pageInfo.PageSize) +} + +// RemoveRun only permits terminal runs to be removed. Returning the same +// guarded transition error for queued/running keeps the API from exposing +// implementation details while allowing response mapping to a 409 conflict. +func (s *Service) RemoveRun(ctx context.Context, ownerUID int64, runID string) error { + if s == nil || s.Repo == nil { + return domain.ErrValidation + } + if runID == "" { + return domain.ErrValidation + } + r, err := s.Repo.GetRun(ctx, ownerUID, runID) + if err != nil { + return err + } + if !domain.IsRunTerminal(r.Status) { + return domain.ErrIllegalRunStatus + } + return s.Repo.DeleteRun(ctx, ownerUID, runID) +} diff --git a/apps/backend/internal/module/scout/usecase/search_hits_only_test.go b/apps/backend/internal/module/scout/usecase/search_hits_only_test.go index 2cc304f..be09a51 100644 --- a/apps/backend/internal/module/scout/usecase/search_hits_only_test.go +++ b/apps/backend/internal/module/scout/usecase/search_hits_only_test.go @@ -128,3 +128,42 @@ func TestMergeHitsDedupe(t *testing.T) { t.Fatalf("merge len=%d want 2", len(got)) } } + +func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) { + aliases := []string{ + "https://www.threads.net/@alice/post/AbC123?xmt=AQG", + "https://threads.com/@alice/post/AbC123/", + "https://m.threads.net/@alice-renamed/post/AbC123#reply", + "https://www.threads.com/t/AbC123", + } + want := "threads-post:AbC123" + for _, raw := range aliases { + if got := canonicalPostIdentity(raw); got != want { + t.Fatalf("canonicalPostIdentity(%q)=%q want %q", raw, got, want) + } + } + for _, raw := range aliases[1:] { + if permalinkID(42, raw) != permalinkID(42, aliases[0]) { + t.Fatalf("alias produced a different persisted ID: %q", raw) + } + } + if got := canonicalPermalink(aliases[1]); got != "https://www.threads.net/@alice/post/AbC123" { + t.Fatalf("canonical permalink=%q", got) + } +} + +func TestMergeHitsDedupeUsesPostIdentityNotHostname(t *testing.T) { + got := mergeHitsDedupe( + []ThreadSearchResult{{URL: "https://threads.net/@alice/post/SameCode?x=1"}}, + []ThreadSearchResult{ + {URL: "https://www.threads.com/@alice-renamed/post/SameCode"}, + {URL: "https://threads.com/@alice/post/OtherCode"}, + }, + ) + if len(got) != 2 { + t.Fatalf("merge len=%d want 2 unique post shortcodes", len(got)) + } + if got[0].URL != "https://www.threads.net/@alice/post/SameCode" { + t.Fatalf("first canonical URL=%q", got[0].URL) + } +} diff --git a/apps/backend/internal/module/scout/usecase/search_pipeline.go b/apps/backend/internal/module/scout/usecase/search_pipeline.go new file mode 100644 index 0000000..2db5e9c --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/search_pipeline.go @@ -0,0 +1,247 @@ +package usecase + +import ( + "context" + "fmt" + "strings" + + "apps/backend/internal/module/scout/domain" +) + +const maxPipelineRawCandidates = 320 + +type SearchPipelineDiagnostics struct { + RawCount int + DuplicateCount int + IrrelevantCount int + EligibleCount int + Stages int + SourceUnavailable bool + ShortfallReasons []string +} + +type SearchPipelineResult struct { + Hits []ThreadSearchResult + Diagnostics SearchPipelineDiagnostics +} + +type searchPipelineRunner struct { + ctx context.Context + service *Service + brief *domain.RunBrief + terms []string + target int + path string + storageState string + source func(context.Context, string, int) ([]ThreadSearchResult, error) + secondary func(context.Context, string, int) ([]ThreadSearchResult, error) + evaluator *CandidateEvaluator + hits []ThreadSearchResult + diagnostics SearchPipelineDiagnostics + lastErr error + sourceOK bool +} + +// searchEligiblePipeline executes initial, same-source boost and optional +// secondary-source stages. It evaluates each raw candidate immediately, so a +// raw hit count can never satisfy target before relevance and dedupe checks. +func (s *Service) searchEligiblePipeline( + ctx context.Context, + ownerUID int64, + terms []string, + brief *domain.RunBrief, + target int, + path string, + storageState string, + initialPerQuery int, +) (SearchPipelineResult, error) { + if s == nil || brief == nil || len(terms) == 0 { + return SearchPipelineResult{}, fmt.Errorf("%w: search pipeline input required", domain.ErrValidation) + } + if initialPerQuery < 1 { + initialPerQuery = 1 + } + if initialPerQuery > 20 { + initialPerQuery = 20 + } + runner := &searchPipelineRunner{ + ctx: ctx, service: s, brief: brief, terms: nonEmptyTerms(terms), target: target, + path: path, storageState: storageState, evaluator: NewCandidateEvaluator(brief), + } + runner.evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) { + return s.Repo.HasSeenIdentity(ctx, ownerUID, identity) + }) + if path == domain.PathCrawler && s.Crawler != nil && storageState != "" { + runner.source = func(ctx context.Context, term string, limit int) ([]ThreadSearchResult, error) { + return s.Crawler.SearchChrome(ctx, storageState, []string{term}, limit) + } + runner.secondary = func(ctx context.Context, term string, limit int) ([]ThreadSearchResult, error) { + if s.Provider == nil { + return nil, fmt.Errorf("search provider is not configured") + } + return s.Provider.SearchThreads(ctx, []string{term}, limit) + } + } else if s.Provider != nil { + runner.source = func(ctx context.Context, term string, limit int) ([]ThreadSearchResult, error) { + return s.Provider.SearchThreads(ctx, []string{term}, limit) + } + } + if runner.source == nil { + return SearchPipelineResult{}, fmt.Errorf("%w: primary search source unavailable", domain.ErrValidation) + } + + runner.runStage(initialPerQuery, runner.source) + if target <= 0 || runner.reachedTarget() || runner.diagnostics.RawCount >= maxPipelineRawCandidates { + return runner.finish() + } + runner.runStage(20, runner.source) + if runner.reachedTarget() || runner.diagnostics.RawCount >= maxPipelineRawCandidates { + return runner.finish() + } + // API providers such as Exa do not expose an offset in this adapter, so a + // second identical request can return the same first page. When the user + // approved only one term, use a small, intent-derived set of conjunctions + // before giving up or switching source. The evaluator still applies the + // original topic signature, so expansion increases recall without relaxing + // relevance. + if expansionTerms := searchPipelineExpansionTerms(runner.brief, runner.terms); len(expansionTerms) > 0 { + runner.runStageForTerms(20, runner.source, expansionTerms) + if runner.reachedTarget() || runner.diagnostics.RawCount >= maxPipelineRawCandidates { + return runner.finish() + } + } + if runner.secondary != nil { + runner.runStage(20, runner.secondary) + } + return runner.finish() +} + +func (r *searchPipelineRunner) runStage(perQuery int, source func(context.Context, string, int) ([]ThreadSearchResult, error)) { + r.runStageForTerms(perQuery, source, r.terms) +} + +func (r *searchPipelineRunner) runStageForTerms(perQuery int, source func(context.Context, string, int) ([]ThreadSearchResult, error), terms []string) { + if source == nil || r.reachedTarget() || r.diagnostics.RawCount >= maxPipelineRawCandidates { + return + } + r.diagnostics.Stages++ + for _, term := range terms { + if r.reachedTarget() || r.diagnostics.RawCount >= maxPipelineRawCandidates { + break + } + remaining := maxPipelineRawCandidates - r.diagnostics.RawCount + limit := perQuery + if limit > remaining { + limit = remaining + } + hits, err := source(r.ctx, term, limit) + if err != nil { + r.lastErr = err + r.diagnostics.SourceUnavailable = true + } + if err == nil { + r.sourceOK = true + } + if len(hits) > remaining { + hits = hits[:remaining] + } + r.diagnostics.RawCount += len(hits) + for _, hit := range hits { + if hit.MatchedQuery == "" { + hit.MatchedQuery = term + } + if permalink := canonicalPermalink(hit.URL); permalink != "" { + hit.URL = permalink + } + evaluation := r.evaluator.EvaluateAt(hit, domain.NowNano()) + switch evaluation.Decision { + case CandidateEligible: + r.hits = append(r.hits, hit) + case CandidateDuplicate: + r.diagnostics.DuplicateCount++ + default: + r.diagnostics.IrrelevantCount++ + } + if r.reachedTarget() { + break + } + } + } +} + +func searchPipelineExpansionTerms(brief *domain.RunBrief, selected []string) []string { + if brief == nil || brief.Mode != domain.ModeActivity || len(nonEmptyTerms(selected)) != 1 { + return nil + } + // Keep this bounded: it is a recovery stage for a one-term approval, not a + // second unrestricted planner. planActivityTerms is deterministic and uses + // the same Threads short-query contract as the review UI. + planned := planActivityTerms(brief.Intent) + selectedKey := normalizeTopicText(selected[0]) + out := make([]string, 0, 4) + seen := map[string]struct{}{selectedKey: {}} + for _, term := range planned { + term = strings.TrimSpace(term) + key := normalizeTopicText(term) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + if len(semanticTopicParts(term)) == 0 { + continue + } + seen[key] = struct{}{} + out = append(out, term) + if len(out) == 4 { + break + } + } + return out +} + +func (r *searchPipelineRunner) reachedTarget() bool { + return r.target > 0 && len(r.hits) >= r.target +} + +func (r *searchPipelineRunner) finish() (SearchPipelineResult, error) { + r.diagnostics.EligibleCount = len(r.hits) + if r.target > 0 && len(r.hits) < r.target { + r.diagnostics.ShortfallReasons = shortfallReasons(r.diagnostics) + } + if !r.sourceOK && r.lastErr != nil { + return SearchPipelineResult{Hits: r.hits, Diagnostics: r.diagnostics}, r.lastErr + } + return SearchPipelineResult{Hits: r.hits, Diagnostics: r.diagnostics}, nil +} + +func shortfallReasons(d SearchPipelineDiagnostics) []string { + var reasons []string + if d.SourceUnavailable { + reasons = append(reasons, domain.ShortfallSourceUnavailable) + } + if d.DuplicateCount > 0 { + reasons = append(reasons, domain.ShortfallDuplicateExhausted) + } + if d.IrrelevantCount > 0 { + reasons = append(reasons, domain.ShortfallRelevanceExhausted) + } + if d.RawCount >= maxPipelineRawCandidates { + reasons = append(reasons, domain.ShortfallLimitReached) + } + if len(reasons) == 0 { + reasons = []string{domain.ShortfallSourceExhausted} + } + return reasons +} + +func normalizePipelineTerms(terms []string) []string { + out := make([]string, 0, len(terms)) + for _, term := range terms { + if term = strings.TrimSpace(term); term != "" { + out = append(out, term) + } + } + return dedupeTerms(out) +} diff --git a/apps/backend/internal/module/scout/usecase/search_pipeline_test.go b/apps/backend/internal/module/scout/usecase/search_pipeline_test.go new file mode 100644 index 0000000..617c609 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/search_pipeline_test.go @@ -0,0 +1,143 @@ +package usecase + +import ( + "context" + "fmt" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" +) + +type stagedPipelineProvider struct { + calls []int + firstBad int + allRelevant bool +} + +type oneTermExpansionProvider struct { + calls []string +} + +func (p *oneTermExpansionProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) { + term := "" + if len(terms) > 0 { + term = terms[0] + } + p.calls = append(p.calls, term) + out := make([]ThreadSearchResult, 0, limit) + for i := 0; i < limit; i++ { + text := "外包合作正在找人" + if term != "外包" { + text = "想找後端工程師做外包合作,歡迎推薦接案夥伴" + } + out = append(out, ThreadSearchResult{ + URL: fmt.Sprintf("https://www.threads.net/@source/post/%s-%d", term, i), Snippet: text, + }) + } + return out, nil +} + +func (p *stagedPipelineProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) { + call := len(p.calls) + 1 + p.calls = append(p.calls, limit) + term := "市集" + if len(terms) > 0 { + term = terms[0] + } + out := make([]ThreadSearchResult, 0, limit) + for i := 0; i < limit; i++ { + text := term + "討論內容" + if !p.allRelevant && call == 1 && i < p.firstBad { + text = "完全無關的內容" + } + out = append(out, ThreadSearchResult{ + URL: fmt.Sprintf("https://www.threads.net/@source/post/c%d-%d", call, i), Snippet: text, + }) + } + return out, nil +} + +func newPipelineService(provider ThreadSearchProvider) *Service { + svc := New(repository.NewMemory()) + svc.Provider = provider + return svc +} + +func TestSearchPipelineContinuesWhenRawHitsExceedEligibleHits(t *testing.T) { + provider := &stagedPipelineProvider{firstBad: 13} + svc := newPipelineService(provider) + result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"市集"}, &domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, + }, 10, domain.PathAPI, "", 20) + if err != nil { + t.Fatal(err) + } + if len(result.Hits) != 10 || result.Diagnostics.EligibleCount != 10 { + t.Fatalf("pipeline result=%+v", result) + } + if len(provider.calls) != 2 || provider.calls[0] != 20 || provider.calls[1] != 20 { + t.Fatalf("calls=%v want initial + same-source boost", provider.calls) + } + if result.Diagnostics.IrrelevantCount != 13 || result.Diagnostics.RawCount != 40 { + t.Fatalf("diagnostics=%+v", result.Diagnostics) + } +} + +func TestSearchPipelineStopsAtEligibleTarget(t *testing.T) { + provider := &stagedPipelineProvider{allRelevant: true} + svc := newPipelineService(provider) + result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"市集", "活動"}, &domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集", "活動"}, + }, 3, domain.PathAPI, "", 20) + if err != nil { + t.Fatal(err) + } + if len(result.Hits) != 3 || len(provider.calls) != 1 { + t.Fatalf("result=%+v calls=%v; target should stop first query", result, provider.calls) + } + if result.Diagnostics.Stages != 1 { + t.Fatalf("stages=%d want 1", result.Diagnostics.Stages) + } +} + +func TestSearchPipelineCapsRawCandidatesAt320(t *testing.T) { + provider := &stagedPipelineProvider{allRelevant: true} + svc := newPipelineService(provider) + terms := []string{"一", "二", "三", "四", "五", "六", "七", "八"} + result, err := svc.searchEligiblePipeline(context.Background(), 7, terms, &domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, + }, 999, domain.PathAPI, "", 20) + if err != nil { + t.Fatal(err) + } + if result.Diagnostics.RawCount != maxPipelineRawCandidates || result.Diagnostics.Stages != 2 { + t.Fatalf("diagnostics=%+v", result.Diagnostics) + } + if len(provider.calls) != 16 { + t.Fatalf("calls=%d want 16 (8 terms x 2 stages)", len(provider.calls)) + } + if len(result.Diagnostics.ShortfallReasons) == 0 { + t.Fatal("raw cap shortfall reason missing") + } +} + +func TestSearchPipelineExpandsOneApprovedTermWhenProviderRepeatsFirstPage(t *testing.T) { + provider := &oneTermExpansionProvider{} + svc := newPipelineService(provider) + result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"外包"}, &domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "外包 後端工程師", ScanTerms: []string{"外包"}, + }, 5, domain.PathAPI, "", 20) + if err != nil { + t.Fatal(err) + } + if len(result.Hits) != 5 { + t.Fatalf("expanded result=%+v, want five eligible hits", result) + } + if len(provider.calls) < 3 || provider.calls[0] != "外包" || provider.calls[1] != "外包" { + t.Fatalf("calls=%v, want repeated first query before expansion", provider.calls) + } + if result.Diagnostics.Stages != 3 { + t.Fatalf("stages=%d, want initial, boost, expansion", result.Diagnostics.Stages) + } +} diff --git a/apps/backend/internal/module/scout/usecase/search_quality_integration_test.go b/apps/backend/internal/module/scout/usecase/search_quality_integration_test.go new file mode 100644 index 0000000..66cc79f --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/search_quality_integration_test.go @@ -0,0 +1,129 @@ +package usecase + +import ( + "context" + "testing" + + "apps/backend/internal/module/scout/domain" + "apps/backend/internal/module/scout/repository" +) + +func TestSearchQualityFixturesUseOneHardRelevanceGate(t *testing.T) { + for _, fixture := range loadQualityFixtures(t) { + t.Run(fixture.ID, func(t *testing.T) { + for _, candidate := range fixture.Cases { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: fixture.Intent, ScanTerms: fixture.Terms, + }) + got := evaluator.Evaluate(ThreadSearchResult{URL: candidate.URL, Snippet: candidate.Text}) + if got.Decision == CandidateEligible != candidate.Relevant { + t.Fatalf("candidate=%q decision=%s reason=%q want eligible=%v", candidate.Text, got.Decision, got.Reason, candidate.Relevant) + } + if candidate.Relevant && got.Reason == "" { + t.Fatal("eligible candidate has no match reason") + } + } + }) + } +} + +func TestSearchQualityShortfallIsExplainableAfterAllStages(t *testing.T) { + provider := &irrelevantPipelineProvider{} + svc := newPipelineService(provider) + result, err := svc.searchEligiblePipeline(context.Background(), 7, []string{"市集"}, &domain.RunBrief{ + Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}, + }, 8, domain.PathAPI, "", 20) + if err != nil { + t.Fatal(err) + } + if result.Diagnostics.EligibleCount != 0 || result.Diagnostics.RawCount != 120 || result.Diagnostics.Stages != 3 { + t.Fatalf("shortfall diagnostics=%+v", result.Diagnostics) + } + if !containsString(result.Diagnostics.ShortfallReasons, domain.ShortfallRelevanceExhausted) { + t.Fatalf("shortfall reasons=%v", result.Diagnostics.ShortfallReasons) + } +} + +type irrelevantPipelineProvider struct{} + +func (*irrelevantPipelineProvider) SearchThreads(_ context.Context, _ []string, limit int) ([]ThreadSearchResult, error) { + out := make([]ThreadSearchResult, 0, limit) + for i := 0; i < limit; i++ { + out = append(out, ThreadSearchResult{URL: "https://threads.net/@noise/post/" + itoaASCII(i), Snippet: "完全無關的貼文"}) + } + return out, nil +} + +func TestSearchQualityHistoricalDedupKeepsOldRunUntouched(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + old := domain.NewRun("old-run", "old-job", 7, domain.RunBrief{Intent: "咖啡店", Mode: domain.ModeActivity}, 10) + old.Status = domain.RunSucceeded + if err := store.CreateRun(ctx, old); err != nil { + t.Fatal(err) + } + oldPost := &domain.Post{ID: "old-post", RunID: old.ID, OwnerUID: 7, ExternalID: "https://www.threads.net/@a/post/Same", Permalink: "https://www.threads.net/@a/post/Same", Text: "舊結果", PostedAt: 100, CreatedAt: 100} + if err := store.PublishRunPosts(ctx, 7, old.ID, []*domain.Post{oldPost}); err != nil { + t.Fatal(err) + } + if err := store.MarkSeenIdentity(ctx, 7, "threads-post:Same", oldPost.ID, oldPost.CreatedAt); err != nil { + t.Fatal(err) + } + evaluator := NewCandidateEvaluator(&domain.RunBrief{Mode: domain.ModeActivity, Intent: "咖啡店", ScanTerms: []string{"咖啡店"}}) + evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) { return store.HasSeenIdentity(ctx, 7, identity) }) + got := evaluator.Evaluate(ThreadSearchResult{URL: "https://threads.com/@renamed/post/Same?x=1", Snippet: "分享咖啡店心得"}) + if got.Decision != CandidateDuplicate { + t.Fatalf("historical duplicate=%+v", got) + } + kept, err := store.GetRun(ctx, 7, old.ID) + if err != nil || kept.Status != domain.RunSucceeded { + t.Fatalf("old run changed: %+v %v", kept, err) + } +} + +func TestSearchQualityResultsUseScoreThenPostedTimeAcrossPages(t *testing.T) { + ctx := context.Background() + store := repository.NewMemory() + run := domain.NewRun("quality-order", "job-order", 7, domain.RunBrief{Intent: "市集", Mode: domain.ModeActivity}, 1) + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + if err := run.Transition(domain.RunRunning, 2); err != nil { + t.Fatal(err) + } + if err := store.ReplaceRunGuarded(ctx, 7, run.ID, []string{domain.RunQueued}, run); err != nil { + t.Fatal(err) + } + svc := New(store) + posts := []*domain.Post{ + {ID: "unknown", RunID: run.ID, OwnerUID: 7, Score: 80, PostedAt: 0, CreatedAt: 500}, + {ID: "old", RunID: run.ID, OwnerUID: 7, Score: 90, PostedAt: 100, CreatedAt: 100}, + {ID: "new", RunID: run.ID, OwnerUID: 7, Score: 100, PostedAt: 300, CreatedAt: 300}, + {ID: "same-b", RunID: run.ID, OwnerUID: 7, Score: 70, PostedAt: 200, CreatedAt: 200}, + {ID: "same-a", RunID: run.ID, OwnerUID: 7, Score: 70, PostedAt: 200, CreatedAt: 100}, + } + if err := svc.PublishRun(ctx, 7, run.ID, posts); err != nil { + t.Fatal(err) + } + page1, err := store.ListRunPosts(ctx, 7, run.ID, 1, 2) + if err != nil || len(page1.Items) != 2 || page1.Items[0].ID != "new" || page1.Items[1].ID != "old" { + t.Fatalf("page1=%+v %v", page1, err) + } + page2, err := store.ListRunPosts(ctx, 7, run.ID, 2, 2) + if err != nil || len(page2.Items) != 2 || page2.Items[0].ID != "unknown" || page2.Items[1].ID != "same-b" { + t.Fatalf("page2=%+v %v", page2, err) + } + page3, err := store.ListRunPosts(ctx, 7, run.ID, 3, 2) + if err != nil || len(page3.Items) != 1 || page3.Items[0].ID != "same-a" { + t.Fatalf("page3=%+v %v", page3, err) + } +} + +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go index 819a5b7..d76e30e 100644 --- a/apps/backend/internal/module/scout/usecase/service.go +++ b/apps/backend/internal/module/scout/usecase/service.go @@ -32,6 +32,12 @@ type ReplyQueue interface { // OutreachPublishedHook fires after mark-published / successful outreach send path. type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string) +// ProductWatchLifecycle is a narrow bridge to Radar; Scout owns product CRUD, +// while Radar owns watch state and historical sweep semantics. +type ProductWatchLifecycle interface { + PauseProductWatches(ctx context.Context, ownerUID int64, productID string) (int, error) +} + type Service struct { Repo domain.Repository Settings SettingsReader @@ -47,6 +53,7 @@ type Service struct { SessionSecret string // OnOutreachPublished optional growth-loop outcome hook. OnOutreachPublished OutreachPublishedHook + RadarLifecycle ProductWatchLifecycle } func New(repo domain.Repository) *Service { @@ -196,6 +203,11 @@ func (s *Service) RemoveProduct(ctx context.Context, ownerUID int64, id string) if _, err := s.GetProduct(ctx, ownerUID, id); err != nil { return err } + if s.RadarLifecycle != nil { + if _, err := s.RadarLifecycle.PauseProductWatches(ctx, ownerUID, id); err != nil { + return err + } + } return s.Repo.DeleteProduct(ctx, id) } @@ -539,6 +551,27 @@ func capHits(hits []ThreadSearchResult, limit int) []ThreadSearchResult { } func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) { + return s.runScanFromBrief(ctx, ownerUID, brief, "") +} + +// RunScanForRun executes the same search pipeline without making results +// visible through the legacy posts collection. Results are staged under the +// running run; PublishRun opens the visibility barrier after the whole scan. +func (s *Service) RunScanForRun(ctx context.Context, ownerUID int64, runID string, brief *domain.RunBrief) ([]*domain.Post, error) { + if runID == "" { + return nil, domain.ErrValidation + } + posts, err := s.runScanFromBrief(ctx, ownerUID, brief, runID) + if err != nil { + return nil, err + } + if err := s.StageRunPosts(ctx, ownerUID, runID, posts); err != nil { + return nil, err + } + return posts, nil +} + +func (s *Service) runScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief, runID string) ([]*domain.Post, error) { if brief == nil { return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation) } @@ -603,7 +636,6 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d } } - var hits []ThreadSearchResult var err error var storageState string if devMode { @@ -615,29 +647,16 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d if s.Crawler == nil { return nil, fmt.Errorf("Chrome crawler is not configured") } - hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) { - return s.Crawler.SearchChrome(ctx, storageState, []string{q}, limit) - }) } else { if s.Provider == nil { return nil, fmt.Errorf("scout search provider is not configured") } - hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) { - return s.Provider.SearchThreads(ctx, []string{q}, limit) - }) } + pipeline, err := s.searchEligiblePipeline(ctx, ownerUID, terms, brief, target, path, storageState, perQuery) if err != nil { return nil, err } - - // 今日目標補抓:主路徑不足 → 同路徑加碼 → 次路徑(crawler 不足時用 search/Exa)只補缺口。 - if target > 0 && len(hits) < target { - hits = fillSearchHitsToTarget(ctx, s, terms, hits, target, path, storageState) - } - if target > 0 { - hits = capHits(hits, target) - } - return s.persistSearchHits(ctx, ownerUID, brief, path, hits) + return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, runID, pipeline.Hits) } // fillSearchHitsToTarget tops up hits when the first fan-out misses the daily goal. @@ -690,36 +709,28 @@ func fillSearchHitsToTarget( return hits } -func mergeHitsDedupe(base, extra []ThreadSearchResult) []ThreadSearchResult { - seen := make(map[string]struct{}, len(base)+len(extra)) - out := make([]ThreadSearchResult, 0, len(base)+len(extra)) - for _, h := range base { - key := canonicalPermalink(h.URL) - if key == "" { - key = strings.TrimSpace(h.URL) +func mergeHitsDedupe(groups ...[]ThreadSearchResult) []ThreadSearchResult { + seen := make(map[string]struct{}) + var out []ThreadSearchResult + for _, group := range groups { + for _, hit := range group { + permalink := canonicalPermalink(hit.URL) + key := canonicalPostIdentity(hit.URL) + if key == "" { + key = permalink + } + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if permalink != "" { + hit.URL = permalink + } + out = append(out, hit) } - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, h) - } - for _, h := range extra { - key := canonicalPermalink(h.URL) - if key == "" { - key = strings.TrimSpace(h.URL) - } - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, h) } return out } @@ -753,15 +764,16 @@ func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func continue } for _, hit := range hits { - key := canonicalPermalink(hit.URL) - if key == "" { + permalink := canonicalPermalink(hit.URL) + identity := canonicalPostIdentity(hit.URL) + if identity == "" || permalink == "" { continue } - if _, ok := seen[key]; ok { + if _, ok := seen[identity]; ok { continue } - seen[key] = struct{}{} - hit.URL = key + seen[identity] = struct{}{} + hit.URL = permalink // 記住是哪條 query 命中,方便 search_tag if hit.MatchedQuery == "" { hit.MatchedQuery = term @@ -776,108 +788,83 @@ func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func } func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) { + return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, "", hits) +} + +func (s *Service) persistSearchHitsWithRun(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path, runID string, hits []ThreadSearchResult) ([]*domain.Post, error) { now := domain.NowNano() - // 保真:crawler 已依 Recent 主序回傳,不再 both-first 重排蓋掉平台相關性。 - // 無 track 的 Exa 結果仍依發文時間新→舊。 - if !hitsHaveTrack(hits) { - sortHitsByPostedAt(hits) - } + // Provider 原始順序只用於 deterministic created_at;最終結果由 + // evaluator score 排序,不能再被來源的時間/track 順序覆蓋。 + hasTrack := hitsHaveTrack(hits) out := make([]*domain.Post, 0, len(hits)) + evaluator := NewCandidateEvaluator(brief) + evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) { + return s.Repo.HasSeenIdentity(ctx, ownerUID, identity) + }) for i, hit := range hits { - text := strings.TrimSpace(hit.Snippet) - if text == "" { + evaluation := evaluator.EvaluateAt(hit, now) + if evaluation.Decision != CandidateEligible { continue } - permalink := canonicalPermalink(hit.URL) - if permalink == "" { - continue - } - postedAt := hit.PublishedAt - // 硬擋:僅極舊(180 天);測試 stub 時間戳不套用 - if postedAt > 0 && isStalePublished(postedAt, defaultScoutHardMaxAgeDays) { - continue - } - term := strings.TrimSpace(hit.MatchedQuery) - // 相關性硬閘:所有來源的正文都必須含查詢主題核,避免 provider - // 回傳的語意擴展/空 SERP 雜訊混進話題佇列。 - matchedTerm := matchingSearchTerm(text+" "+hit.Title, brief.ScanTerms) - if matchedTerm == "" { - continue - } - // fan-out records the query that returned a hit. Prefer the term actually - // found in its text so a result from a different query is not mislabeled. - if term == "" || !textMatchesSearchTerm(text+" "+hit.Title, term) { - term = matchedTerm - } - 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 - } - // 痛點回覆(product/theme)要找「有困擾的人」,略過同業硬廣/服務洽詢 - if (brief.Mode == domain.ModeProduct || brief.Mode == domain.ModeTheme) && - classified.classification == domain.ClassificationProviderOffer { - continue - } - if brief.Mode == domain.ModeProvider && (classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) { - continue - } - score := classified.score - reason := classified.reason - // track 只標記/輕加分,不重排(both 表示熱門+最新都有,可信度稍高) - switch hit.Track { - case "both": - score = minInt(100, score+6) - reason = reason + "; track: both" - case "recent": - score = minInt(100, score+3) - reason = reason + "; track: recent" - case "top": - reason = reason + "; track: top" - } - if hit.SerpRank > 0 { - reason = reason + fmt.Sprintf("; serp_rank: %d", hit.SerpRank) - } - // 軟時效:>45 天降權並標註,不刪(避免「準的稍舊文」消失) - if postedAt > 0 && isSoftAged(postedAt, defaultScoutSoftAgeDays) { - score = maxInt(1, score-12) - reason = reason + "; soft_aged" - } + text := evaluation.Text + permalink := evaluation.Permalink + postedAt := evaluation.PostedAt // created_at:保 crawler 回傳序(i 越小越前);有發文時間仍寫 PostedAt 供 UI createdAt := now - int64(i)*1000 - if postedAt > 0 && !hitsHaveTrack(hits) { + if postedAt > 0 && !hasTrack { // 非 crawler 路徑仍用發文時間當 created 序 createdAt = postedAt } p := &domain.Post{ ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink, - OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text, - SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew, - Score: score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel, - MatchReason: reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent, + OwnerUID: ownerUID, RunID: runID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text, + SearchTag: evaluation.SearchTag, Opportunity: "", OutreachStatus: domain.OutreachNew, + Score: evaluation.Score, Classification: evaluation.Classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel, + MatchReason: evaluation.Reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent, ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path, PostedAt: postedAt, CreatedAt: createdAt, } - if err := s.Repo.SavePost(ctx, p); err != nil { - return nil, err + if runID == "" { + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } } out = append(out, p) } - // 話題優先顯示近期討論動能(track/問答訊號已計入分數),再以發文時間決勝。 - // 其他模式維持既有的時間排序,避免改變商機/解法媒合的行為。 - if brief.Mode == domain.ModeActivity { - sortActivityPostsByMomentum(out) - } else if !hitsHaveTrack(hits) { - sortPostsByPostedAt(out) - } + // Score is the primary quality order. Posted/created time and ID only make + // equal scores deterministic; an old but highly relevant post can therefore + // still be returned ahead of a newer weak match. + sortPostsByScore(out) return out, nil } +func sortPostsByScore(posts []*domain.Post) { + for i := 0; i < len(posts); i++ { + for j := i + 1; j < len(posts); j++ { + a, b := posts[i], posts[j] + shouldSwap := false + if a.Score != b.Score { + shouldSwap = b.Score > a.Score + } else if (a.PostedAt > 0) != (b.PostedAt > 0) { + shouldSwap = b.PostedAt > 0 + } else if a.PostedAt > 0 && a.PostedAt != b.PostedAt { + shouldSwap = b.PostedAt > a.PostedAt + } else if a.CreatedAt != b.CreatedAt { + shouldSwap = b.CreatedAt > a.CreatedAt + } else { + shouldSwap = b.ID > a.ID + } + if shouldSwap { + posts[i], posts[j] = posts[j], posts[i] + } + } + } +} + +// sortPostsByResultTime is kept as a package-local compatibility name for +// older tests/callers; result ordering now intentionally delegates to score. +func sortPostsByResultTime(posts []*domain.Post) { sortPostsByScore(posts) } + func hitsHaveTrack(hits []ThreadSearchResult) bool { for _, h := range hits { if h.Track != "" { @@ -892,6 +879,7 @@ var searchAnchors = map[string]bool{ "求推薦": true, "推薦": true, "分享": true, "心得": true, "活動": true, "怎麼辦": true, "詢問": true, "討論": true, "有人知道": true, "請問": true, + "熱門": true, "最新": true, "近期": true, } // textMatchesSearchTerm:正文須含查詢的主題核(非口語錨點)。 @@ -917,14 +905,15 @@ func textMatchesSearchTerm(text, term string) bool { content = append(content, strings.ReplaceAll(t, " ", "")) } } - // 有主題核:任一主題核命中即可(不要求錨點) + // 有主題核:每個主題核都要命中;錨點只是搜尋修飾,不要求正文出現。 + // 「後端 外包」不能因為只提到「外包」就混入其他職種。 if len(content) > 0 { for _, c := range content { - if strings.Contains(body, c) { - return true + if !strings.Contains(body, c) { + return false } } - return false + return true } // 只有錨點:放寬(使用者刻意只搜「求推薦」) for _, a := range anchors { @@ -1071,7 +1060,64 @@ func authorFromThreadsURL(raw string) string { } func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) { - return s.Repo.ListPosts(ctx, ownerUID, brandID) + posts, err := s.Repo.ListPosts(ctx, ownerUID, brandID) + if err != nil { + return nil, err + } + return dedupePostsByIdentity(posts), nil +} + +// dedupePostsByIdentity hides legacy duplicates that were stored before IDs +// were based on Threads post shortcodes. It is read-only: no user history is deleted. +func dedupePostsByIdentity(posts []*domain.Post) []*domain.Post { + seen := make(map[string]int, len(posts)) + out := make([]*domain.Post, 0, len(posts)) + for _, post := range posts { + if post == nil { + continue + } + key := canonicalPostIdentity(post.Permalink) + if key == "" { + key = canonicalPostIdentity(post.ExternalID) + } + if key == "" { + key = "id:" + post.ID + } + if idx, ok := seen[key]; ok { + if preferScoutPost(post, out[idx]) { + out[idx] = post + } + continue + } + seen[key] = len(out) + out = append(out, post) + } + sortPostsByScore(out) + return out +} + +func preferScoutPost(candidate, current *domain.Post) bool { + statusRank := func(status string) int { + switch status { + case domain.OutreachPublished: + return 5 + case domain.OutreachQueued: + return 4 + case domain.OutreachDrafted, domain.OutreachSkipped: + return 3 + case domain.OutreachNew: + return 1 + default: + return 0 + } + } + if a, b := statusRank(candidate.OutreachStatus), statusRank(current.OutreachStatus); a != b { + return a > b + } + if candidate.CreatedAt != current.CreatedAt { + return candidate.CreatedAt > current.CreatedAt + } + return candidate.Score > current.Score } func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, personaID string) (*domain.Post, error) { diff --git a/apps/backend/internal/module/scout/usecase/testdata/topic_quality_cases.json b/apps/backend/internal/module/scout/usecase/testdata/topic_quality_cases.json new file mode 100644 index 0000000..489c320 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/testdata/topic_quality_cases.json @@ -0,0 +1,33 @@ +[ + {"id":"work-backend-outsourcing","intent":"外包 工程師 後端","terms":["後端 外包"],"target":1,"cases":[{"url":"https://www.threads.net/@builder/post/work-001","text":"想找後端工程師做外包合作,有推薦的接案夥伴嗎?","relevant":true},{"url":"https://www.threads.net/@career/post/work-002","text":"請問一般工程師轉職需要準備什麼?","relevant":false}]}, + {"id":"anime-demon-slayer","intent":"鬼滅之刃","terms":["鬼滅之刃","鬼滅之刃 最新"],"target":1,"cases":[{"url":"https://threads.net/@fan/post/anime-001?share=1","text":"鬼滅之刃最新一集討論度好高,大家最喜歡哪個角色?","relevant":true},{"url":"https://www.threads.net/@media/post/anime-002","text":"最近想看動畫,有推薦熱門作品嗎?","relevant":false}]}, + {"id":"market-weekend","intent":"台北週末市集","terms":["台北 市集","週末 市集"],"target":1,"cases":[{"url":"https://www.threads.net/@local/post/market-001","text":"這週末台北有沒有適合拍照的市集?","relevant":true},{"url":"https://www.threads.net/@weather/post/market-002","text":"台北週末天氣好像會下雨,大家記得帶傘。","relevant":false}]}, + {"id":"alias-shortcode","intent":"咖啡店","terms":["咖啡店"],"target":1,"cases":[{"url":"https://threads.com/@alice/post/SameCode?x=1","text":"分享最近找到的咖啡店。","relevant":true},{"url":"https://www.threads.net/@renamed/post/SameCode","text":"分享最近找到的咖啡店。","relevant":true}]}, + {"id":"person-mayday","intent":"五月天 演唱會","terms":["五月天 演唱會"],"target":1,"cases":[{"url":"https://www.threads.net/@music/post/mayday-001","text":"五月天演唱會哪首歌最適合現場大合唱?","relevant":true},{"url":"https://www.threads.net/@music/post/mayday-002","text":"最近聽了很多華語歌,想找新歌單。","relevant":false}]}, + {"id":"film-wong-kar-wai","intent":"王家衛 電影","terms":["王家衛 電影"],"target":1,"cases":[{"url":"https://www.threads.net/@cinema/post/wkw-001","text":"王家衛電影的色彩和配樂總讓人很想重看。","relevant":true},{"url":"https://www.threads.net/@cinema/post/wkw-002","text":"最近有哪些經典電影值得入門?","relevant":false}]}, + {"id":"book-three-body","intent":"三體 小說","terms":["三體 小說"],"target":1,"cases":[{"url":"https://www.threads.net/@reader/post/book-001","text":"三體小說第三部看完後,大家怎麼解讀結局?","relevant":true},{"url":"https://www.threads.net/@reader/post/book-002","text":"最近想找一本適合通勤讀的科幻書。","relevant":false}]}, + {"id":"game-elden-ring","intent":"艾爾登法環 DLC","terms":["艾爾登法環 DLC"],"target":1,"cases":[{"url":"https://www.threads.net/@gamer/post/game-001","text":"艾爾登法環 DLC 的新頭目打法有人整理嗎?","relevant":true},{"url":"https://www.threads.net/@gamer/post/game-002","text":"最近想找一款耐玩的單機遊戲。","relevant":false}]}, + {"id":"event-lantern-festival","intent":"台南 燈會","terms":["台南 燈會"],"target":1,"cases":[{"url":"https://www.threads.net/@travel/post/event-001","text":"台南燈會晚上幾點去比較不塞車?","relevant":true},{"url":"https://www.threads.net/@travel/post/event-002","text":"台南這週天氣變化很大,出門記得帶外套。","relevant":false}]}, + {"id":"event-taipei-marathon","intent":"台北 馬拉松","terms":["台北 馬拉松"],"target":1,"cases":[{"url":"https://www.threads.net/@runner/post/event-002","text":"台北馬拉松報名開始了嗎?新手該如何訓練?","relevant":true},{"url":"https://www.threads.net/@runner/post/event-003","text":"最近開始每天散步,希望養成運動習慣。","relevant":false}]}, + {"id":"event-design-expo","intent":"設計 展覽","terms":["設計 展覽"],"target":1,"cases":[{"url":"https://www.threads.net/@design/post/event-004","text":"這個月有哪些設計展覽值得安排週末去看?","relevant":true},{"url":"https://www.threads.net/@design/post/event-005","text":"我正在整理房間的展覽海報收藏。","relevant":false}]}, + {"id":"event-music-festival","intent":"海洋音樂祭","terms":["海洋 音樂祭"],"target":1,"cases":[{"url":"https://www.threads.net/@festival/post/event-006","text":"今年海洋音樂祭的演出名單有人整理了嗎?","relevant":true},{"url":"https://www.threads.net/@festival/post/event-007","text":"最近在家練習吉他和寫歌。","relevant":false}]}, + {"id":"industry-uiux-freelance","intent":"UI UX 接案","terms":["UI UX 接案"],"target":1,"cases":[{"url":"https://www.threads.net/@designer/post/industry-001","text":"UI UX接案報價通常怎麼估,想找人合作產品設計。","relevant":true},{"url":"https://www.threads.net/@designer/post/industry-002","text":"最近想學畫畫,請推薦適合初學者的工具。","relevant":false}]}, + {"id":"industry-content-marketing","intent":"內容 行銷 外包","terms":["內容 行銷 外包"],"target":1,"cases":[{"url":"https://www.threads.net/@brand/post/industry-003","text":"品牌想找內容行銷外包團隊,有推薦的嗎?","relevant":true},{"url":"https://www.threads.net/@brand/post/industry-004","text":"行銷人最近都在討論短影音趨勢。","relevant":false}]}, + {"id":"industry-ecommerce-logistics","intent":"電商 物流","terms":["電商 物流"],"target":1,"cases":[{"url":"https://www.threads.net/@seller/post/industry-005","text":"電商物流常遇到超商退貨,大家怎麼處理?","relevant":true},{"url":"https://www.threads.net/@seller/post/industry-006","text":"最近想換一台適合拍商品照的相機。","relevant":false}]}, + {"id":"industry-restaurant-staff","intent":"餐廳 外場 招募","terms":["餐廳 外場 招募"],"target":1,"cases":[{"url":"https://www.threads.net/@restaurant/post/industry-007","text":"餐廳正在招募外場夥伴,想問排班和薪資行情。","relevant":true},{"url":"https://www.threads.net/@restaurant/post/industry-008","text":"最近去吃了一家很棒的餐廳,想分享菜色。","relevant":false}]}, + {"id":"intent-baby-nanny","intent":"台北 保母","terms":["台北 保母"],"target":1,"cases":[{"url":"https://www.threads.net/@parent/post/intent-001","text":"台北找保母有推薦嗎?希望有耐心照顧幼兒。","relevant":true},{"url":"https://www.threads.net/@parent/post/intent-002","text":"小孩最近開始上學,正在準備新的文具。","relevant":false}]}, + {"id":"intent-pet-cafe","intent":"寵物 友善 餐廳","terms":["寵物 友善 餐廳"],"target":1,"cases":[{"url":"https://www.threads.net/@pet/post/intent-003","text":"台中有寵物友善餐廳可以帶狗狗一起吃飯嗎?","relevant":true},{"url":"https://www.threads.net/@pet/post/intent-004","text":"狗狗最近換飼料後胃口變好了。","relevant":false}]}, + {"id":"intent-fragrance-skincare","intent":"無香 保養","terms":["無香 保養"],"target":1,"cases":[{"url":"https://www.threads.net/@beauty/post/intent-005","text":"敏感肌想找無香保養品,大家有推薦嗎?","relevant":true},{"url":"https://www.threads.net/@beauty/post/intent-006","text":"最近在研究不同香水的木質調。","relevant":false}]}, + {"id":"intent-apartment-outlet","intent":"租屋 插座","terms":["租屋 插座"],"target":1,"cases":[{"url":"https://www.threads.net/@home/post/intent-007","text":"租屋處插座太少,大家都怎麼安全增加插座?","relevant":true},{"url":"https://www.threads.net/@home/post/intent-008","text":"最近想換租屋處的窗簾顏色。","relevant":false}]}, + {"id":"intent-language-tutor","intent":"日文 家教","terms":["日文 家教"],"target":1,"cases":[{"url":"https://www.threads.net/@study/post/intent-009","text":"想找日文家教準備 N2,有適合遠距的老師嗎?","relevant":true},{"url":"https://www.threads.net/@study/post/intent-010","text":"最近在看日本旅遊的交通攻略。","relevant":false}]}, + {"id":"local-coffee-shop","intent":"咖啡店","terms":["咖啡店"],"target":1,"cases":[{"url":"https://www.threads.net/@local/post/local-001","text":"這間咖啡店有插座又安靜,很適合工作。","relevant":true},{"url":"https://www.threads.net/@local/post/local-002","text":"週末想去公園散步,哪裡風景比較好?","relevant":false}]}, + {"id":"local-hiking-trail","intent":"台北 登山步道","terms":["台北 登山步道"],"target":1,"cases":[{"url":"https://www.threads.net/@hiker/post/local-003","text":"台北登山步道有適合新手、風景又好的路線嗎?","relevant":true},{"url":"https://www.threads.net/@hiker/post/local-004","text":"最近買了新的登山鞋,還在找襪子搭配。","relevant":false}]}, + {"id":"travel-kyoto","intent":"京都 賞楓","terms":["京 賞楓"],"target":1,"cases":[{"url":"https://www.threads.net/@trip/post/travel-001","text":"京都賞楓住哪一區比較方便,想避開人潮。","relevant":true},{"url":"https://www.threads.net/@trip/post/travel-002","text":"日本機票最近好像變便宜了。","relevant":false}]}, + {"id":"camping-family","intent":"親子 露營","terms":["親子 露營"],"target":1,"cases":[{"url":"https://www.threads.net/@camp/post/travel-003","text":"親子露營第一次帶小孩,有哪些裝備不能少?","relevant":true},{"url":"https://www.threads.net/@camp/post/travel-004","text":"最近在家整理夏天衣服和睡袋。","relevant":false}]}, + {"id":"used-books","intent":"二手書 台北","terms":["二手書 台北"],"target":1,"cases":[{"url":"https://www.threads.net/@reader/post/local-005","text":"台北哪裡有整理得不錯的二手書店?","relevant":true},{"url":"https://www.threads.net/@reader/post/local-006","text":"最近讀完一本小說,正在找下一本。","relevant":false}]}, + {"id":"home-baking","intent":"家庭 烘焙","terms":["家庭 烘焙"],"target":1,"cases":[{"url":"https://www.threads.net/@baker/post/home-001","text":"家庭烘焙新手想做酸種麵包,有推薦的入門方法嗎?","relevant":true},{"url":"https://www.threads.net/@baker/post/home-002","text":"最近在市場買到很好吃的麵包。","relevant":false}]}, + {"id":"cycling-helmet","intent":"公路車 安全帽","terms":["公路車 安全帽"],"target":1,"cases":[{"url":"https://www.threads.net/@cycle/post/sport-001","text":"公路車安全帽怎麼挑尺寸和通風?","relevant":true},{"url":"https://www.threads.net/@cycle/post/sport-002","text":"最近開始騎腳踏車通勤,想規劃路線。","relevant":false}]}, + {"id":"photography-lens","intent":"人像 鏡頭","terms":["人像 鏡頭"],"target":1,"cases":[{"url":"https://www.threads.net/@photo/post/gear-001","text":"拍人像適合用哪顆鏡頭,想要自然的散景?","relevant":true},{"url":"https://www.threads.net/@photo/post/gear-002","text":"最近在練習用手機拍街景。","relevant":false}]}, + {"id":"gardening-balcony","intent":"陽台 種菜","terms":["陽台 種菜"],"target":1,"cases":[{"url":"https://www.threads.net/@garden/post/home-003","text":"陽台種菜需要多少日照,九層塔怎麼照顧?","relevant":true},{"url":"https://www.threads.net/@garden/post/home-004","text":"最近買了幾盆室內觀葉植物。","relevant":false}]}, + {"id":"mental-health-sleep","intent":"睡眠 焦慮","terms":["睡眠 焦慮"],"target":1,"cases":[{"url":"https://www.threads.net/@wellness/post/health-001","text":"焦慮讓睡眠變差,有哪些不靠藥物的改善方法?","relevant":true},{"url":"https://www.threads.net/@wellness/post/health-002","text":"最近天氣熱,晚上很難保持房間涼爽。","relevant":false}]} +] diff --git a/apps/backend/internal/module/scout/usecase/topic_quality_gate_test.go b/apps/backend/internal/module/scout/usecase/topic_quality_gate_test.go new file mode 100644 index 0000000..718d20e --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/topic_quality_gate_test.go @@ -0,0 +1,78 @@ +package usecase + +import ( + "fmt" + "sort" + "testing" + + "apps/backend/internal/module/scout/domain" + "github.com/stretchr/testify/require" +) + +type qualityGateHit struct { + relevant bool + score int + order int +} + +// TestTopicQualityGate is deliberately deterministic: it exercises the same +// hard candidate gate used by the worker, while the fixture supplies the +// human-labelled retrieval set. No external provider or network is involved. +func TestTopicQualityGate(t *testing.T) { + fixtures := loadQualityFixtures(t) + require.GreaterOrEqual(t, len(fixtures), 30, "quality gate requires at least 30 independent topic groups") + + var precisionSum float64 + targetHits := 0 + diagnostics := make([]string, 0) + for _, fixture := range fixtures { + hits := make([]qualityGateHit, 0, len(fixture.Cases)) + for i, candidate := range fixture.Cases { + evaluator := NewCandidateEvaluator(&domain.RunBrief{ + Mode: domain.ModeActivity, Intent: fixture.Intent, ScanTerms: fixture.Terms, + }) + got := evaluator.Evaluate(ThreadSearchResult{URL: candidate.URL, Snippet: candidate.Text}) + if got.Decision == CandidateEligible { + hits = append(hits, qualityGateHit{relevant: candidate.Relevant, score: got.Score, order: i}) + } + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return hits[i].order < hits[j].order + }) + top := hits + if len(top) > 10 { + top = top[:10] + } + relevantTop := 0 + for _, hit := range top { + if hit.relevant { + relevantTop++ + } + } + precision := 0.0 + if len(top) > 0 { + precision = float64(relevantTop) / float64(len(top)) + } + target := fixture.Target + if target <= 0 { + target = 1 + } + if len(hits) >= target { + targetHits++ + } + precisionSum += precision + t.Logf("case=%s precision@10=%.2f target=%d eligible=%d relevant=%d", fixture.ID, precision, target, len(hits), relevantTop) + if precision < 0.8 || len(hits) < target { + diagnostics = append(diagnostics, fmt.Sprintf("case=%s precision=%.2f target=%d eligible=%d", fixture.ID, precision, target, len(hits))) + } + } + + averagePrecision := precisionSum / float64(len(fixtures)) + targetRate := float64(targetHits) / float64(len(fixtures)) + require.GreaterOrEqual(t, averagePrecision, 0.8, "precision@10 average; diagnostics=%v", diagnostics) + require.GreaterOrEqual(t, targetRate, 0.9, "eligible target rate; diagnostics=%v", diagnostics) + require.Empty(t, diagnostics, "quality gate failures") +} diff --git a/apps/backend/internal/module/scout/usecase/topic_signature.go b/apps/backend/internal/module/scout/usecase/topic_signature.go new file mode 100644 index 0000000..8a2bbc5 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/topic_signature.go @@ -0,0 +1,443 @@ +package usecase + +import ( + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +// TopicConcept is one necessary concept group in a topic. A candidate must +// match at least one core or approved alias from every group. +type TopicConcept struct { + Name string + Cores []string + Aliases []string +} + +// TopicSignature is the hard relevance contract for one scan. Search +// modifiers and conversation anchors are intentionally not concepts. +type TopicSignature struct { + Intent string + Concepts []TopicConcept + Anchors []string +} + +// TopicMatch is deliberately small: callers need the decision, matched +// concepts for diagnostics, and missing concepts for shortfall reasons. +type TopicMatch struct { + Matched bool + MatchedCores []string + MissingGroups []string + Reason string +} + +// NewTopicSignature builds a signature from the original intent and the +// approved search terms. Approved terms may expose an explicit alias (for +// example "鬼滅" for "鬼滅之刃") or split a compact intent into concepts (for +// example "台北 市集" for "台北週末市集"). +func NewTopicSignature(intent string, approvedTerms []string) TopicSignature { + intent = normalizeTopicTerm(intent) + sig := TopicSignature{Intent: intent, Anchors: append([]string(nil), topicAnchors...)} + intentParts := semanticTopicParts(intent) + naturalIntent := len(intentParts) == 1 && isNaturalTopicIntent(intent) + if naturalIntent { + if derived := deriveNaturalTopicParts(intent, approvedTerms); len(derived) > 0 { + intentParts = derived + } + } + approvedParts := make([]string, 0) + approvedTermParts := make([][]string, 0, len(approvedTerms)) + for _, term := range approvedTerms { + parts := semanticTopicParts(term) + if len(parts) == 0 { + continue + } + approvedTermParts = append(approvedTermParts, parts) + approvedParts = append(approvedParts, parts...) + } + approvedParts = uniqueTopicTerms(approvedParts) + + // A compact long phrase such as 台北週末市集 is often not present + // contiguously in a post. When approved terms explicitly split it, use the + // split concepts instead of making the unsplittable phrase mandatory. Named + // entities (鬼滅之刃) remain one concept because their approved form is + // still a single term. + splitApproved := !naturalIntent && len(intentParts) == 1 && len(approvedParts) > 1 && + !containsTopicTerm(approvedParts, intentParts[0]) + keepIntentParts := !splitApproved + if keepIntentParts { + for _, part := range intentParts { + sig.addConcept(part, "") + } + } + for _, termParts := range approvedTermParts { + // A multi-concept query ("後端 外包") is a conjunction, not an + // alias declaration. Treating each token as an alias would let a post + // mentioning only "後端" pass a signature that requires "後端工程師". + explicitAlias := len(termParts) == 1 + for _, part := range termParts { + if isTopicAnchor(part) { + continue + } + matched := false + for i := range sig.Concepts { + concept := &sig.Concepts[i] + for _, core := range concept.Cores { + if topicEquivalent(part, core) { + matched = true + continue + } + // Work-intent vocabulary is a bounded synonym group. A + // user asking for 接案 should still see a post that says + // 外包, but this never turns arbitrary query fragments into + // aliases for a role or named entity. + if isWorkTopicTerm(core) && isWorkTopicTerm(part) { + concept.addAlias(part) + matched = true + continue + } + // Only a single-token approved term explicitly grants an + // alias. Never infer aliases from a conjunction's fragments. + if explicitAlias && topicContains(core, part) && topicRuneCount(part) >= 2 { + concept.addAlias(part) + matched = true + } + } + } + if !matched && splitApproved { + sig.addConcept(part, "") + } + } + } + // No usable concept means the input contained only generic anchors. It is + // safer to reject every candidate than to turn "推薦" into a topic. + return sig +} + +// BuildTopicSignature is kept as a descriptive alias for callers that prefer +// a builder-style name. +func BuildTopicSignature(intent string, approvedTerms []string) TopicSignature { + return NewTopicSignature(intent, approvedTerms) +} + +func (s *TopicSignature) addConcept(core, alias string) { + core = normalizeTopicTerm(core) + if core == "" || isTopicAnchor(core) { + return + } + for i := range s.Concepts { + if topicEquivalent(s.Concepts[i].Name, core) { + if alias != "" { + s.Concepts[i].addAlias(alias) + } + return + } + } + c := TopicConcept{Name: core, Cores: []string{core}} + if alias != "" { + c.addAlias(alias) + } + s.Concepts = append(s.Concepts, c) +} + +func (c *TopicConcept) addAlias(alias string) { + alias = normalizeTopicTerm(alias) + if alias == "" || isTopicAnchor(alias) || topicEquivalent(alias, c.Name) { + return + } + for _, existing := range c.Aliases { + if topicEquivalent(existing, alias) { + return + } + } + c.Aliases = append(c.Aliases, alias) +} + +// Match applies the all-concept hard gate. Matching is case-insensitive and +// whitespace-insensitive, which handles CJK and Latin terms consistently. +func (s TopicSignature) Match(text string) TopicMatch { + body := normalizeTopicText(text) + match := TopicMatch{Matched: len(s.Concepts) > 0} + for _, concept := range s.Concepts { + matched := "" + candidates := append(append([]string(nil), concept.Cores...), concept.Aliases...) + for _, candidate := range candidates { + candidate = normalizeTopicText(candidate) + if candidate != "" && strings.Contains(body, candidate) { + matched = candidate + break + } + } + if matched == "" { + match.Matched = false + match.MissingGroups = append(match.MissingGroups, concept.Name) + continue + } + match.MatchedCores = append(match.MatchedCores, matched) + } + if len(match.MatchedCores) > 0 { + match.Reason = "matched core: " + strings.Join(match.MatchedCores, ", ") + } + if len(match.MissingGroups) > 0 { + if match.Reason != "" { + match.Reason += "; " + } + match.Reason += "missing core: " + strings.Join(match.MissingGroups, ", ") + } + return match +} + +func (s TopicSignature) Matches(text string) bool { return s.Match(text).Matched } + +var topicAnchors = []string{ + "求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論", + "有人知道", "請問", "請問一下", "求助", "有沒有", "有沒有人", "有人也", + "熱門", "最新", "近期", +} + +func isTopicAnchor(term string) bool { + term = normalizeTopicText(term) + for _, anchor := range topicAnchors { + if term == normalizeTopicText(anchor) { + return true + } + } + return false +} + +func semanticTopicParts(raw string) []string { + raw = normalizeTopicTerm(raw) + if raw == "" { + return nil + } + fields := strings.FieldsFunc(raw, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune(",,、/|·。!?!?::;;()()【】[]", r) + }) + parts := make([]string, 0, len(fields)) + for _, field := range fields { + field = normalizeTopicTerm(field) + if field == "" || isTopicAnchor(field) || isTopicModifier(field) { + continue + } + parts = append(parts, field) + } + return uniqueTopicTerms(parts) +} + +// Natural activity input is often a sentence ("想找後端工程師接案"), while +// approved search terms are short variants. Treating that whole sentence as +// one mandatory contiguous core makes every real post fail the gate. Derive +// concepts from the approved terms instead, but keep the gate conjunctive. +func isNaturalTopicIntent(intent string) bool { + if len(splitTitleCores(intent)) > 0 { + return false + } + if topicRuneCount(intent) > 8 { + return true + } + body := normalizeTopicText(intent) + for _, marker := range naturalSentenceMarkers { + if strings.Contains(body, normalizeTopicText(marker)) { + return true + } + } + for _, marker := range activityWorkMarkers { + if strings.Contains(body, normalizeTopicText(marker)) { + return true + } + } + return false +} + +var naturalTopicFillers = []string{ + "想找", "想看", "想問", "我想", "我要", "需要", "可以", "有沒有", "有人", + "適合", "什麼", "怎麼", "如何", "最近", "換季", "週末", "這週", "本週", + "真的", "求推薦", "推薦", "分享", "心得", "活動", "討論", "請問", "哪裡", "哪家", "附近", "有", "找", "拍", "看看", +} + +var naturalSentenceMarkers = []string{ + "想找", "想看", "想問", "我想", "我要", "需要", "可以", "有沒有", "有人", + "適合", "什麼", "怎麼", "如何", "最近", "換季", "真的", "求推薦", "推薦", "分享", "心得", "活動", "討論", "請問", "哪裡", "哪家", "附近", "有", "找", "拍", "看看", +} + +func deriveNaturalTopicParts(intent string, approvedTerms []string) []string { + body := normalizeTopicText(intent) + type candidate struct { + term string + count int + } + candidates := make([]candidate, 0, 8) + index := make(map[string]int) + add := func(raw string) { + raw = cleanNaturalTopicPart(raw) + key := normalizeTopicText(raw) + if key == "" || topicRuneCount(raw) < 2 || !strings.Contains(body, key) { + return + } + if i, ok := index[key]; ok { + candidates[i].count++ + return + } + index[key] = len(candidates) + candidates = append(candidates, candidate{term: raw, count: 1}) + } + for _, region := range activityRegions { + if strings.Contains(body, normalizeTopicText(region)) { + add(region) + if i, ok := index[normalizeTopicText(region)]; ok { + candidates[i].count = 2 + } + } + } + // Preserve explicit work/role vocabulary from the user's sentence even if + // they unchecked the corresponding generated query variant. + knownIntentTerms := append([]string{}, activityWorkMarkers...) + knownIntentTerms = append(knownIntentTerms, activityRoleSpecialties...) + knownIntentTerms = append(knownIntentTerms, "工程師") + for _, term := range knownIntentTerms { + if strings.Contains(body, normalizeTopicText(term)) { + add(term) + if i, ok := index[normalizeTopicText(term)]; ok { + candidates[i].count = 2 + } + } + } + for _, term := range approvedTerms { + for _, part := range semanticTopicParts(term) { + add(part) + } + } + + // Prefer compact, repeated concepts over one-off sliding-window fragments + // generated from a sentence. Known work vocabulary may legitimately occur + // in only one approved conjunction (for example 接案). + out := make([]string, 0, len(candidates)) + for _, c := range candidates { + if c.count < 2 && !isKnownNaturalTopicTerm(c.term) { + continue + } + out = append(out, c.term) + } + if len(out) == 0 { + for _, part := range semanticTopicParts(intent) { + if cleaned := cleanNaturalTopicPart(part); cleaned != "" { + out = append(out, cleaned) + } + } + } + return pruneContainedTopicParts(uniqueTopicTerms(out)) +} + +func cleanNaturalTopicPart(raw string) string { + raw = normalizeTopicTerm(raw) + for _, filler := range naturalTopicFillers { + raw = strings.ReplaceAll(raw, filler, "") + } + for _, modifier := range []string{"熱門", "最新", "近期"} { + raw = strings.ReplaceAll(raw, modifier, "") + } + return normalizeTopicTerm(raw) +} + +func isKnownNaturalTopicTerm(term string) bool { + term = normalizeTopicText(term) + if term == "工程師" { + return true + } + for _, group := range [][]string{activityWorkMarkers, activityRoleSpecialties, activityRegions} { + for _, known := range group { + if topicEquivalent(term, known) { + return true + } + } + } + return false +} + +func isWorkTopicTerm(term string) bool { + term = normalizeTopicText(term) + for _, known := range activityWorkMarkers { + if normalizeTopicText(known) == term { + return true + } + } + return false +} + +func pruneContainedTopicParts(parts []string) []string { + keep := make([]string, 0, len(parts)) + for _, part := range parts { + partKey := normalizeTopicText(part) + contained := false + for _, other := range parts { + otherKey := normalizeTopicText(other) + if otherKey == partKey || topicRuneCount(other) <= topicRuneCount(part) { + continue + } + if strings.Contains(otherKey, partKey) && !isKnownNaturalTopicTerm(part) { + contained = true + break + } + } + if !contained { + keep = append(keep, part) + } + } + return keep +} + +func isTopicModifier(term string) bool { + term = normalizeTopicText(term) + return term == "熱門" || term == "最新" || term == "近期" +} + +func normalizeTopicTerm(raw string) string { + raw = strings.ReplaceAll(raw, "\u3000", " ") + return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ") +} + +func normalizeTopicText(raw string) string { + return strings.ReplaceAll(strings.ToLower(normalizeTopicTerm(raw)), " ", "") +} + +func uniqueTopicTerms(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, term := range in { + term = normalizeTopicTerm(term) + key := normalizeTopicText(term) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, term) + } + return out +} + +func containsTopicTerm(terms []string, want string) bool { + want = normalizeTopicText(want) + for _, term := range terms { + if normalizeTopicText(term) == want { + return true + } + } + return false +} + +func topicEquivalent(a, b string) bool { return normalizeTopicText(a) == normalizeTopicText(b) } + +func topicContains(container, part string) bool { + return strings.Contains(normalizeTopicText(container), normalizeTopicText(part)) +} + +func topicRuneCount(term string) int { return utf8.RuneCountInString(normalizeTopicText(term)) } + +// Keep deterministic output if a caller serializes concepts for diagnostics. +func (s TopicSignature) SortConcepts() { + sort.SliceStable(s.Concepts, func(i, j int) bool { return s.Concepts[i].Name < s.Concepts[j].Name }) +} diff --git a/apps/backend/internal/module/scout/usecase/topic_signature_test.go b/apps/backend/internal/module/scout/usecase/topic_signature_test.go new file mode 100644 index 0000000..c6acf00 --- /dev/null +++ b/apps/backend/internal/module/scout/usecase/topic_signature_test.go @@ -0,0 +1,121 @@ +package usecase + +import ( + "strings" + "testing" +) + +func TestTopicSignatureExcludesAnchorsAndKeepsNamedEntity(t *testing.T) { + sig := NewTopicSignature("鬼滅之刃", []string{"鬼滅之刃 最新", "鬼滅 分享"}) + if len(sig.Concepts) != 1 { + t.Fatalf("concepts=%+v, want one named-entity concept", sig.Concepts) + } + if !sig.Matches("鬼滅之刃最新一集大家都在討論") { + t.Fatal("named entity should match") + } + if sig.Matches("最近想看動畫,有推薦熱門作品嗎") { + t.Fatal("generic recommendation must not bypass entity gate") + } + if len(sig.Concepts[0].Aliases) != 1 || sig.Concepts[0].Aliases[0] != "鬼滅" { + t.Fatalf("aliases=%+v, want explicit 鬼滅 alias", sig.Concepts[0].Aliases) + } +} + +func TestTopicSignatureRequiresEveryConceptGroup(t *testing.T) { + sig := BuildTopicSignature("外包 後端工程師", []string{"後端 外包", "後端工程師 推薦"}) + if len(sig.Concepts) != 2 { + t.Fatalf("concepts=%+v, want outsourcing and backend groups", sig.Concepts) + } + if !sig.Matches("想找後端工程師做外包合作,有推薦的接案夥伴嗎") { + t.Fatal("candidate with both concepts should match") + } + missing := sig.Match("請問一般工程師轉職需要準備什麼") + if missing.Matched || len(missing.MissingGroups) != 2 { + t.Fatalf("missing concepts were not rejected: %+v", missing) + } + if sig.Matches("正在找後端工程師轉職") { + t.Fatal("explicitly missing outsourcing concept should fail") + } +} + +func TestTopicSignatureDoesNotTurnConjunctionFragmentsIntoAliases(t *testing.T) { + sig := NewTopicSignature("外包 後端工程師", []string{"後端 外包"}) + if sig.Matches("有人分享後端外包合作經驗") { + t.Fatal("a conjunction fragment must not alias 後端工程師") + } + if !sig.Matches("有人分享後端工程師外包合作經驗") { + t.Fatal("full backend-engineer and outsourcing concepts should match") + } +} + +func TestTopicSignatureUsesApprovedSplitAndLatinCase(t *testing.T) { + sig := NewTopicSignature("台北週末市集", []string{"台北 市集", "週末 市集"}) + if len(sig.Concepts) != 3 { + t.Fatalf("split concepts=%+v, want city/weekend/market", sig.Concepts) + } + if !sig.Matches("這週末台北有沒有適合拍照的市集?") { + t.Fatal("approved split concepts should match natural sentence") + } + if sig.Matches("台北週末天氣好像會下雨") { + t.Fatal("weather post missing market concept") + } + latin := NewTopicSignature("OpenAI API", []string{"openai api"}) + if !latin.Matches("請問 OPENAI API 要怎麼申請") { + t.Fatal("latin topic matching should ignore case") + } +} + +func TestTopicSignatureDecomposesNaturalIntentWithoutRequiringWholeSentence(t *testing.T) { + sig := NewTopicSignature("想找台北週末市集可以拍什麼", []string{"台北 市集", "週末 市集"}) + if len(sig.Concepts) != 2 { + t.Fatalf("natural concepts=%+v, want location and topic", sig.Concepts) + } + if !sig.Matches("台北這週有市集活動,適合拍照") { + t.Fatal("natural sentence should match its meaningful concepts") + } + if sig.Matches("台北這週有咖啡活動,適合拍照") { + t.Fatal("different topic should remain irrelevant") + } +} + +func TestTopicSignatureKeepsNaturalWorkConceptsButDropsGeneratedVariants(t *testing.T) { + sig := NewTopicSignature("找後端工程師接案", []string{"後端 外包", "後端 接案", "後端 工程師"}) + if len(sig.Concepts) != 3 { + t.Fatalf("work concepts=%+v, want backend/engineer/freelance", sig.Concepts) + } + if !sig.Matches("正在找後端工程師接案夥伴") { + t.Fatal("natural work intent should match") + } + if !sig.Matches("正在找後端工程師做外包合作") { + t.Fatal("接案 and 外包 should be bounded work-intent synonyms") + } + if sig.Matches("正在找前端工程師接案夥伴") { + t.Fatal("different specialty should remain irrelevant") + } +} + +func TestTopicSignatureExtractsTopicFromQuestionLikeIntent(t *testing.T) { + sig := NewTopicSignature("週末有插座", []string{"插座 求推薦", "咖啡 插座"}) + if len(sig.Concepts) != 1 || !sig.Matches("請問這間咖啡店週末有插座嗎?") { + t.Fatalf("question-like intent signature=%+v", sig) + } +} + +func TestTopicSignatureExtractsNamedEntityAfterRecommendationPhrase(t *testing.T) { + sig := NewTopicSignature("推薦鬼滅之刃", []string{"鬼滅之刃 最新", "鬼滅 分享"}) + if len(sig.Concepts) != 1 || !sig.Matches("最近在討論鬼滅之刃最新一集") { + t.Fatalf("recommendation phrase signature=%+v", sig) + } +} + +func TestTopicSignatureReasonNamesMatchedCores(t *testing.T) { + sig := NewTopicSignature("外包 後端", nil) + match := sig.Match("後端工程師正在找外包合作") + if !match.Matched || !strings.Contains(match.Reason, "外包") || !strings.Contains(match.Reason, "後端") { + t.Fatalf("reason=%q does not explain matched cores", match.Reason) + } + anchors := NewTopicSignature("推薦 分享", nil) + if len(anchors.Concepts) != 0 || anchors.Matches("推薦分享") { + t.Fatal("anchor-only intent must not become a topic") + } +} diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go index 4c9c4c9..3c3f523 100644 --- a/apps/backend/internal/response/response.go +++ b/apps/backend/internal/response/response.go @@ -200,6 +200,8 @@ func mapError(err error) (int, Envelope) { return http.StatusBadRequest, Envelope{Code: 400061, Message: "crawler session required when dev_mode enabled"} case errors.Is(err, scoutDomain.ErrHasProducts): return http.StatusConflict, Envelope{Code: 409030, Message: "brand has products; remove products first"} + case errors.Is(err, scoutDomain.ErrIllegalRunStatus): + return http.StatusConflict, Envelope{Code: 409031, Message: "scout run is not in a removable state"} case errors.Is(err, growthDomain.ErrNotFound): return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} case errors.Is(err, growthDomain.ErrForbidden): @@ -218,6 +220,8 @@ func mapError(err error) (int, Envelope) { return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} case errors.Is(err, radarDomain.ErrForbidden), errors.Is(err, crmDomain.ErrForbidden): return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} + case errors.Is(err, radarDomain.ErrConflict): + return http.StatusConflict, Envelope{Code: 409002, Message: err.Error()} case errors.Is(err, radarDomain.ErrValidation), errors.Is(err, crmDomain.ErrValidation): return http.StatusBadRequest, Envelope{Code: 400100, Message: cleanBizMessage(err.Error())} default: diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index 277190e..e674a86 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -233,6 +233,8 @@ func NewServiceContext(c config.Config) *ServiceContext { } // 關鍵字建議拿既有海巡痛點詞當素材,不另建第二套關鍵字引擎。 radarSvc.PainTerms = &radarPainTermBridge{Scout: scoutSvc} + radarSvc.ProductSource = &radarProductContextBridge{Scout: scoutSvc} + scoutSvc.RadarLifecycle = radarSvc crmSvc := crmUC.New(crmRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) crmSvc.Growth = &crmGrowthBridge{Growth: growthSvc} @@ -294,6 +296,23 @@ type devModeFromMembers struct { type scoutReplyQueue struct{ Studio *studioUC.Service } +type radarProductContextBridge struct{ Scout *scoutUC.Service } + +func (b *radarProductContextBridge) GetBrand(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) { + brand, err := b.Scout.GetBrand(ctx, ownerUID, id) + if err != nil { + return nil, err + } + return &radarUC.ProductBrand{ID: brand.ID, OwnerUID: brand.OwnerUID, DisplayName: brand.DisplayName, TargetAudience: brand.TargetAudience, UpdatedAt: brand.UpdatedAt}, nil +} +func (b *radarProductContextBridge) GetProduct(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) { + product, err := b.Scout.GetProduct(ctx, ownerUID, id) + if err != nil { + return nil, err + } + return &radarUC.ProductCatalogProduct{ID: product.ID, OwnerUID: product.OwnerUID, BrandID: product.BrandID, Label: product.Label, ProductContext: product.ProductContext, PainPoints: product.PainPoints, MatchTags: product.MatchTags, ProviderCapabilityTerms: product.ProviderCapabilityTerms, ProviderExcludeTerms: product.ProviderExcludeTerms, UpdatedAt: product.UpdatedAt}, nil +} + func (q *scoutReplyQueue) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (string, error) { bundle, err := q.Studio.QueueExternalReply(ctx, ownerUID, accountID, replyToMediaID, text, title) if err != nil { diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go index 92870f9..82b2111 100644 --- a/apps/backend/internal/types/m5_convert.go +++ b/apps/backend/internal/types/m5_convert.go @@ -116,7 +116,7 @@ func ScoutPostFromDomain(p *scoutDomain.Post) ScoutPostPublic { return ScoutPostPublic{} } return ScoutPostPublic{ - Id: p.ID, BrandId: p.BrandID, Author: p.Author, Text: p.Text, SearchTag: p.SearchTag, + Id: p.ID, RunId: p.RunID, BrandId: p.BrandID, Author: p.Author, Text: p.Text, SearchTag: p.SearchTag, Opportunity: p.Opportunity, OutreachStatus: p.OutreachStatus, DraftText: p.DraftText, Score: p.Score, MatchedProductId: p.MatchedProductID, MatchedProductLabel: p.MatchedProductLabel, MatchReason: p.MatchReason, ScoutMode: p.ScoutMode, IntentSnippet: p.IntentSnippet, @@ -126,6 +126,26 @@ func ScoutPostFromDomain(p *scoutDomain.Post) ScoutPostPublic { } } +func ScoutRunFromDomain(r *scoutDomain.Run) ScoutRunPublic { + if r == nil { + return ScoutRunPublic{} + } + reasons := append([]string(nil), r.ShortfallReasons...) + return ScoutRunPublic{ + Id: r.ID, JobId: r.JobID, ThemeKey: r.ThemeKey, ThemeLabel: r.ThemeLabel, + Intent: r.Intent, Mode: r.Mode, BrandId: r.BrandID, TargetCount: r.TargetCount, + Status: r.Status, SearchedCount: r.SearchedCount, DuplicateCount: r.DuplicateCount, + IrrelevantCount: r.IrrelevantCount, EligibleCount: r.EligibleCount, + PendingCount: r.PendingCount, ShortfallCount: r.ShortfallCount, + ShortfallReasons: reasons, CreatedAt: r.CreatedAt, StartedAt: r.StartedAt, + CompletedAt: r.CompletedAt, Error: r.Error, + } +} + +func PaginationFromDomain(p scoutDomain.PageInfo) Pagination { + return Pagination{Page: p.Page, PageSize: p.PageSize, Total: p.Total, TotalPages: p.TotalPages} +} + func HomeworkFromDomain(h *scoutDomain.Homework) ScoutHomeworkPublic { if h == nil { return ScoutHomeworkPublic{} diff --git a/apps/backend/internal/types/m5_convert_test.go b/apps/backend/internal/types/m5_convert_test.go new file mode 100644 index 0000000..7f702ab --- /dev/null +++ b/apps/backend/internal/types/m5_convert_test.go @@ -0,0 +1,36 @@ +package types + +import ( + "testing" + + scoutDomain "apps/backend/internal/module/scout/domain" +) + +func TestScoutRunFromDomainPreservesCountersAndUnixNano(t *testing.T) { + created := int64(1_700_000_000_123_456_789) + run := &scoutDomain.Run{ + ID: "run-1", JobID: "job-1", ThemeKey: "theme|one", ThemeLabel: "One", + Intent: "one", Mode: scoutDomain.ModeTheme, BrandID: "brand-1", TargetCount: 8, + Status: scoutDomain.RunSucceeded, SearchedCount: 22, DuplicateCount: 4, + IrrelevantCount: 3, EligibleCount: 8, PendingCount: 5, ShortfallCount: 0, + ShortfallReasons: []string{scoutDomain.ShortfallDuplicateExhausted}, CreatedAt: created, + StartedAt: created + 1, CompletedAt: created + 2, Error: "", + } + out := ScoutRunFromDomain(run) + if out.Id != run.ID || out.JobId != run.JobID || out.CreatedAt != created || out.StartedAt != created+1 || out.CompletedAt != created+2 { + t.Fatalf("run identity/time changed: %+v", out) + } + if out.SearchedCount != 22 || out.DuplicateCount != 4 || out.IrrelevantCount != 3 || out.EligibleCount != 8 || out.PendingCount != 5 { + t.Fatalf("run counters changed: %+v", out) + } + if len(out.ShortfallReasons) != 1 || out.ShortfallReasons[0] != scoutDomain.ShortfallDuplicateExhausted { + t.Fatalf("shortfall reasons changed: %+v", out.ShortfallReasons) + } +} + +func TestScoutPostFromDomainIncludesRunID(t *testing.T) { + out := ScoutPostFromDomain(&scoutDomain.Post{ID: "post-1", RunID: "run-1", CreatedAt: 1}) + if out.RunId != "run-1" { + t.Fatalf("run id not mapped: %+v", out) + } +} diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index 90067a7..4fef013 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -127,6 +127,12 @@ type ApplyInviteCodeReq struct { Code string `json:"code"` } +type AssignProductReq struct { + Id string `path:"id"` + BrandId string `json:"brand_id"` + ProductId string `json:"product_id"` +} + type AuthBindReq struct { LoginId string `json:"login_id"` Platform string `json:"platform"` @@ -454,6 +460,27 @@ type ContactTouchPublic struct { CreatedAt int64 `json:"created_at"` } +type CostPreviewPublic struct { + PreviewId string `json:"preview_id"` + Action string `json:"action"` + KeyMode string `json:"key_mode"` // platform | byok + FixedCredits int `json:"fixed_credits"` + MinCredits int `json:"min_credits"` + MaxCredits int `json:"max_credits"` + SearchCalls int `json:"search_calls"` + MaxAiCandidates int `json:"max_ai_candidates"` + RemainingCredits int `json:"remaining_credits"` + EstimateBasis string `json:"estimate_basis"` + ExpiresAt int64 `json:"expires_at"` +} + +type CostPreviewReq struct { + Action string `form:"action"` // sweep | explore | demand_map_enrich | reply + WatchId string `form:"watch_id,optional"` + ProductId string `form:"product_id,optional"` + CandidateLimit int `form:"candidate_limit,optional"` +} + type CreateContactNoteReq struct { Id string `path:"id"` Body string `json:"body"` @@ -482,6 +509,8 @@ type CreateWatchReq struct { ExcludeTerms []string `json:"exclude_terms,optional"` Regions []string `json:"regions,optional"` Enabled bool `json:"enabled,optional"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` } type CreateWorkspaceReq struct { @@ -517,6 +546,35 @@ type DeleteCrmConversionReq struct { Id string `path:"id"` } +type DemandMapPhrase struct { + Text string `json:"text"` + Kind string `json:"kind"` + BasisKind string `json:"basis_kind,optional"` + BasisText string `json:"basis_text,optional"` + Origin string `json:"origin"` // product | user | ai + Enabled bool `json:"enabled"` +} + +type DemandMapProductReq struct { + ProductId string `path:"productId"` +} + +type DemandMapPublic struct { + ProductId string `json:"product_id"` + DemandInputVersion string `json:"demand_input_version"` + MapVersion int64 `json:"map_version"` + State string `json:"state"` // ready | incomplete | stale + PainPhrases []DemandMapPhrase `json:"pain_phrases"` + ScenarioPhrases []DemandMapPhrase `json:"scenario_phrases"` + DesiredOutcomes []DemandMapPhrase `json:"desired_outcomes"` + SolutionSignals []DemandMapPhrase `json:"solution_signals"` + ExclusionSignals []DemandMapPhrase `json:"exclusion_signals"` + SourceBasis []string `json:"source_basis"` + CustomPhrases []DemandMapPhrase `json:"custom_phrases"` + AiEnrichedAt int64 `json:"ai_enriched_at,optional"` + UpdatedAt int64 `json:"updated_at"` +} + type DismissOpportunityReq struct { Id string `path:"id"` Reason string `json:"reason,optional"` @@ -541,17 +599,40 @@ type DraftReviewPublic struct { type Empty struct { } +type EnrichDemandMapReq struct { + ProductId string `path:"productId"` + PreviewId string `json:"preview_id"` + CreditCeiling int `json:"credit_ceiling"` + ExpectedMapVersion int64 `json:"expected_map_version"` +} + type ExploreOpportunitiesData struct { - SweepId string `json:"sweep_id"` - HitCount int `json:"hit_count"` - JudgedCount int `json:"judged_count"` - CreatedCount int `json:"created_count"` - TruncatedCount int `json:"truncated_count"` - CreditsUsed int `json:"credits_used"` + SweepId string `json:"sweep_id"` + HitCount int `json:"hit_count"` + JudgedCount int `json:"judged_count"` + CreatedCount int `json:"created_count"` + MatchedCount int `json:"matched_count"` + MergedCount int `json:"merged_count"` + TruncatedCount int `json:"truncated_count"` + CreditsUsed int `json:"credits_used"` + DedupedCount int `json:"deduped_count"` + PrefilterPassCount int `json:"prefilter_pass_count"` + PrefilterReviewCount int `json:"prefilter_review_count"` + PrefilterRejectedCount int `json:"prefilter_rejected_count"` + CachedJudgmentCount int `json:"cached_judgment_count"` + TombstoneMatchedCount int `json:"tombstone_matched_count"` + BudgetDeferredCount int `json:"budget_deferred_count"` + SweepStatus string `json:"status"` } type ExploreOpportunitiesReq struct { - Terms []string `json:"terms"` + Terms []string `json:"terms"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` + PreviewId string `json:"preview_id,optional"` + CreditCeiling int `json:"credit_ceiling,optional"` + DemandInputVersion string `json:"demand_input_version,optional"` + DemandMapVersion int64 `json:"demand_map_version,optional"` } type ExportReportData struct { @@ -659,7 +740,9 @@ type ImportOpportunitiesData struct { } type ImportOpportunitiesReq struct { - Items []ImportOpportunityItem `json:"items"` + Items []ImportOpportunityItem `json:"items"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` } type ImportOpportunityItem struct { @@ -962,6 +1045,7 @@ type ListCheckupsReq struct { type ListContactsReq struct { Page int `form:"page,default=1"` PageSize int `form:"pageSize,default=20"` + Query string `form:"query,optional"` Stage string `form:"stage,optional"` FollowUp string `form:"follow_up,optional"` Band string `form:"band,optional"` @@ -993,13 +1077,21 @@ type ListModelsReq struct { } type ListOpportunitiesReq struct { - Page int `form:"page,default=1"` - PageSize int `form:"pageSize,default=20"` - Band string `form:"band,optional"` - Status string `form:"status,optional"` - WatchId string `form:"watch_id,optional"` - From int64 `form:"from,optional"` - To int64 `form:"to,optional"` + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Band string `form:"band,optional"` + Status string `form:"status,optional"` + WatchId string `form:"watch_id,optional"` + BrandId string `form:"brand_id,optional"` + ProductId string `form:"product_id,optional"` + FitBand string `form:"fit_band,optional"` + MatchState string `form:"match_state,optional"` + ReviewState string `form:"review_state,optional"` + TimeScope string `form:"time_scope,optional"` // today | 7d | all + PriorityBand string `form:"priority_band,optional"` // high | review | low + Sort string `form:"sort,optional"` + From int64 `form:"from,optional"` + To int64 `form:"to,optional"` } type ListOutcomesReq struct { @@ -1037,9 +1129,12 @@ type ListSweepsReq struct { } type ListWatchesReq struct { - Page int `form:"page,default=1"` - PageSize int `form:"pageSize,default=20"` - Status string `form:"status,optional"` + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=20"` + Status string `form:"status,optional"` + ContextMode string `form:"context_mode,optional"` + BrandId string `form:"brand_id,optional"` + ProductId string `form:"product_id,optional"` } type ListWorkspaceMembersReq struct { @@ -1202,29 +1297,53 @@ type OpportunityOverride struct { } type OpportunityPublic struct { - Id string `json:"id"` - WatchId string `json:"watch_id,optional"` - Source string `json:"source"` // threads | manual | scout_promote - SourceScoutPostId string `json:"source_scout_post_id,optional"` - ExternalId string `json:"external_id"` - Permalink string `json:"permalink"` - AuthorHandle string `json:"author_handle"` - Text string `json:"text"` - PostedAt int64 `json:"posted_at"` - Status string `json:"status"` // judging | qualified | rejected | accepted | dismissed - IntentScore int `json:"intent_score"` - IntentBand string `json:"intent_band"` // high | mid | low - Reasons []OpportunityReason `json:"reasons"` - RegionDetected string `json:"region_detected,optional"` - RegionMatch string `json:"region_match"` // match | mismatch | unknown - FreshnessHours int `json:"freshness_hours"` - MatchedService string `json:"matched_service,optional"` - MatchedTerms []string `json:"matched_terms"` - RejectReason string `json:"reject_reason,optional"` - Override *OpportunityOverride `json:"override,optional"` - ContactId string `json:"contact_id,optional"` - DefaultReply *ReplyVariantPublic `json:"default_reply,optional"` - CreatedAt int64 `json:"created_at"` + Id string `json:"id"` + WatchId string `json:"watch_id,optional"` + Source string `json:"source"` // threads | manual | scout_promote + SourceScoutPostId string `json:"source_scout_post_id,optional"` + ExternalId string `json:"external_id"` + Permalink string `json:"permalink"` + AuthorHandle string `json:"author_handle"` + Text string `json:"text"` + PostedAt int64 `json:"posted_at"` + Status string `json:"status"` // judging | qualified | rejected | accepted | dismissed + IntentScore int `json:"intent_score"` + IntentBand string `json:"intent_band"` // high | mid | low + Reasons []OpportunityReason `json:"reasons"` + RegionDetected string `json:"region_detected,optional"` + RegionMatch string `json:"region_match"` // match | mismatch | unknown + FreshnessHours int `json:"freshness_hours"` + MatchedService string `json:"matched_service,optional"` + MatchedTerms []string `json:"matched_terms"` + RejectReason string `json:"reject_reason,optional"` + Override *OpportunityOverride `json:"override,optional"` + ContactId string `json:"contact_id,optional"` + DefaultReply *ReplyVariantPublic `json:"default_reply,optional"` + PrimaryBrandId string `json:"primary_brand_id,optional"` + PrimaryProductId string `json:"primary_product_id,optional"` + PrimaryBrandName string `json:"primary_brand_name,optional"` + PrimaryProductLabel string `json:"primary_product_label,optional"` + PrimaryProductFitScore int `json:"primary_product_fit_score,optional"` + PrimaryProductFitBand string `json:"primary_product_fit_band,optional"` + PrimaryProductOverridden bool `json:"primary_product_overridden,optional"` + ProductMatches []ProductMatchPublic `json:"product_matches"` + ReviewState string `json:"review_state"` // pending | completed | removed + PreviousReviewState string `json:"previous_review_state,optional"` + RemovalReason string `json:"removal_reason,optional"` + RemovalNote string `json:"removal_note,optional"` + RemovedAt int64 `json:"removed_at,optional"` + RemovedBy int64 `json:"removed_by,optional"` + LastMatchedAt int64 `json:"last_matched_at,optional"` + PriorityScore int `json:"priority_score"` + PriorityBand string `json:"priority_band"` // high | review | low + PainFitScore int `json:"pain_fit_score,optional"` + DemandIntentScore int `json:"demand_intent_score,optional"` + EvidenceQualityScore int `json:"evidence_quality_score,optional"` + FreshnessScore int `json:"freshness_score,optional"` + DemandEvidence []string `json:"demand_evidence,optional"` + DemandInputVersion string `json:"demand_input_version,optional"` + DemandMapVersion int64 `json:"demand_map_version,optional"` + CreatedAt int64 `json:"created_at"` } type OpportunityReason struct { @@ -1617,6 +1736,14 @@ type PlaybookPublic struct { Mine bool `json:"mine,optional"` } +type ProductFitReasonPublic struct { + Dimension string `json:"dimension"` // pain | scenario | audience | capability + Score int `json:"score"` + Reason string `json:"reason"` + CandidateExcerpt string `json:"candidate_excerpt,optional"` + ProductBasis string `json:"product_basis,optional"` +} + type ProductIdPath struct { Id string `path:"id"` } @@ -1629,6 +1756,25 @@ type ProductListReq struct { BrandId string `form:"brand_id,optional"` } +type ProductMatchPublic struct { + BrandId string `json:"brand_id"` + ProductId string `json:"product_id"` + BrandNameSnapshot string `json:"brand_name_snapshot"` + ProductLabelSnapshot string `json:"product_label_snapshot"` + BrandUpdatedAt int64 `json:"brand_updated_at"` + ProductUpdatedAt int64 `json:"product_updated_at"` + ProductFitScore int `json:"product_fit_score"` + ProductFitBand string `json:"product_fit_band"` // strong | possible | weak + Eligible bool `json:"eligible"` + Excluded bool `json:"excluded"` + ExcludeReason string `json:"exclude_reason,optional"` + Reasons []ProductFitReasonPublic `json:"reasons"` + Risks []string `json:"risks"` + WatchIds []string `json:"watch_ids"` + MatchedTerms []string `json:"matched_terms"` + MatchedAt int64 `json:"matched_at"` +} + type ProductPublic struct { Id string `json:"id"` BrandId string `json:"brand_id"` @@ -1671,18 +1817,35 @@ type PublishPlaybookReq struct { } type RadarSweepPublic struct { - Id string `json:"id"` - WatchId string `json:"watch_id"` - JobId string `json:"job_id,optional"` - Path string `json:"path"` // api | crawler - HitCount int `json:"hit_count"` - JudgedCount int `json:"judged_count"` - CreatedCount int `json:"created_count"` - TruncatedCount int `json:"truncated_count"` - FailedReason string `json:"failed_reason,optional"` - CreditsUsed int `json:"credits_used"` - StartedAt int64 `json:"started_at"` - EndedAt int64 `json:"ended_at,optional"` + Id string `json:"id"` + WatchId string `json:"watch_id"` + JobId string `json:"job_id,optional"` + Path string `json:"path"` // api | crawler + HitCount int `json:"hit_count"` + JudgedCount int `json:"judged_count"` + CreatedCount int `json:"created_count"` + TruncatedCount int `json:"truncated_count"` + MatchEvaluatedCount int `json:"match_evaluated_count"` + MatchMergedCount int `json:"match_merged_count"` + FitRejectedCount int `json:"fit_rejected_count"` + FailedReason string `json:"failed_reason,optional"` + CreditsUsed int `json:"credits_used"` + StartedAt int64 `json:"started_at"` + EndedAt int64 `json:"ended_at,optional"` + DemandInputVersion string `json:"demand_input_version,optional"` + DemandMapVersion int64 `json:"demand_map_version,optional"` + DedupedCount int `json:"deduped_count"` + PrefilterPassCount int `json:"prefilter_pass_count"` + PrefilterReviewCount int `json:"prefilter_review_count"` + PrefilterRejectedCount int `json:"prefilter_rejected_count"` + CachedJudgmentCount int `json:"cached_judgment_count"` + TombstoneMatchedCount int `json:"tombstone_matched_count"` + BudgetDeferredCount int `json:"budget_deferred_count"` + SweepStatus string `json:"status"` // complete | partial_budget | blocked_budget | failed + CreditSearch int `json:"credit_search"` + CreditDemandMap int `json:"credit_demand_map"` + CreditJudge int `json:"credit_judge"` + CreditReply int `json:"credit_reply"` } type RadarTodayData struct { @@ -1696,6 +1859,12 @@ type RadarTodayData struct { EmptyHint string `json:"empty_hint,optional"` } +type RadarTodayReq struct { + BrandId string `form:"brand_id,optional"` + ProductId string `form:"product_id,optional"` + FitBand string `form:"fit_band,optional"` +} + type RadarTodayStats struct { Total int `json:"total"` High int `json:"high"` @@ -1704,15 +1873,22 @@ type RadarTodayStats struct { } type RadarWatchPublic struct { - Id string `json:"id"` - Terms []string `json:"terms"` - ExcludeTerms []string `json:"exclude_terms"` - Regions []string `json:"regions"` - Status string `json:"status"` // active | paused | archived - LastSweptAt int64 `json:"last_swept_at,optional"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - FirstSweepTriggered bool `json:"first_sweep_triggered,optional"` + Id string `json:"id"` + Terms []string `json:"terms"` + ExcludeTerms []string `json:"exclude_terms"` + Regions []string `json:"regions"` + Status string `json:"status"` // active | paused | archived + ContextMode string `json:"context_mode"` // generic | product + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` + BrandNameSnapshot string `json:"brand_name_snapshot,optional"` + ProductLabelSnapshot string `json:"product_label_snapshot,optional"` + ContextBoundAt int64 `json:"context_bound_at,optional"` + PauseReason string `json:"pause_reason,optional"` + LastSweptAt int64 `json:"last_swept_at,optional"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + FirstSweepTriggered bool `json:"first_sweep_triggered,optional"` } type RemoveWorkspaceMemberReq struct { @@ -1766,6 +1942,14 @@ type ReviewDecisionReq struct { Reason string `json:"reason,optional"` } +type ReviewStateReq struct { + Id string `path:"id"` + State string `json:"state"` // pending | completed | removed + RemovalReason string `json:"removal_reason,optional"` + RemovalNote string `json:"removal_note,optional"` + DuplicateOpportunityId string `json:"duplicate_of_opportunity_id,optional"` +} + type SaveAiReq struct { Provider string `json:"provider,optional"` Model string `json:"model,optional"` @@ -1852,6 +2036,7 @@ type ScoutPostListReq struct { type ScoutPostPublic struct { Id string `json:"id"` + RunId string `json:"run_id,optional"` BrandId string `json:"brand_id,optional"` Author string `json:"author"` Text string `json:"text"` @@ -1874,8 +2059,56 @@ type ScoutPostPublic struct { CreatedAt int64 `json:"created_at"` } +type ScoutRunListData struct { + List []ScoutRunPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type ScoutRunListReq struct { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + BrandId string `form:"brand_id,optional"` + Mode string `form:"mode,optional"` +} + +type ScoutRunPostsData struct { + Run ScoutRunPublic `json:"run"` + List []ScoutPostPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type ScoutRunPostsReq struct { + RunId string `path:"runId"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` +} + +type ScoutRunPublic struct { + Id string `json:"id"` + JobId string `json:"job_id"` + ThemeKey string `json:"theme_key"` + ThemeLabel string `json:"theme_label"` + Intent string `json:"intent"` + Mode string `json:"mode"` + BrandId string `json:"brand_id,optional"` + TargetCount int `json:"target_count"` + Status string `json:"status"` + SearchedCount int `json:"searched_count"` + DuplicateCount int `json:"duplicate_count"` + IrrelevantCount int `json:"irrelevant_count"` + EligibleCount int `json:"eligible_count"` + PendingCount int `json:"pending_count"` + ShortfallCount int `json:"shortfall_count"` + ShortfallReasons []string `json:"shortfall_reasons"` + CreatedAt int64 `json:"created_at"` + StartedAt int64 `json:"started_at,optional"` + CompletedAt int64 `json:"completed_at,optional"` + Error string `json:"error,optional"` +} + type ScoutScanJobData struct { - Job JobPublic `json:"job"` + Job JobPublic `json:"job"` + Run ScoutRunPublic `json:"run"` } type ScoutScanReq struct { @@ -1940,6 +2173,12 @@ type SetContactFollowUpReq struct { Days int `json:"days,optional"` } +type SetPrimaryProductReq struct { + Id string `path:"id"` + ProductId string `json:"product_id"` + Reason string `json:"reason"` +} + type SnoozeFollowUpReq struct { Id string `path:"id"` Days int `json:"days"` @@ -1982,7 +2221,9 @@ type SubmitDraftReviewReq struct { } type SuggestWatchTermsReq struct { - Limit int `json:"limit,optional"` + Limit int `json:"limit,optional"` + BrandId string `json:"brand_id,optional"` + ProductId string `json:"product_id,optional"` } type SweepListData struct { @@ -2111,6 +2352,17 @@ type UpdateCrmConversionReq struct { Note string `json:"note,optional"` } +type UpdateDemandMapReq struct { + ProductId string `path:"productId"` + ExpectedMapVersion int64 `json:"expected_map_version"` + PainPhrases []DemandMapPhrase `json:"pain_phrases,optional"` + ScenarioPhrases []DemandMapPhrase `json:"scenario_phrases,optional"` + DesiredOutcomes []DemandMapPhrase `json:"desired_outcomes,optional"` + SolutionSignals []DemandMapPhrase `json:"solution_signals,optional"` + ExclusionSignals []DemandMapPhrase `json:"exclusion_signals,optional"` + CustomPhrases []DemandMapPhrase `json:"custom_phrases,optional"` +} + type UpdateWatchReq struct { Id string `path:"id"` Terms []string `json:"terms,optional"` @@ -2343,9 +2595,11 @@ type WatchSuggestData struct { } type WatchTermSuggestion struct { - Term string `json:"term"` - Reason string `json:"reason"` - Usage string `json:"usage"` // include | exclude + Term string `json:"term"` + Reason string `json:"reason"` + Usage string `json:"usage"` // include | exclude + BasisKind string `json:"basis_kind,optional"` + BasisText string `json:"basis_text,optional"` } type WeeklyCheckupPublic struct { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9e74360..94ad146 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,7 @@ import { PrivacyPage } from "./pages/PrivacyPage"; import { TermsPage } from "./pages/TermsPage"; const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage").then((m) => ({ default: m.AdminUsersPage }))); const BrandsPage = lazy(() => import("./pages/BrandsPage").then((m) => ({ default: m.BrandsPage }))); +const PolicyPage = lazy(() => import("./pages/PolicyPage").then((m) => ({ default: m.PolicyPage }))); const CrewPage = lazy(() => import("./pages/CrewPage").then((m) => ({ default: m.CrewPage }))); const JobDetailPage = lazy(() => import("./pages/JobDetailPage").then((m) => ({ default: m.JobDetailPage }))); const InsightsPage = lazy(() => import("./pages/InsightsPage").then((m) => ({ default: m.InsightsPage }))); @@ -24,7 +25,7 @@ const OutboxDetailPage = lazy(() => import("./pages/OutboxDetailPage").then((m) const OutboxPage = lazy(() => import("./pages/OutboxPage").then((m) => ({ default: m.OutboxPage }))); const ProfilePage = lazy(() => import("./pages/ProfilePage").then((m) => ({ default: m.ProfilePage }))); const RadarWatchesPage = lazy(() => import("./pages/RadarWatchesPage").then((m) => ({ default: m.RadarWatchesPage }))); -const RadarTodayPage = lazy(() => import("./pages/RadarTodayPage").then((m) => ({ default: m.RadarTodayPage }))); +const RadarOpportunitiesPage = lazy(() => import("./pages/RadarOpportunitiesPage").then((m) => ({ default: m.RadarOpportunitiesPage }))); const CrmStatsPage = lazy(() => import("./pages/CrmStatsPage").then((m) => ({ default: m.CrmStatsPage }))); const CrmBoardPage = lazy(() => import("./pages/CrmBoardPage").then((m) => ({ default: m.CrmBoardPage }))); const CrmFollowUpsPage = lazy(() => @@ -88,11 +89,13 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/radar/CostPreviewDialog.tsx b/apps/web/src/components/radar/CostPreviewDialog.tsx new file mode 100644 index 0000000..6db46bd --- /dev/null +++ b/apps/web/src/components/radar/CostPreviewDialog.tsx @@ -0,0 +1,26 @@ +import { useState } from "react"; +import type { CostPreview } from "../../domain/types"; +import { Badge, Button, Input } from "../ui"; + +type Props = { + preview: CostPreview; + onConfirm: (creditCeiling: number) => void; + onCancel: () => void; + busy?: boolean; +}; + +export function CostPreviewDialog({ preview, onConfirm, onCancel, busy = false }: Props) { + const [ceiling, setCeiling] = useState(String(preview.max_credits)); + const value = Number(ceiling); + const valid = Number.isFinite(value) && value >= preview.fixed_credits && value <= preview.max_credits; + return ( +
+

執行前先確認點數

預覽本身不扣點;只有 provider 成功回傳才會計入用量。

{preview.key_mode === "byok" ? "BYOK · 平台 0 點" : "平台點數"}
+
固定
{preview.fixed_credits}
預估範圍
{preview.min_credits}–{preview.max_credits}
搜尋呼叫
{preview.search_calls}
剩餘
{preview.remaining_credits}
+

{preview.estimate_basis} · 預覽至 {new Date(preview.expires_at / 1_000_000).toLocaleTimeString()}

+ setCeiling(event.target.value)} hint={`至少 ${preview.fixed_credits},最多 ${preview.max_credits}`} /> + {!valid ?

點數上限必須落在固定成本至預估上限之間。

: null} +
+
+ ); +} diff --git a/apps/web/src/components/radar/DemandMapEditor.tsx b/apps/web/src/components/radar/DemandMapEditor.tsx new file mode 100644 index 0000000..60c615a --- /dev/null +++ b/apps/web/src/components/radar/DemandMapEditor.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import type { BrandProduct, DemandMap, DemandMapPhrase } from "../../domain/types"; +import { Badge, Button, Textarea } from "../ui"; + +type Category = "pain_phrases" | "scenario_phrases" | "desired_outcomes" | "solution_signals" | "exclusion_signals"; +const categories: Array<{ key: Category; label: string; hint: string; kind: string }> = [ + { key: "pain_phrases", label: "使用者痛點", hint: "產品要解決的困擾,例如漏水、協作混亂", kind: "pain" }, + { key: "scenario_phrases", label: "使用情境", hint: "使用者會怎麼描述發生的情境", kind: "scenario" }, + { key: "desired_outcomes", label: "期待結果", hint: "使用者想要的結果或改善", kind: "outcome" }, + { key: "solution_signals", label: "解法訊號", hint: "能判斷你有能力協助的詞", kind: "solution" }, + { key: "exclusion_signals", label: "排除訊號", hint: "徵才、廣告等不應進入商機的內容", kind: "exclusion" }, +]; + +type Props = { + product: BrandProduct; + map: DemandMap | null; + loading?: boolean; + saving?: boolean; + error?: string; + onSave: (patch: Omit & { expected_map_version: number }) => void; + onDraftChange?: (patch: DemandMapPatch) => void; +}; + +export type DemandMapPatch = Omit & { expected_map_version: number }; + +function lines(phrases: DemandMapPhrase[] = []): string { + return phrases.filter((phrase) => phrase.enabled).map((phrase) => phrase.text).join("\n"); +} + +function asPhrases(value: string, kind: string, existing: DemandMapPhrase[]): DemandMapPhrase[] { + return value.split(/\n|,|、/).map((text) => text.trim()).filter(Boolean).map((text) => { + const old = existing.find((phrase) => phrase.text === text); + return old ? { ...old, enabled: true } : { text, kind, origin: "user", basis_kind: "custom", basis_text: "手動補充", enabled: true }; + }); +} + +function buildPatch(map: DemandMap, draft: Record, custom: string): DemandMapPatch { + const next = Object.fromEntries(categories.map(({ key, kind }) => [key, asPhrases(draft[key], kind, map[key])])) as Record; + return { + expected_map_version: map.map_version, + demand_input_version: map.demand_input_version, + map_version: map.map_version, + state: next.pain_phrases.length && next.scenario_phrases.length && next.solution_signals.length ? "ready" : "incomplete", + pain_phrases: next.pain_phrases, + scenario_phrases: next.scenario_phrases, + desired_outcomes: next.desired_outcomes, + solution_signals: next.solution_signals, + exclusion_signals: next.exclusion_signals, + source_basis: map.source_basis, + custom_phrases: asPhrases(custom, "custom", map.custom_phrases), + ai_enriched_at: map.ai_enriched_at, + }; +} + +export function DemandMapEditor({ product, map, loading = false, saving = false, error, onSave, onDraftChange }: Props) { + const [draft, setDraft] = useState>({ + pain_phrases: "", scenario_phrases: "", desired_outcomes: "", solution_signals: "", exclusion_signals: "", + }); + const [custom, setCustom] = useState(""); + + useEffect(() => { + if (!map) return; + setDraft({ + pain_phrases: lines(map.pain_phrases), + scenario_phrases: lines(map.scenario_phrases), + desired_outcomes: lines(map.desired_outcomes), + solution_signals: lines(map.solution_signals), + exclusion_signals: lines(map.exclusion_signals), + }); + setCustom(lines(map.custom_phrases)); + }, [map]); + + function save() { + if (!map) return; + onSave(buildPatch(map, draft, custom)); + } + + function changeDraft(next: Record) { + setDraft(next); + if (map) onDraftChange?.(buildPatch(map, next, custom)); + } + + function changeCustom(next: string) { + setCustom(next); + if (map) onDraftChange?.(buildPatch(map, draft, next)); + } + + return ( +
+
+
+

產品需求地圖

+

先確認產品真實痛點,再用它縮小巡邏結果。來源會保留在每個詞旁邊。

+
+ {map ?
{map.state === "ready" ? "可用" : "待補資料"}版本 {map.map_version}
: null} +
+ {loading ?

正在整理產品需求…

: null} + {error ?

{error}

: null} + {map ? <> + {categories.map(({ key, label, hint }) => ( +
+