feat: add profile search query builders and weighted scoring
This commit is contained in:
@@ -6,15 +6,21 @@ import (
|
|||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"math"
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
forYouMaxSeeds = 8
|
forYouMaxSeeds = 8
|
||||||
forYouMaxRecommendations = 8
|
forYouMaxRecommendations = 10
|
||||||
forYouCandidateFetchLimit = 36
|
forYouCandidateFetchLimit = 60
|
||||||
forYouResultLimit = 12
|
forYouResultLimit = 18
|
||||||
|
forYouProfileSearchLimit = 8
|
||||||
|
forYouProfileGenreSearches = 2
|
||||||
|
forYouProfileThemeSearches = 2
|
||||||
|
forYouCollaborativeWeight = 1.4
|
||||||
|
forYouProfileSearchWeight = 0.8
|
||||||
forYouSeedRecencyWindow = 180 * 24 * time.Hour
|
forYouSeedRecencyWindow = 180 * 24 * time.Hour
|
||||||
forYouFreshReleaseWindow = 540 * 24 * time.Hour
|
forYouFreshReleaseWindow = 540 * 24 * time.Hour
|
||||||
forYouGenreMatchWeight = 1.8
|
forYouGenreMatchWeight = 1.8
|
||||||
@@ -28,6 +34,17 @@ type recommendationSeed struct {
|
|||||||
weight float64
|
weight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type weightedEntity struct {
|
||||||
|
id int
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileSearchQuery struct {
|
||||||
|
genreIDs []int
|
||||||
|
studioID int
|
||||||
|
weight float64
|
||||||
|
}
|
||||||
|
|
||||||
type recommendationCandidate struct {
|
type recommendationCandidate struct {
|
||||||
anime jikan.Anime
|
anime jikan.Anime
|
||||||
score float64
|
score float64
|
||||||
@@ -101,7 +118,11 @@ func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) floa
|
|||||||
return statusWeight * recencyWeight * progressWeight
|
return statusWeight * recencyWeight * progressWeight
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTasteProfile(seedAnimes []jikan.Anime) userTasteProfile {
|
func buildTasteProfile(
|
||||||
|
now time.Time,
|
||||||
|
seeds []recommendationSeed,
|
||||||
|
seedAnimes []jikan.Anime,
|
||||||
|
) userTasteProfile {
|
||||||
profile := userTasteProfile{
|
profile := userTasteProfile{
|
||||||
genres: make(map[int]float64),
|
genres: make(map[int]float64),
|
||||||
themes: make(map[int]float64),
|
themes: make(map[int]float64),
|
||||||
@@ -109,27 +130,33 @@ func buildTasteProfile(seedAnimes []jikan.Anime) userTasteProfile {
|
|||||||
demographics: make(map[int]float64),
|
demographics: make(map[int]float64),
|
||||||
}
|
}
|
||||||
|
|
||||||
var airingCount int
|
var totalWeight float64
|
||||||
var recentCount int
|
var airingWeight float64
|
||||||
|
var recentWeight float64
|
||||||
|
|
||||||
for _, anime := range seedAnimes {
|
for i, anime := range seedAnimes {
|
||||||
addEntityWeights(profile.genres, anime.Genres, 1.0)
|
seedWeight := 1.0
|
||||||
addEntityWeights(profile.themes, anime.Themes, 0.7)
|
if i < len(seeds) && seeds[i].weight > 0 {
|
||||||
addEntityWeights(profile.studios, anime.Studios, 0.5)
|
seedWeight = seeds[i].weight
|
||||||
addEntityWeights(profile.demographics, anime.Demographics, 0.7)
|
}
|
||||||
|
|
||||||
|
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 {
|
if anime.Airing {
|
||||||
airingCount++
|
airingWeight += seedWeight
|
||||||
}
|
}
|
||||||
if anime.Year > 0 && time.Now().Year()-anime.Year <= 4 {
|
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||||
recentCount++
|
recentWeight += seedWeight
|
||||||
}
|
}
|
||||||
|
totalWeight += seedWeight
|
||||||
}
|
}
|
||||||
|
|
||||||
total := len(seedAnimes)
|
if totalWeight > 0 {
|
||||||
if total > 0 {
|
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||||
profile.prefersAiring = float64(airingCount)/float64(total) >= 0.5
|
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
||||||
profile.prefersRecent = float64(recentCount)/float64(total) >= 0.5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
@@ -144,18 +171,95 @@ func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||||
|
queries := make([]profileSearchQuery, 0, 6)
|
||||||
|
|
||||||
|
for _, entity := range strongestWeightedEntities(profile.genres, forYouProfileGenreSearches) {
|
||||||
|
queries = append(queries, profileSearchQuery{
|
||||||
|
genreIDs: []int{entity.id},
|
||||||
|
weight: entity.weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range strongestWeightedEntities(profile.themes, forYouProfileThemeSearches) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileSearchRankWeight(rank int) float64 {
|
||||||
|
return math.Max(0.35, 1-(float64(rank)*0.08))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
||||||
|
return (math.Log1p(collaborativeScore) * forYouCollaborativeWeight) +
|
||||||
|
(profileSearchScore * forYouProfileSearchWeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTasteMetadata(anime jikan.Anime) bool {
|
||||||
|
return len(anime.Genres) > 0 ||
|
||||||
|
len(anime.Themes) > 0 ||
|
||||||
|
len(anime.Studios) > 0 ||
|
||||||
|
len(anime.Demographics) > 0
|
||||||
|
}
|
||||||
|
|
||||||
func scoreRecommendationCandidate(
|
func scoreRecommendationCandidate(
|
||||||
now time.Time,
|
now time.Time,
|
||||||
profile userTasteProfile,
|
profile userTasteProfile,
|
||||||
candidate jikan.Anime,
|
candidate jikan.Anime,
|
||||||
collaborativeScore float64,
|
collaborativeScore float64,
|
||||||
|
profileSearchScore float64,
|
||||||
) recommendationCandidate {
|
) recommendationCandidate {
|
||||||
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||||
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||||
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||||
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||||
|
|
||||||
score := collaborativeScore
|
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
||||||
score += genreScore * forYouGenreMatchWeight
|
score += genreScore * forYouGenreMatchWeight
|
||||||
score += themeScore * forYouThemeMatchWeight
|
score += themeScore * forYouThemeMatchWeight
|
||||||
score += studioScore * forYouStudioMatchWeight
|
score += studioScore * forYouStudioMatchWeight
|
||||||
|
|||||||
Reference in New Issue
Block a user