Files
silo-server/internal/recommendations/validation.go

112 lines
3.0 KiB
Go

package recommendations
import "strings"
// validationResult holds the outcome of validating a candidate.
type validationResult struct {
rejected bool
scoreMult float64 // 1.0 = no change, < 1.0 = penalty
}
// passesGenreGate returns true if the candidate shares at least one genre with
// the source, or if either has no genres (missing data should not penalize).
func passesGenreGate(sourceGenres, candidateGenres []string) bool {
if len(sourceGenres) == 0 || len(candidateGenres) == 0 {
return true
}
for _, sg := range sourceGenres {
for _, cg := range candidateGenres {
if strings.EqualFold(sg, cg) {
return true
}
}
}
return false
}
// genreOverlapCount returns the number of shared genres (case-insensitive).
func genreOverlapCount(a, b []string) int {
count := 0
for _, ag := range a {
for _, bg := range b {
if strings.EqualFold(ag, bg) {
count++
break
}
}
}
return count
}
// titleWordOverlap computes the word overlap ratio between two titles.
// Strips leading English articles and lowercases both titles.
// Returns shared_words / min(len(sourceWords), len(candidateWords)).
func titleWordOverlap(sourceTitle, candidateTitle string) float64 {
normalize := func(title string) []string {
t := strings.ToLower(strings.TrimSpace(title))
// Strip leading English articles.
for _, article := range []string{"the ", "a ", "an "} {
if strings.HasPrefix(t, article) {
t = t[len(article):]
break
}
}
words := strings.Fields(t)
if len(words) == 0 {
return nil
}
return words
}
srcWords := normalize(sourceTitle)
candWords := normalize(candidateTitle)
if len(srcWords) == 0 || len(candWords) == 0 {
return 0
}
// Build set from candidate words.
candSet := make(map[string]bool, len(candWords))
for _, w := range candWords {
candSet[w] = true
}
shared := 0
for _, w := range srcWords {
if candSet[w] {
shared++
}
}
minLen := len(srcWords)
if len(candWords) < minLen {
minLen = len(candWords)
}
return float64(shared) / float64(minLen)
}
// validateCandidate runs the validation pipeline on a single candidate.
func validateCandidate(source, candidate *ItemMetadata, item ScoredItem) validationResult {
overlap := titleWordOverlap(source.Title, candidate.Title)
genreShared := genreOverlapCount(source.Genres, candidate.Genres)
genreGatePass := passesGenreGate(source.Genres, candidate.Genres)
// Step 1: Genre gate — reject zero genre overlap.
if !genreGatePass {
return validationResult{rejected: true}
}
// Step 2: Title overlap with exact match and different year — penalize.
if overlap >= 1.0 && source.Year != candidate.Year && source.Year > 0 && candidate.Year > 0 {
return validationResult{rejected: false, scoreMult: 0.5}
}
// Step 3: High title overlap with weak genre overlap (exactly 1 shared) — penalize.
if overlap >= 0.5 && genreShared == 1 {
return validationResult{rejected: false, scoreMult: 0.7}
}
_ = item // score available for future use
return validationResult{rejected: false, scoreMult: 1.0}
}