112 lines
3.0 KiB
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}
|
|
}
|