183 lines
4.8 KiB
Go
183 lines
4.8 KiB
Go
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
|
|
}
|
|
|
|
|