package usecase import ( "context" "sort" "apps/backend/internal/module/radar/domain" ) type scoredCandidate struct { cand *domain.CandidatePost result *JudgeResult credits int } /* ProcessCandidates judges hits, applies daily quota truncation by score, and persists. - Already judged external_ids (resume) are skipped without re-billing. - Existing external_id → merge matched_terms only. - Incomplete reasons → skip persist, count as judge failure on sweep. - Truncation is success path: truncated_count increments, not an error. */ func (s *Service) ProcessCandidates( ctx context.Context, ownerUID int64, watch *domain.RadarWatch, profile *domain.ServiceProfile, sweepID string, cands []*domain.CandidatePost, alreadyJudged map[string]bool, ) (created, judged, truncated, failed int, credits int, err error) { if alreadyJudged == nil { alreadyJudged = map[string]bool{} } maxDaily, err := s.MaxDailyOpportunities(ctx, ownerUID) if err != nil { return 0, 0, 0, 0, 0, err } todayCount, err := s.Repo.CountToday(ctx, ownerUID, domain.NowNano()) if err != nil { return 0, 0, 0, 0, 0, err } remaining := maxDaily - int(todayCount) if remaining < 0 { remaining = 0 } // Cap how many we judge this run: remaining + small buffer so we can rank then truncate. // 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}) } return 0, 0, truncated, 0, 0, nil } var scored []scoredCandidate var judgedIDs []string for _, c := range cands { if c == nil || c.ExternalID == "" { continue } if alreadyJudged[c.ExternalID] { continue } // Dedupe check without re-judge: if exists, merge term only. existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, c.ExternalID) if gerr == nil && existing != nil { term := c.MatchedTerm _, _ = s.Repo.UpsertByExternalID(ctx, &domain.Opportunity{ OwnerUID: ownerUID, ExternalID: c.ExternalID, MatchedTerms: []string{term}, }) judgedIDs = append(judgedIDs, c.ExternalID) judged++ continue } if len(scored) >= judgeBudget { truncated++ continue } res, cred, jerr := s.JudgeCandidate(ctx, ownerUID, profile, watch, c) credits += cred if jerr != nil || res == nil { failed++ judgedIDs = append(judgedIDs, c.ExternalID) judged++ continue } scored = append(scored, scoredCandidate{cand: c, result: res, credits: cred}) judgedIDs = append(judgedIDs, c.ExternalID) judged++ } // Rank qualified/rejected by score desc; always persist rejected; qualified subject to remaining. sort.SliceStable(scored, func(i, j int) bool { return scored[i].result.IntentScore > scored[j].result.IntentScore }) for _, sc := range scored { res := sc.result // rejected always stored if reasons ok if res.Status == domain.OppRejected { if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil { failed++ continue } created++ continue } if remaining <= 0 { truncated++ continue } if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil { failed++ continue } created++ remaining-- } if sweepID != "" { delta := domain.SweepDelta{ JudgedCount: judged, CreatedCount: created, TruncatedCount: truncated, CreditsUsed: credits, JudgedExternalIDs: judgedIDs, } if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil { return created, judged, truncated, failed, credits, uerr } } return created, judged, truncated, failed, credits, nil } func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) error { if err := domain.ValidateReasons(res.Reasons); err != nil { return err } watchID := "" if watch != nil { watchID = watch.ID } 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, } if o.IntentBand == "" { o.ApplyBandFromScore() } _, err := s.Repo.UpsertByExternalID(ctx, o) return err }