thread-master/apps/backend/internal/module/scout/usecase/candidate.go

235 lines
7.5 KiB
Go
Raw Permalink Normal View History

2026-08-13 02:22:24 +00:00
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
}