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 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) { 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, } // 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) } _, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{ HitCount: len(cands), CreditsUsed: fetchCredits, }) 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, 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, 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 }