Files
silo-server/internal/recommendations/collaborative.go
T

163 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package recommendations
import (
"context"
"fmt"
"sort"
)
type collaborativeCandidate struct {
score float64
support int
}
func addCollaborativeSupport(candidates map[string]collaborativeCandidate, itemID string, score float64) {
candidate := candidates[itemID]
candidate.score += score
candidate.support++
candidates[itemID] = candidate
}
// SimilarUsersLiked returns items highly rated or favorited by users with
// similar taste profiles. Scores are weighted by the similarity of each peer
// user to the requesting user. Items already rated or watched by the target
// user are filtered out. Applies MMR re-ranking for diversity.
func (e *Engine) SimilarUsersLiked(ctx context.Context, userID int, profileID string, limit int) ([]ScoredItem, error) {
meta, err := e.repo.GetTasteProfileMeta(ctx, userID, profileID)
if err != nil {
return nil, fmt.Errorf("get taste profile meta for user %d profile %s: %w", userID, profileID, err)
}
maxContentRating := ""
if meta != nil {
maxContentRating = meta.MaxContentRating
}
similarUsers, err := e.repo.FindSimilarUsers(ctx, userID, profileID, maxContentRating, 10)
if err != nil {
return nil, fmt.Errorf("find similar users for user %d profile %s: %w", userID, profileID, err)
}
if len(similarUsers) == 0 {
return nil, nil
}
candidates := make(map[string]collaborativeCandidate)
for _, su := range similarUsers {
similarity := su.Score
peerWeights := make(map[string]float64)
// Collect highly-rated items (45 stars) from this similar user.
ratings, err := e.ratingsRepo.List(ctx, su.UserID, su.ProfileID, 100, 0)
if err != nil {
return nil, fmt.Errorf("list ratings for similar user %d profile %s: %w", su.UserID, su.ProfileID, err)
}
for _, r := range ratings {
var weight float64
switch {
case r.Rating == 5:
weight = WeightRated5
case r.Rating == 4:
weight = WeightRated4
default:
continue
}
if existing, ok := peerWeights[r.MediaItemID]; !ok || weight > existing {
peerWeights[r.MediaItemID] = weight
}
}
// Collect favorited items from this similar user.
store, err := e.storeProvider.ForUser(ctx, su.UserID)
if err != nil {
return nil, fmt.Errorf("get store for similar user %d: %w", su.UserID, err)
}
favorites, err := store.ListFavorites(ctx, su.ProfileID, 100, 0)
if err != nil {
return nil, fmt.Errorf("list favorites for similar user %d profile %s: %w", su.UserID, su.ProfileID, err)
}
for _, f := range favorites {
if existing, ok := peerWeights[f.MediaItemID]; !ok || WeightFavorited > existing {
peerWeights[f.MediaItemID] = WeightFavorited
}
}
for itemID, weight := range peerWeights {
addCollaborativeSupport(candidates, itemID, similarity*weight)
}
}
if len(candidates) == 0 {
return nil, nil
}
// Build list of candidate item IDs for filtering.
candidateIDs := make([]string, 0, len(candidates))
for id := range candidates {
candidateIDs = append(candidateIDs, id)
}
// Filter out items the target user has already rated.
ratedMap, err := e.ratingsRepo.ListForItems(ctx, userID, profileID, candidateIDs)
if err != nil {
return nil, fmt.Errorf("list rated items for filtering: %w", err)
}
// Build scored result list, excluding already-rated or already-watched items.
results := make([]ScoredItem, 0, len(candidates))
supportCounts := make(map[string]int, len(candidates))
for id, candidate := range candidates {
if _, rated := ratedMap[id]; rated {
continue
}
supportCounts[id] = candidate.support
results = append(results, ScoredItem{
MediaItemID: id,
Score: candidate.score,
Reason: "similar_users_liked",
})
}
watchedSet, err := e.watchedItemIDSet(ctx, userID, profileID)
if err != nil {
return nil, fmt.Errorf("get watched items for user %d profile %s: %w", userID, profileID, err)
}
results = excludeScoredItems(results, watchedSet)
// Sort by score descending.
sort.Slice(results, func(i, j int) bool {
if results[i].Score != results[j].Score {
return results[i].Score > results[j].Score
}
if supportCounts[results[i].MediaItemID] != supportCounts[results[j].MediaItemID] {
return supportCounts[results[i].MediaItemID] > supportCounts[results[j].MediaItemID]
}
return results[i].MediaItemID < results[j].MediaItemID
})
// Apply MMR re-ranking for diversity.
if len(results) > limit*3 {
results = results[:limit*3]
}
resultIDs := make([]string, len(results))
for i, item := range results {
resultIDs[i] = item.MediaItemID
}
embMap, _ := e.repo.GetBatchEmbeddings(ctx, resultIDs)
results = applyMMR(results, embMap, e.mmrLambda(LambdaSimilarUsers), limit)
// Apply genre cap to "Similar Users Liked" for cross-genre diversity.
genres, _ := e.repo.GetItemGenres(ctx, resultIDs)
results = applyGenreCap(results, genres, GenreCapPercent)
if len(results) > limit {
results = results[:limit]
}
return results, nil
}