From 8ae79c301a9f9a27aa4a0b17a13b8b71c3f595fe Mon Sep 17 00:00:00 2001 From: mkelvers Date: Thu, 4 Jun 2026 16:10:08 +0200 Subject: [PATCH] feat: add recommendation scoring and reranking engine --- internal/anime/recommendations.go | 269 +++++++++++++++++++++++++ internal/anime/recommendations_test.go | 73 +++++++ 2 files changed, 342 insertions(+) create mode 100644 internal/anime/recommendations.go create mode 100644 internal/anime/recommendations_test.go diff --git a/internal/anime/recommendations.go b/internal/anime/recommendations.go new file mode 100644 index 0000000..5f9a2b0 --- /dev/null +++ b/internal/anime/recommendations.go @@ -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) +} diff --git a/internal/anime/recommendations_test.go b/internal/anime/recommendations_test.go new file mode 100644 index 0000000..002dd72 --- /dev/null +++ b/internal/anime/recommendations_test.go @@ -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) + } +}