118 lines
3.0 KiB
Go
118 lines
3.0 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"mal/integrations/jikan"
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
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) * collaborativeWeight) +
|
|
(profileSearchScore * profileSearchWeight)
|
|
}
|
|
|
|
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(
|
|
now time.Time,
|
|
profile userTasteProfile,
|
|
candidate jikan.Anime,
|
|
collaborativeScore float64,
|
|
profileSearchScore 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 := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
|
score += genreScore * genreMatchWeight
|
|
score += themeScore * themeMatchWeight
|
|
score += studioScore * studioMatchWeight
|
|
score += demographicScore * demographicMatchWeight
|
|
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
|
|
|
return recommendationCandidate{
|
|
anime: candidate,
|
|
score: score,
|
|
genreMatches: genreMatches,
|
|
themeMatches: themeMatches,
|
|
studioMatches: studioMatches,
|
|
demographicMatches: demographicMatches,
|
|
}
|
|
}
|
|
|
|
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
|
|
var score float64
|
|
|
|
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 && isRecentCandidate(now, candidate.Year) {
|
|
score += 0.45
|
|
}
|
|
if isClassicCandidate(now, candidate.Year) {
|
|
score -= 0.2
|
|
}
|
|
if candidate.Status == "Not yet aired" {
|
|
score -= 0.35
|
|
}
|
|
if isFreshRelease(now, candidate.Aired.From) {
|
|
score += 0.3
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
func isRecentCandidate(now time.Time, year int) bool {
|
|
return year > 0 && now.Year()-year <= 4
|
|
}
|
|
|
|
func isClassicCandidate(now time.Time, year int) bool {
|
|
return year > 0 && now.Year()-year > 15
|
|
}
|
|
|
|
func isFreshRelease(now time.Time, airedFrom string) bool {
|
|
if airedFrom == "" {
|
|
return false
|
|
}
|
|
|
|
airedAt, err := time.Parse(time.RFC3339, airedFrom)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return now.Sub(airedAt) <= freshReleaseWindow
|
|
}
|
|
|
|
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
|
var matches int
|
|
var score float64
|
|
|
|
for _, entity := range entities {
|
|
weight, ok := weights[entity.MalID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
matches++
|
|
score += weight
|
|
}
|
|
|
|
return matches, score
|
|
}
|