545 lines
13 KiB
Go
545 lines
13 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// weightedAverage computes a weighted average of embedding vectors. Each entry
|
|
// in vecs is scaled by the corresponding weight in weights. The result is
|
|
// L2-normalised. Returns nil when no items contribute a non-zero weight or
|
|
// when all embeddings are nil.
|
|
func weightedAverage(vecs [][]float32, weights []float64) []float32 {
|
|
if len(vecs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var dims int
|
|
for _, v := range vecs {
|
|
if v != nil {
|
|
dims = len(v)
|
|
break
|
|
}
|
|
}
|
|
if dims == 0 {
|
|
return nil
|
|
}
|
|
|
|
sum := make([]float64, dims)
|
|
totalAbsWeight := 0.0
|
|
|
|
for i, v := range vecs {
|
|
if v == nil || i >= len(weights) {
|
|
continue
|
|
}
|
|
w := weights[i]
|
|
if w == 0 {
|
|
continue
|
|
}
|
|
totalAbsWeight += math.Abs(w)
|
|
for j, val := range v {
|
|
sum[j] += float64(val) * w
|
|
}
|
|
}
|
|
|
|
if totalAbsWeight == 0 {
|
|
return nil
|
|
}
|
|
|
|
// L2 normalise.
|
|
var norm float64
|
|
for _, v := range sum {
|
|
norm += v * v
|
|
}
|
|
norm = math.Sqrt(norm)
|
|
if norm == 0 {
|
|
return nil
|
|
}
|
|
|
|
result := make([]float32, dims)
|
|
for i, v := range sum {
|
|
result[i] = float32(v / norm)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// timeDecay computes an exponential decay factor for a signal recorded at signalTime.
|
|
// halfLifeDays controls how fast signals lose weight. Floor is 10% of original.
|
|
func timeDecay(signalTime time.Time, now time.Time, halfLifeDays float64) float64 {
|
|
if halfLifeDays <= 0 {
|
|
return 1.0
|
|
}
|
|
lambda := math.Ln2 / halfLifeDays
|
|
daysSince := now.Sub(signalTime).Hours() / 24.0
|
|
if daysSince < 0 {
|
|
daysSince = 0
|
|
}
|
|
factor := math.Exp(-lambda * daysSince)
|
|
if factor < 0.1 {
|
|
factor = 0.1 // 10% floor
|
|
}
|
|
return factor
|
|
}
|
|
|
|
// contentRatingScore returns a numeric precedence for a content rating so
|
|
// ratings can be compared by strictness. Higher score = more restrictive.
|
|
func contentRatingScore(rating string) int {
|
|
scores := map[string]int{
|
|
"NC-17": 7,
|
|
"TV-MA": 6,
|
|
"R": 5,
|
|
"TV-14": 4,
|
|
"PG-13": 3,
|
|
"TV-PG": 2,
|
|
"PG": 2,
|
|
"G": 1,
|
|
"TV-G": 1,
|
|
"TV-Y": 0,
|
|
}
|
|
if s, ok := scores[rating]; ok {
|
|
return s
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// maxContentRatingFromSets derives the highest content rating among
|
|
// positively-signaled and completed canonical items.
|
|
func maxContentRatingFromSets(ratedSet, completedSet, favSet map[string]struct{}, itemContentRatings map[string]string) string {
|
|
best := ""
|
|
bestScore := -1
|
|
|
|
check := func(id string) {
|
|
cr, ok := itemContentRatings[id]
|
|
if !ok || cr == "" {
|
|
return
|
|
}
|
|
s := contentRatingScore(cr)
|
|
if s > bestScore {
|
|
bestScore = s
|
|
best = cr
|
|
}
|
|
}
|
|
|
|
for id := range ratedSet {
|
|
check(id)
|
|
}
|
|
for id := range completedSet {
|
|
check(id)
|
|
}
|
|
for id := range favSet {
|
|
check(id)
|
|
}
|
|
return best
|
|
}
|
|
|
|
// RefreshTasteProfile rebuilds the taste profile embedding for the given user
|
|
// and profile by aggregating signal weights from ratings, favorites, watchlist,
|
|
// watch progress, and rewatches. Applies time decay and builds clustered
|
|
// sub-profiles.
|
|
func (e *Engine) RefreshTasteProfile(ctx context.Context, userID int, profileID string) error {
|
|
store, err := e.storeProvider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("get user store for user %d: %w", userID, err)
|
|
}
|
|
|
|
ratings, err := e.ratingsRepo.List(ctx, userID, profileID, 1000, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("list ratings: %w", err)
|
|
}
|
|
|
|
favorites, err := store.ListFavorites(ctx, profileID, 1000, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("list favorites: %w", err)
|
|
}
|
|
|
|
watchlist, err := store.ListWatchlist(ctx, profileID, 1000, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("list watchlist: %w", err)
|
|
}
|
|
|
|
watchProgress, err := e.signalReader().WatchProgressForUser(ctx, userID, profileID)
|
|
if err != nil {
|
|
return fmt.Errorf("get watch progress: %w", err)
|
|
}
|
|
|
|
rewatchCounts, err := e.signalReader().RewatchCounts(ctx, userID, profileID)
|
|
if err != nil {
|
|
return fmt.Errorf("get rewatch counts: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
halfLife := e.cfg.TasteDecayHalfLifeDays
|
|
if halfLife == 0 {
|
|
halfLife = 180
|
|
}
|
|
|
|
rawIDSet := make(map[string]struct{})
|
|
for _, r := range ratings {
|
|
rawIDSet[r.MediaItemID] = struct{}{}
|
|
}
|
|
for _, f := range favorites {
|
|
rawIDSet[f.MediaItemID] = struct{}{}
|
|
}
|
|
for _, w := range watchlist {
|
|
rawIDSet[w.MediaItemID] = struct{}{}
|
|
}
|
|
for _, wp := range watchProgress {
|
|
rawIDSet[wp.MediaItemID] = struct{}{}
|
|
}
|
|
for _, rc := range rewatchCounts {
|
|
rawIDSet[rc.MediaItemID] = struct{}{}
|
|
}
|
|
|
|
rawIDs := make([]string, 0, len(rawIDSet))
|
|
for id := range rawIDSet {
|
|
rawIDs = append(rawIDs, id)
|
|
}
|
|
|
|
refs, err := e.repo.ResolveCanonicalContentRefs(ctx, rawIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve canonical content refs: %w", err)
|
|
}
|
|
|
|
signalCounts := make(map[string]int)
|
|
signals := make(map[string]*canonicalWeightComponents)
|
|
ensureSignal := func(id string) *canonicalWeightComponents {
|
|
if s, ok := signals[id]; ok {
|
|
return s
|
|
}
|
|
s := &canonicalWeightComponents{}
|
|
signals[id] = s
|
|
return s
|
|
}
|
|
ratedSet := make(map[string]struct{})
|
|
|
|
for _, r := range ratings {
|
|
ref, ok := refs[r.MediaItemID]
|
|
if !ok || !ref.isCanonicalTasteItem() {
|
|
continue
|
|
}
|
|
|
|
s := ensureSignal(ref.CanonicalID)
|
|
rating := r.Rating
|
|
s.Rating = &rating
|
|
ratedSet[ref.CanonicalID] = struct{}{}
|
|
|
|
decay := timeDecay(r.RatedAt, now, halfLife)
|
|
switch {
|
|
case r.Rating == 5:
|
|
s.ExplicitWeight += WeightRated5 * decay
|
|
signalCounts["rated_5"]++
|
|
case r.Rating == 4:
|
|
s.ExplicitWeight += WeightRated4 * decay
|
|
signalCounts["rated_4"]++
|
|
case r.Rating == 3:
|
|
s.ExplicitWeight += WeightRated3 * decay
|
|
signalCounts["rated_3"]++
|
|
default:
|
|
s.ExplicitWeight += WeightRatedLow * decay
|
|
signalCounts["rated_low"]++
|
|
}
|
|
}
|
|
|
|
for _, wp := range watchProgress {
|
|
ref, ok := refs[wp.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
weight, ok := implicitWatchWeight(wp)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch weight {
|
|
case WeightWatchHigh:
|
|
signalCounts["watch_high"]++
|
|
case WeightWatchMed:
|
|
signalCounts["watch_med"]++
|
|
case WeightWatchLow:
|
|
signalCounts["watch_low"]++
|
|
}
|
|
}
|
|
|
|
for _, rc := range rewatchCounts {
|
|
ref, ok := refs[rc.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" || rc.Count < 2 {
|
|
continue
|
|
}
|
|
signalCounts["rewatch"]++
|
|
}
|
|
|
|
implicitSignals, completedSet := buildCanonicalImplicitSignals(watchProgress, rewatchCounts, refs, now, halfLife)
|
|
for canonicalID, weight := range implicitSignals {
|
|
ensureSignal(canonicalID).ImplicitWeight += weight
|
|
}
|
|
|
|
favSet := make(map[string]struct{}, len(favorites))
|
|
for _, f := range favorites {
|
|
ref, ok := refs[f.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
favSet[ref.CanonicalID] = struct{}{}
|
|
s := ensureSignal(ref.CanonicalID)
|
|
s.IntentWeight += WeightFavorited * timeDecay(parseSignalTime(f.AddedAt, now), now, halfLife)
|
|
signalCounts["favorited"]++
|
|
}
|
|
|
|
for _, w := range watchlist {
|
|
ref, ok := refs[w.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
s := ensureSignal(ref.CanonicalID)
|
|
s.IntentWeight += WeightWatchlist * timeDecay(parseSignalTime(w.AddedAt, now), now, halfLife)
|
|
signalCounts["watchlist"]++
|
|
}
|
|
|
|
if len(signals) == 0 {
|
|
return nil
|
|
}
|
|
|
|
allIDs := make([]string, 0, len(signals))
|
|
for id := range signals {
|
|
allIDs = append(allIDs, id)
|
|
}
|
|
|
|
items, err := e.itemRepo.GetByIDs(ctx, allIDs)
|
|
if err != nil {
|
|
slog.Error("taste: failed to fetch items for genre enrichment, cluster labels will be degraded",
|
|
"user_id", userID, "profile_id", profileID,
|
|
"id_count", len(allIDs), "error", err)
|
|
} else {
|
|
itemMap := make(map[string]*models.MediaItem, len(items))
|
|
for _, item := range items {
|
|
itemMap[item.ContentID] = item
|
|
}
|
|
for id, s := range signals {
|
|
if item, ok := itemMap[id]; ok {
|
|
s.Genres = item.Genres
|
|
}
|
|
}
|
|
}
|
|
|
|
embMap, err := e.repo.GetBatchEmbeddings(ctx, allIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("get batch embeddings: %w", err)
|
|
}
|
|
|
|
vecs := make([][]float32, 0, len(signals))
|
|
embWeights := make([]float64, 0, len(signals))
|
|
var positiveItems []clusterItem
|
|
|
|
signalIDs := make([]string, 0, len(signals))
|
|
for id := range signals {
|
|
signalIDs = append(signalIDs, id)
|
|
}
|
|
sort.Strings(signalIDs)
|
|
|
|
for _, id := range signalIDs {
|
|
s := signals[id]
|
|
emb, ok := embMap[id]
|
|
if !ok || emb == nil {
|
|
continue
|
|
}
|
|
finalWeight := combineCanonicalWeight(s.Rating, s.ExplicitWeight, s.ImplicitWeight, s.IntentWeight)
|
|
if finalWeight == 0 {
|
|
continue
|
|
}
|
|
|
|
vecs = append(vecs, emb)
|
|
embWeights = append(embWeights, finalWeight)
|
|
|
|
if finalWeight > 0 {
|
|
positiveItems = append(positiveItems, clusterItem{
|
|
itemID: id,
|
|
embedding: emb,
|
|
weight: finalWeight,
|
|
genres: s.Genres,
|
|
})
|
|
}
|
|
}
|
|
|
|
profile := weightedAverage(vecs, embWeights)
|
|
if profile == nil {
|
|
return nil
|
|
}
|
|
|
|
maxContentRating := ""
|
|
if len(allIDs) > 0 && items != nil {
|
|
crMap := make(map[string]string, len(items))
|
|
for _, item := range items {
|
|
crMap[item.ContentID] = item.ContentRating
|
|
}
|
|
maxContentRating = maxContentRatingFromSets(ratedSet, completedSet, favSet, crMap)
|
|
}
|
|
|
|
if err := e.repo.UpsertTasteProfile(ctx, userID, profileID, profile, signalCounts, maxContentRating); err != nil {
|
|
return fmt.Errorf("upsert taste profile: %w", err)
|
|
}
|
|
|
|
if len(positiveItems) > 0 {
|
|
clusters := buildTasteClusters(positiveItems)
|
|
for i := range clusters {
|
|
clusters[i].UserID = userID
|
|
clusters[i].ProfileID = profileID
|
|
}
|
|
if err := e.repo.UpsertTasteClusters(ctx, userID, profileID, clusters); err != nil {
|
|
return fmt.Errorf("upsert taste clusters: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetTasteProfileSummary returns a human-readable summary of the user's taste
|
|
// profile including top genres and directors.
|
|
func (e *Engine) GetTasteProfileSummary(ctx context.Context, userID int, profileID string) (*TasteProfileSummary, error) {
|
|
meta, err := e.repo.GetTasteProfileMeta(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get taste profile meta: %w", err)
|
|
}
|
|
if meta == nil {
|
|
return &TasteProfileSummary{
|
|
TopGenres: []string{},
|
|
FavoriteDirectors: []string{},
|
|
SignalCounts: map[string]int{},
|
|
UpdatedAt: "",
|
|
}, nil
|
|
}
|
|
|
|
// Gather top-rated and favorited item IDs to derive genre/director preferences.
|
|
ratings, err := e.ratingsRepo.List(ctx, userID, profileID, 1000, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list ratings for summary: %w", err)
|
|
}
|
|
|
|
store, err := e.storeProvider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user store for summary: %w", err)
|
|
}
|
|
|
|
favorites, err := store.ListFavorites(ctx, profileID, 1000, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list favorites for summary: %w", err)
|
|
}
|
|
|
|
rawIDSet := make(map[string]struct{})
|
|
for _, r := range ratings {
|
|
if r.Rating >= 4 {
|
|
rawIDSet[r.MediaItemID] = struct{}{}
|
|
}
|
|
}
|
|
for _, f := range favorites {
|
|
rawIDSet[f.MediaItemID] = struct{}{}
|
|
}
|
|
|
|
rawIDs := make([]string, 0, len(rawIDSet))
|
|
for id := range rawIDSet {
|
|
rawIDs = append(rawIDs, id)
|
|
}
|
|
|
|
refs, err := e.repo.ResolveCanonicalContentRefs(ctx, rawIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve canonical summary refs: %w", err)
|
|
}
|
|
|
|
idSet := make(map[string]struct{})
|
|
for _, r := range ratings {
|
|
if r.Rating < 4 {
|
|
continue
|
|
}
|
|
ref, ok := refs[r.MediaItemID]
|
|
if !ok || !ref.isCanonicalTasteItem() {
|
|
continue
|
|
}
|
|
idSet[ref.CanonicalID] = struct{}{}
|
|
}
|
|
for _, f := range favorites {
|
|
ref, ok := refs[f.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
idSet[ref.CanonicalID] = struct{}{}
|
|
}
|
|
|
|
topGenres := []string{}
|
|
topDirectors := []string{}
|
|
|
|
if len(idSet) > 0 {
|
|
ids := make([]string, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
items, err := e.itemRepo.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get items for summary: %w", err)
|
|
}
|
|
|
|
genreCounts := make(map[string]int)
|
|
directorCounts := make(map[string]int)
|
|
|
|
for _, item := range items {
|
|
for _, g := range item.Genres {
|
|
genreCounts[g]++
|
|
}
|
|
for _, p := range item.People {
|
|
if p.Kind == models.PersonKindDirector {
|
|
directorCounts[p.Name]++
|
|
}
|
|
}
|
|
}
|
|
|
|
topGenres = topN(genreCounts, 5)
|
|
topDirectors = topN(directorCounts, 5)
|
|
}
|
|
|
|
return &TasteProfileSummary{
|
|
TopGenres: topGenres,
|
|
FavoriteDirectors: topDirectors,
|
|
SignalCounts: meta.SignalCounts,
|
|
UpdatedAt: meta.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// topN returns up to n keys from counts, ordered by count descending.
|
|
func topN(counts map[string]int, n int) []string {
|
|
type kv struct {
|
|
key string
|
|
count int
|
|
}
|
|
|
|
pairs := make([]kv, 0, len(counts))
|
|
for k, v := range counts {
|
|
pairs = append(pairs, kv{k, v})
|
|
}
|
|
|
|
// Simple selection sort — n is always small (≤ 5).
|
|
for i := 0; i < len(pairs) && i < n; i++ {
|
|
maxIdx := i
|
|
for j := i + 1; j < len(pairs); j++ {
|
|
if pairs[j].count > pairs[maxIdx].count {
|
|
maxIdx = j
|
|
}
|
|
}
|
|
pairs[i], pairs[maxIdx] = pairs[maxIdx], pairs[i]
|
|
}
|
|
|
|
end := n
|
|
if end > len(pairs) {
|
|
end = len(pairs)
|
|
}
|
|
|
|
result := make([]string, end)
|
|
for i := 0; i < end; i++ {
|
|
result[i] = pairs[i].key
|
|
}
|
|
return result
|
|
}
|