Files
mal/internal/anime/recommendations.go

270 lines
6.3 KiB
Go

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)
}