thread-master/apps/backend/internal/module/radar/usecase/judge_persist.go

183 lines
4.8 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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
}