package usecase import ( "context" "fmt" "strings" "apps/backend/internal/module/radar/domain" ) // ExploreResult is the outcome of an on-demand explore run (no watch subscription). type ExploreResult struct { SweepID string HitCount int JudgedCount int CreatedCount int MatchedCount int MergedCount int TruncatedCount int CreditsUsed int } /* ExploreOpportunities runs an immediate search → five-question judge pipeline for a short list of Threads-searchable terms (contract B / plan T3). 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) } terms, err := validateExploreTerms(rawTerms) if err != nil { return nil, err } // Profile optional at gate — judge tolerates nil profile (same as manual import). profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID) // Synthetic watch so FetchCandidates / ProcessCandidates can carry terms. w := &domain.RadarWatch{ ID: "", OwnerUID: ownerUID, 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 = mergeFetchWatch(w, plan) } } } // Cap hits for sync explore (≤20); AI judge still subject to daily quota inside ProcessCandidates. const exploreHitLimit = 20 cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, exploreHitLimit) if ferr != nil { return nil, ferr } sw, err := s.BeginSweepRecord(ctx, ownerUID, "", "", path) if err != nil { return nil, err } if path != "" { _ = s.setSweepPath(ctx, sw.ID, path) } rawHitCount := len(cands) cands, prefilter := PrefilterCandidates(cands, w, productContext) _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ 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( ctx, ownerUID, w, profile, sw.ID, cands, nil, ) if perr != nil { reason := "判定流程失敗:" + perr.Error() end := domain.NowNano() _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{FailedReason: &reason, EndedAt: end}) return nil, perr } end := domain.NowNano() var failPtr *string if failed > 0 && created == 0 && judged > 0 { r := fmt.Sprintf("%d 筆判定失敗", failed) failPtr = &r } sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ CreditsUsed: judgeCredits, CreditJudge: judgeCredits, EndedAt: end, FailedReason: failPtr, }) credits := fetchCredits + judgeCredits if sw != nil { // UpdateSweep may have accumulated credits differently; prefer stored total when available. if sw.CreditsUsed > 0 { credits = sw.CreditsUsed } } return &ExploreResult{ SweepID: sw.ID, HitCount: len(cands), JudgedCount: judged, CreatedCount: created, MatchedCount: sw.MatchEvaluatedCount, MergedCount: sw.MatchMergedCount, TruncatedCount: truncated, CreditsUsed: credits, }, nil } func validateExploreTerms(raw []string) ([]string, error) { if len(raw) == 0 { return nil, fmt.Errorf("%w: terms required (1–%d)", domain.ErrValidation, domain.MaxExploreTerms) } if len(raw) > domain.MaxExploreTerms { return nil, fmt.Errorf("%w: at most %d terms", domain.ErrValidation, domain.MaxExploreTerms) } out := make([]string, 0, len(raw)) seen := map[string]bool{} for _, r := range raw { term := domain.NormalizeSearchTerm(r) if term == "" { return nil, fmt.Errorf("%w: empty term", domain.ErrValidation) } if !domain.IsThreadsSearchable(term) { return nil, fmt.Errorf("%w: term %q is not Threads-searchable (≤2 tokens, CJK 2–4 chars, ≤12 total, no punctuation)", domain.ErrValidation, term) } key := strings.ToLower(term) if seen[key] { continue } seen[key] = true out = append(out, term) } if len(out) == 0 { return nil, fmt.Errorf("%w: terms required", domain.ErrValidation) } return out, nil }