feat: add recommendation scoring and reranking engine
This commit is contained in:
269
internal/anime/recommendations.go
Normal file
269
internal/anime/recommendations.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/domain"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
forYouMaxSeeds = 8
|
||||||
|
forYouMaxRecommendations = 8
|
||||||
|
forYouCandidateFetchLimit = 36
|
||||||
|
forYouResultLimit = 12
|
||||||
|
forYouSeedRecencyWindow = 180 * 24 * time.Hour
|
||||||
|
forYouFreshReleaseWindow = 540 * 24 * time.Hour
|
||||||
|
forYouGenreMatchWeight = 1.8
|
||||||
|
forYouThemeMatchWeight = 1.0
|
||||||
|
forYouStudioMatchWeight = 0.7
|
||||||
|
forYouDemographicMatchWeight = 0.9
|
||||||
|
)
|
||||||
|
|
||||||
|
type recommendationSeed struct {
|
||||||
|
animeID int
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type recommendationCandidate struct {
|
||||||
|
anime jikan.Anime
|
||||||
|
score float64
|
||||||
|
genreMatches int
|
||||||
|
themeMatches int
|
||||||
|
studioMatches int
|
||||||
|
demographicMatches int
|
||||||
|
}
|
||||||
|
|
||||||
|
type userTasteProfile struct {
|
||||||
|
genres map[int]float64
|
||||||
|
themes map[int]float64
|
||||||
|
studios map[int]float64
|
||||||
|
demographics map[int]float64
|
||||||
|
prefersAiring bool
|
||||||
|
prefersRecent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRecommendationSeeds(
|
||||||
|
now time.Time,
|
||||||
|
watchlist []db.GetUserWatchListRow,
|
||||||
|
) []recommendationSeed {
|
||||||
|
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
|
||||||
|
|
||||||
|
for _, entry := range watchlist {
|
||||||
|
weight := recommendationEntryWeight(now, entry)
|
||||||
|
if weight <= 0 || entry.AnimeID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds = append(seeds, recommendationSeed{
|
||||||
|
animeID: int(entry.AnimeID),
|
||||||
|
weight: weight,
|
||||||
|
})
|
||||||
|
if len(seeds) >= forYouMaxSeeds {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
|
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
||||||
|
status := strings.TrimSpace(entry.Status)
|
||||||
|
|
||||||
|
var statusWeight float64
|
||||||
|
switch status {
|
||||||
|
case "completed":
|
||||||
|
statusWeight = 1.0
|
||||||
|
case "watching":
|
||||||
|
statusWeight = 0.9
|
||||||
|
case "plan_to_watch":
|
||||||
|
statusWeight = 0.35
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
recencyWeight := 1.0
|
||||||
|
if !entry.UpdatedAt.IsZero() {
|
||||||
|
age := now.Sub(entry.UpdatedAt)
|
||||||
|
if age > 0 {
|
||||||
|
recencyWeight = math.Max(0.35, 1-(age.Hours()/forYouSeedRecencyWindow.Hours()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressWeight := 0.6
|
||||||
|
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||||
|
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusWeight * recencyWeight * progressWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTasteProfile(seedAnimes []jikan.Anime) userTasteProfile {
|
||||||
|
profile := userTasteProfile{
|
||||||
|
genres: make(map[int]float64),
|
||||||
|
themes: make(map[int]float64),
|
||||||
|
studios: make(map[int]float64),
|
||||||
|
demographics: make(map[int]float64),
|
||||||
|
}
|
||||||
|
|
||||||
|
var airingCount int
|
||||||
|
var recentCount int
|
||||||
|
|
||||||
|
for _, anime := range seedAnimes {
|
||||||
|
addEntityWeights(profile.genres, anime.Genres, 1.0)
|
||||||
|
addEntityWeights(profile.themes, anime.Themes, 0.7)
|
||||||
|
addEntityWeights(profile.studios, anime.Studios, 0.5)
|
||||||
|
addEntityWeights(profile.demographics, anime.Demographics, 0.7)
|
||||||
|
|
||||||
|
if anime.Airing {
|
||||||
|
airingCount++
|
||||||
|
}
|
||||||
|
if anime.Year > 0 && time.Now().Year()-anime.Year <= 4 {
|
||||||
|
recentCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(seedAnimes)
|
||||||
|
if total > 0 {
|
||||||
|
profile.prefersAiring = float64(airingCount)/float64(total) >= 0.5
|
||||||
|
profile.prefersRecent = float64(recentCount)/float64(total) >= 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
||||||
|
for _, entity := range entities {
|
||||||
|
if entity.MalID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target[entity.MalID] += weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreRecommendationCandidate(
|
||||||
|
now time.Time,
|
||||||
|
profile userTasteProfile,
|
||||||
|
candidate jikan.Anime,
|
||||||
|
collaborativeScore float64,
|
||||||
|
) recommendationCandidate {
|
||||||
|
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||||
|
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||||
|
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||||
|
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||||
|
|
||||||
|
score := collaborativeScore
|
||||||
|
score += genreScore * forYouGenreMatchWeight
|
||||||
|
score += themeScore * forYouThemeMatchWeight
|
||||||
|
score += studioScore * forYouStudioMatchWeight
|
||||||
|
score += demographicScore * forYouDemographicMatchWeight
|
||||||
|
|
||||||
|
if candidate.Score > 0 {
|
||||||
|
score += min(candidate.Score/10.0, 1.0)
|
||||||
|
}
|
||||||
|
if candidate.Popularity > 0 {
|
||||||
|
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
|
||||||
|
}
|
||||||
|
if profile.prefersAiring && candidate.Airing {
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
|
||||||
|
score += 0.45
|
||||||
|
}
|
||||||
|
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
|
||||||
|
score -= 0.2
|
||||||
|
}
|
||||||
|
if candidate.Status == "Not yet aired" {
|
||||||
|
score -= 0.35
|
||||||
|
}
|
||||||
|
if candidate.Aired.From != "" {
|
||||||
|
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
|
||||||
|
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendationCandidate{
|
||||||
|
anime: candidate,
|
||||||
|
score: score,
|
||||||
|
genreMatches: genreMatches,
|
||||||
|
themeMatches: themeMatches,
|
||||||
|
studioMatches: studioMatches,
|
||||||
|
demographicMatches: demographicMatches,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||||
|
var (
|
||||||
|
matches int
|
||||||
|
score float64
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, entity := range entities {
|
||||||
|
weight, ok := weights[entity.MalID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches++
|
||||||
|
score += weight
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, score
|
||||||
|
}
|
||||||
|
|
||||||
|
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
|
||||||
|
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
|
||||||
|
seenGenres := make(map[int]int)
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if len(selected) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if isGenreOverrepresented(candidate.anime, seenGenres) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
||||||
|
for _, genre := range candidate.anime.Genres {
|
||||||
|
seenGenres[genre.MalID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(selected) >= limit {
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if len(selected) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
|
||||||
|
return anime.MalID == candidate.anime.MalID
|
||||||
|
}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGenreOverrepresented(anime jikan.Anime, seenGenres map[int]int) bool {
|
||||||
|
if len(anime.Genres) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedGenres := 0
|
||||||
|
for _, genre := range anime.Genres {
|
||||||
|
if seenGenres[genre.MalID] >= 3 {
|
||||||
|
matchedGenres++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedGenres == len(anime.Genres)
|
||||||
|
}
|
||||||
73
internal/anime/recommendations_test.go
Normal file
73
internal/anime/recommendations_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package anime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"mal/integrations/jikan"
|
||||||
|
"mal/internal/db"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecommendationEntryWeightPrioritizesCommittedRecentHistory(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
completed := recommendationEntryWeight(now, db.GetUserWatchListRow{
|
||||||
|
Status: "completed",
|
||||||
|
UpdatedAt: now.Add(-24 * time.Hour),
|
||||||
|
CurrentEpisode: sql.NullInt64{Int64: 12, Valid: true},
|
||||||
|
})
|
||||||
|
planned := recommendationEntryWeight(now, db.GetUserWatchListRow{
|
||||||
|
Status: "plan_to_watch",
|
||||||
|
UpdatedAt: now.Add(-24 * time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
if completed <= planned {
|
||||||
|
t.Fatalf("expected completed history to outrank planned history, got completed=%f planned=%f", completed, planned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRecommendationSeedsFiltersUnsupportedStatuses(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
seeds := buildRecommendationSeeds(now, []db.GetUserWatchListRow{
|
||||||
|
{AnimeID: 1, Status: "dropped", UpdatedAt: now},
|
||||||
|
{AnimeID: 2, Status: "watching", UpdatedAt: now},
|
||||||
|
{AnimeID: 3, Status: "completed", UpdatedAt: now},
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(seeds) != 2 {
|
||||||
|
t.Fatalf("expected 2 valid seeds, got %d", len(seeds))
|
||||||
|
}
|
||||||
|
if seeds[0].animeID != 2 || seeds[1].animeID != 3 {
|
||||||
|
t.Fatalf("unexpected seed ordering: %+v", seeds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
profile := userTasteProfile{
|
||||||
|
genres: map[int]float64{
|
||||||
|
1: 2.0,
|
||||||
|
},
|
||||||
|
themes: map[int]float64{},
|
||||||
|
studios: map[int]float64{},
|
||||||
|
demographics: map[int]float64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
matching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||||
|
MalID: 10,
|
||||||
|
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
|
||||||
|
Popularity: 100,
|
||||||
|
Score: 8.0,
|
||||||
|
}, 5.0)
|
||||||
|
nonMatching := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||||
|
MalID: 11,
|
||||||
|
Genres: []jikan.NamedEntity{{MalID: 2, Name: "Drama"}},
|
||||||
|
Popularity: 100,
|
||||||
|
Score: 8.0,
|
||||||
|
}, 5.0)
|
||||||
|
|
||||||
|
if matching.score <= nonMatching.score {
|
||||||
|
t.Fatalf("expected matching candidate to score higher, got matching=%f nonMatching=%f", matching.score, nonMatching.score)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user