235 lines
7.5 KiB
Go
235 lines
7.5 KiB
Go
|
|
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
|
||
|
|
}
|