package usecase import ( "strings" "apps/backend/internal/module/scout/domain" ) type CandidateDecision string const ( CandidateEligible CandidateDecision = "eligible" CandidateDuplicate CandidateDecision = "duplicate" CandidateIrrelevant CandidateDecision = "irrelevant" ) // CandidateStats describes mutually exclusive decisions made by one // evaluator. Searched is incremented for every raw candidate presented to it. type CandidateStats struct { Searched int Duplicate int Irrelevant int Eligible int } type CandidateEvaluation struct { Decision CandidateDecision Identity string Permalink string Text string SearchTag string PostedAt int64 Classification string Score int Reason string TopicMatch TopicMatch } // CandidateEvaluator is the single relevance gate used before persistence. // Historical identity checks are intentionally left to T042; this evaluator // only handles canonical duplicates in the current candidate set. type CandidateEvaluator struct { brief *domain.RunBrief signature TopicSignature seen map[string]struct{} historicalSeen func(identity string) (bool, error) stats CandidateStats } func NewCandidateEvaluator(brief *domain.RunBrief) *CandidateEvaluator { if brief == nil { brief = &domain.RunBrief{} } signature := NewTopicSignature(brief.Intent, brief.ScanTerms) // Provider/demand briefs intentionally replace Intent with a product label; // their approved pain/capability terms are the real evidence. Keep their // established mode-specific gates until those modes get their own signature // contract, while activity/product/theme retain the original intent gate. if brief.Mode == domain.ModeProvider || brief.Mode == domain.ModeDemand { signature = TopicSignature{} } return &CandidateEvaluator{brief: brief, signature: signature, seen: make(map[string]struct{})} } func (e *CandidateEvaluator) Stats() CandidateStats { if e == nil { return CandidateStats{} } return e.stats } // SetHistoricalSeenLookup injects the owner-scoped repository check. The // callback receives the canonical identity, never a raw provider URL. func (e *CandidateEvaluator) SetHistoricalSeenLookup(check func(identity string) (bool, error)) { if e != nil { e.historicalSeen = check } } func (e *CandidateEvaluator) Evaluate(hit ThreadSearchResult) CandidateEvaluation { return e.EvaluateAt(hit, domain.NowNano()) } func (e *CandidateEvaluator) EvaluateAt(hit ThreadSearchResult, now int64) CandidateEvaluation { if e == nil { return CandidateEvaluation{Decision: CandidateIrrelevant, Reason: "evaluator unavailable"} } e.stats.Searched++ text := strings.TrimSpace(hit.Snippet) if text == "" { // Some providers return a title without highlights/text. It is still // usable evidence when the title carries the complete topic phrase. text = strings.TrimSpace(hit.Title) } permalink := canonicalPermalink(hit.URL) identity := canonicalPostIdentity(hit.URL) result := CandidateEvaluation{ Decision: CandidateIrrelevant, Identity: identity, Permalink: permalink, Text: text, PostedAt: hit.PublishedAt, } if text == "" || permalink == "" || identity == "" { e.stats.Irrelevant++ result.Reason = "missing canonical URL or post text" return result } if _, exists := e.seen[identity]; exists { e.stats.Duplicate++ result.Decision = CandidateDuplicate result.Reason = "duplicate canonical identity: " + identity return result } if e.historicalSeen != nil { seen, err := e.historicalSeen(identity) if err != nil { e.stats.Irrelevant++ result.Reason = "historical identity lookup failed" return result } if seen { // Historical duplicates are terminal. Mark them locally so a provider // returning the same post twice does not repeat the repository lookup. e.seen[identity] = struct{}{} e.stats.Duplicate++ result.Decision = CandidateDuplicate result.Reason = "duplicate historical identity: " + identity return result } } body := text + " " + strings.TrimSpace(hit.Title) if len(e.signature.Concepts) > 0 { result.TopicMatch = e.signature.Match(body) if !result.TopicMatch.Matched { e.stats.Irrelevant++ result.Reason = result.TopicMatch.Reason return result } } else { // Compatibility for briefs that only carry approved scan terms. A // signature with no concepts still must not admit an empty query or turn // a generic anchor such as "推薦" into a topic. if e.brief.Mode == domain.ModeActivity && len(semanticTopicParts(e.brief.Intent)) == 0 { var hasTopicPart bool for _, term := range e.brief.ScanTerms { if len(semanticTopicParts(term)) > 0 { hasTopicPart = true break } } if !hasTopicPart { e.stats.Irrelevant++ result.Reason = "no approved topic core matched" return result } } matched := matchingSearchTerm(body, e.brief.ScanTerms) if matched == "" { e.stats.Irrelevant++ result.Reason = "no approved topic core matched" return result } result.TopicMatch = TopicMatch{Matched: true, MatchedCores: []string{matched}, Reason: "matched core: " + matched} } classified := classifyPost(e.brief.Mode, body, e.brief.ScanTerms) if e.brief.Mode == domain.ModeProvider { classified = classifyProvider(body, e.brief.Pains, e.brief.Tags, e.brief.Periphery) } if e.brief.Mode == domain.ModeDemand { classified = classifyDemand(body, e.brief.Pains, e.brief.Periphery) } if classified.classification == domain.ClassificationNoise || ((e.brief.Mode == domain.ModeProduct || e.brief.Mode == domain.ModeTheme) && classified.classification == domain.ClassificationProviderOffer) || (e.brief.Mode == domain.ModeProvider && classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) { e.stats.Irrelevant++ result.Reason = classified.reason return result } term := strings.TrimSpace(hit.MatchedQuery) if term == "" || !textMatchesSearchTerm(body, term) { term = result.TopicMatch.MatchedCores[0] } score, reason := classified.score, classified.reason if result.TopicMatch.Reason != "" { reason += "; " + result.TopicMatch.Reason } if hit.Track == "both" { boost := 6 if e.brief.Mode == domain.ModeActivity { boost = 35 } score = minInt(100, score+boost) reason += "; track: both" } else if hit.Track == "recent" { boost := 3 if e.brief.Mode == domain.ModeActivity { boost = 8 } score = minInt(100, score+boost) reason += "; track: recent" } else if hit.Track == "top" { if e.brief.Mode == domain.ModeActivity { score = minInt(100, score+12) } reason += "; track: top" } if hit.SerpRank > 0 { reason += "; serp_rank: " + itoaASCII(hit.SerpRank) } if hit.PublishedAt > 0 && isSoftAged(hit.PublishedAt, defaultScoutSoftAgeDays) { score = maxInt(1, score-12) reason += "; soft_aged" } // Only an eligible candidate occupies the local identity. Providers often // return a sparse/short first hit followed by a richer snippet for the same // post; rejecting the first hit must not prevent the second from being // evaluated. This is deliberately after every relevance and classification // gate, while historical duplicates were marked above as terminal. e.seen[identity] = struct{}{} if now <= 0 { now = domain.NowNano() } _ = now // reserved for the run-level created_at assignment result.Decision = CandidateEligible result.SearchTag = term result.Classification = classified.classification result.Score = score result.Reason = reason e.stats.Eligible++ return result }