From 6d5e9d0a2cc8dd68ae011a2b5ddf01a2033ef4ad Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 27 Jun 2026 17:55:38 +0200 Subject: [PATCH] feat: surface recommendation rationale --- internal/anime/recommendations/rerank.go | 2 +- internal/anime/recommendations/scoring.go | 33 +++++++++++++++++++++++ internal/anime/recommendations/types.go | 1 + internal/domain/anime.go | 1 + templates/top_picks.gohtml | 12 ++++++++- 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/internal/anime/recommendations/rerank.go b/internal/anime/recommendations/rerank.go index 1032b741..5da4c60d 100644 --- a/internal/anime/recommendations/rerank.go +++ b/internal/anime/recommendations/rerank.go @@ -24,7 +24,7 @@ func rerankRecommendationCandidates(candidates []recommendationCandidate, limit continue } - selected = append(selected, domain.Anime{Anime: candidate.anime}) + selected = append(selected, domain.Anime{Anime: candidate.anime, RecommendationRationale: candidate.rationale}) features := diversityFeatures(candidate.anime) seen.add(features) recent = append(recent, features) diff --git a/internal/anime/recommendations/scoring.go b/internal/anime/recommendations/scoring.go index ed120231..6eababc4 100644 --- a/internal/anime/recommendations/scoring.go +++ b/internal/anime/recommendations/scoring.go @@ -48,9 +48,23 @@ func scoreRecommendationCandidate( themeMatches: themes, studioMatches: studios, demographicMatches: demos, + rationale: buildRecommendationRationale(profile, candidate), } } +func buildRecommendationRationale(profile userTasteProfile, candidate jikan.Anime) []string { + rationale := make([]string, 0, 4) + rationale = append(rationale, matchedEntityNames(profile.genres, candidate.Genres)...) + rationale = append(rationale, matchedEntityNames(profile.themes, candidate.Themes)...) + rationale = append(rationale, matchedEntityNames(profile.studios, candidate.Studios)...) + rationale = append(rationale, matchedEntityNames(profile.demographics, candidate.Demographics)...) + + if len(rationale) > 4 { + return rationale[:4] + } + return rationale +} + func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 { var score float64 @@ -115,3 +129,22 @@ func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) return matches, score } + +func matchedEntityNames(weights map[int]float64, entities []jikan.NamedEntity) []string { + if len(weights) == 0 { + return []string{} + } + + names := make([]string, 0, 1) + for _, entity := range entities { + if entity.Name == "" || weights[entity.MalID] <= 0 { + continue + } + names = append(names, entity.Name) + if len(names) >= 1 { + break + } + } + + return names +} diff --git a/internal/anime/recommendations/types.go b/internal/anime/recommendations/types.go index edcb4fb5..979cbec4 100644 --- a/internal/anime/recommendations/types.go +++ b/internal/anime/recommendations/types.go @@ -25,6 +25,7 @@ type recommendationCandidate struct { themeMatches int studioMatches int demographicMatches int + rationale []string } type userTasteProfile struct { diff --git a/internal/domain/anime.go b/internal/domain/anime.go index c36831e7..46e562a6 100644 --- a/internal/domain/anime.go +++ b/internal/domain/anime.go @@ -9,6 +9,7 @@ import ( type Anime struct { jikan.Anime + RecommendationRationale []string } type Genre struct { diff --git a/templates/top_picks.gohtml b/templates/top_picks.gohtml index e338ca9b..25881ad3 100644 --- a/templates/top_picks.gohtml +++ b/templates/top_picks.gohtml @@ -21,7 +21,17 @@ {{else}}
{{range .Animes}} - {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} +
+ {{template "anime_card" dict "Anime" . "WithActions" true "HideTitle" true "IsWatchlist" (index $.WatchlistMap .MalID)}} +

{{.DisplayTitle}}

+ {{if .RecommendationRationale}} +
+ {{range .RecommendationRationale}} + {{.}} + {{end}} +
+ {{end}} +
{{end}}
{{end}}