163 lines
4.3 KiB
Go
163 lines
4.3 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// coldStartLevel returns the cold-start graduation level based on positive
|
|
// signal count. Higher levels indicate more personalization is appropriate.
|
|
//
|
|
// 0 signals → level 0 (100% non-personalized)
|
|
// 1-4 → level 1 (non-personalized + one personal row)
|
|
// 5-14 → level 2 (50/50 mix)
|
|
// 15+ → level 3 (fully personalized)
|
|
func coldStartLevel(positiveSignalCount int) int {
|
|
switch {
|
|
case positiveSignalCount >= ColdStartFullPersonalized:
|
|
return 3
|
|
case positiveSignalCount >= ColdStartMixed:
|
|
return 2
|
|
case positiveSignalCount >= ColdStartMinimal:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// buildColdStartRows builds the set of non-personalized recommendation rows
|
|
// used during cold-start (and appended to warm profiles for discovery).
|
|
// Rows with empty item slices are omitted.
|
|
func buildColdStartRows(popular, recentlyAdded, topRated []ScoredItem, genreSamplers map[string][]ScoredItem) []ForYouRow {
|
|
var rows []ForYouRow
|
|
|
|
if len(popular) > 0 {
|
|
rows = append(rows, ForYouRow{
|
|
Type: RecTypePopular,
|
|
Label: "Popular on This Server",
|
|
Items: popular,
|
|
})
|
|
}
|
|
|
|
if len(recentlyAdded) > 0 {
|
|
rows = append(rows, ForYouRow{
|
|
Type: RecTypeRecentlyAdded,
|
|
Label: "Recently Added",
|
|
Items: recentlyAdded,
|
|
})
|
|
}
|
|
|
|
if len(topRated) > 0 {
|
|
rows = append(rows, ForYouRow{
|
|
Type: RecTypeTopRated,
|
|
Label: "Top Rated",
|
|
Items: topRated,
|
|
})
|
|
}
|
|
|
|
// Sort genre names for deterministic row order.
|
|
genres := make([]string, 0, len(genreSamplers))
|
|
for g := range genreSamplers {
|
|
genres = append(genres, g)
|
|
}
|
|
sort.Strings(genres)
|
|
|
|
for _, genre := range genres {
|
|
items := genreSamplers[genre]
|
|
if len(items) > 0 {
|
|
rows = append(rows, ForYouRow{
|
|
Type: "genre_sampler",
|
|
Label: "Top " + genre,
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
// mergePersonalizedAndColdStart combines personalized rows with cold-start rows
|
|
// according to the user's cold-start graduation level.
|
|
//
|
|
// Level 0: cold-start rows only
|
|
// Level 1: cold-start rows first, then up to 1 personal row
|
|
// Level 2: interleave personal and cold-start rows (alternating)
|
|
// Level 3: personal rows first, cold-start rows appended at the end
|
|
func mergePersonalizedAndColdStart(personalRows, coldStartRows []ForYouRow, level int) []ForYouRow {
|
|
switch level {
|
|
case 0:
|
|
return coldStartRows
|
|
|
|
case 1:
|
|
limited := personalRows
|
|
if len(limited) > 1 {
|
|
limited = limited[:1]
|
|
}
|
|
merged := make([]ForYouRow, 0, len(coldStartRows)+len(limited))
|
|
merged = append(merged, coldStartRows...)
|
|
merged = append(merged, limited...)
|
|
return merged
|
|
|
|
case 2:
|
|
merged := make([]ForYouRow, 0, len(personalRows)+len(coldStartRows))
|
|
pi, ci := 0, 0
|
|
for pi < len(personalRows) || ci < len(coldStartRows) {
|
|
if pi < len(personalRows) {
|
|
merged = append(merged, personalRows[pi])
|
|
pi++
|
|
}
|
|
if ci < len(coldStartRows) {
|
|
merged = append(merged, coldStartRows[ci])
|
|
ci++
|
|
}
|
|
}
|
|
return merged
|
|
|
|
default: // level 3
|
|
merged := make([]ForYouRow, 0, len(personalRows)+len(coldStartRows))
|
|
merged = append(merged, personalRows...)
|
|
merged = append(merged, coldStartRows...)
|
|
return merged
|
|
}
|
|
}
|
|
|
|
// applyRecencyBoost multiplies the score of recently added items by a boost
|
|
// factor that decays linearly from RecencyBoostMultiplier to 1.0 over
|
|
// RecencyBoostDays. Items not present in addedDates or older than the window
|
|
// are left unchanged. The returned slice is a new copy sorted by descending
|
|
// boosted score.
|
|
func applyRecencyBoost(items []ScoredItem, addedDates map[string]time.Time, now time.Time) []ScoredItem {
|
|
boostWindow := time.Duration(RecencyBoostDays) * 24 * time.Hour
|
|
|
|
boosted := make([]ScoredItem, len(items))
|
|
for i, item := range items {
|
|
boosted[i] = item
|
|
|
|
addedAt, ok := addedDates[item.MediaItemID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
age := now.Sub(addedAt)
|
|
if age < 0 {
|
|
// Added in the future (clock skew) — apply full boost.
|
|
age = 0
|
|
}
|
|
if age >= boostWindow {
|
|
continue
|
|
}
|
|
|
|
// Linear decay: fraction goes from 1.0 (just added) to 0.0 (at window edge).
|
|
fraction := 1.0 - float64(age)/float64(boostWindow)
|
|
// Multiplier ranges from RecencyBoostMultiplier down to 1.0.
|
|
multiplier := 1.0 + (RecencyBoostMultiplier-1.0)*fraction
|
|
boosted[i].Score *= multiplier
|
|
}
|
|
|
|
sort.Slice(boosted, func(i, j int) bool {
|
|
return boosted[i].Score > boosted[j].Score
|
|
})
|
|
|
|
return boosted
|
|
}
|