refactor: split recommendation engine into subpackage
This commit is contained in:
171
internal/anime/recommendations/profile.go
Normal file
171
internal/anime/recommendations/profile.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func buildRecommendationSeeds(now time.Time, watchlist []db.GetUserWatchListRow) []recommendationSeed {
|
||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), maxSeeds))
|
||||
|
||||
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) >= maxSeeds {
|
||||
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()/seedRecencyWindow.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(now time.Time, seeds []recommendationSeed, 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 totalWeight float64
|
||||
var airingWeight float64
|
||||
var recentWeight float64
|
||||
|
||||
for i, anime := range seedAnimes {
|
||||
seedWeight := 1.0
|
||||
if i < len(seeds) && seeds[i].weight > 0 {
|
||||
seedWeight = seeds[i].weight
|
||||
}
|
||||
|
||||
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
||||
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
||||
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
||||
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
||||
|
||||
if anime.Airing {
|
||||
airingWeight += seedWeight
|
||||
}
|
||||
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||
recentWeight += seedWeight
|
||||
}
|
||||
totalWeight += seedWeight
|
||||
}
|
||||
|
||||
if totalWeight > 0 {
|
||||
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||
profile.prefersRecent = recentWeight/totalWeight >= 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 buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||
queries := make([]profileSearchQuery, 0, 6)
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.genres, profileGenreSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.themes, profileThemeSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
studioID: entity.id,
|
||||
weight: entity.weight * 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
||||
if limit <= 0 || len(weights) == 0 {
|
||||
return []weightedEntity{}
|
||||
}
|
||||
|
||||
items := make([]weightedEntity, 0, len(weights))
|
||||
for id, weight := range weights {
|
||||
if id <= 0 || weight <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, weightedEntity{id: id, weight: weight})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].weight == items[j].weight {
|
||||
return items[i].id < items[j].id
|
||||
}
|
||||
return items[i].weight > items[j].weight
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
Reference in New Issue
Block a user