163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
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 (4–5 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
|
|||
|
|
}
|