diff --git a/internal/api/handlers/taste_seed.go b/internal/api/handlers/taste_seed.go index e5ca3204..ef70b37b 100644 --- a/internal/api/handlers/taste_seed.go +++ b/internal/api/handlers/taste_seed.go @@ -32,11 +32,11 @@ type tasteSeedSubmitResponse struct { // HandleTasteSeedItems handles GET /recommendations/taste-seed/items. // -// Returns a paginated, hydrated list of "popular on this server" posters used -// for the new-user taste-seeding picker. Blends server-watched popularity with -// TMDB rating so fresh servers (no watch history yet) still surface meaningful -// content. The user_state field carries the existing is_favorite flag, so the -// UI can pre-select items the profile already favorited. +// Returns a paginated, hydrated list of posters used for the new-user +// taste-seeding picker. Blends server-watched popularity with rating reliability +// and recency so fresh servers (no watch history yet) still surface recognizable +// content. The user_state field carries the existing is_favorite flag, so the UI +// can pre-select items the profile already favorited. func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r *http.Request) { if h.recsRepo == nil || h.Fetcher == nil { writeJSON(w, http.StatusOK, tasteSeedItemsResponse{Items: []sectionItemResponse{}}) diff --git a/internal/recommendations/repo.go b/internal/recommendations/repo.go index 22c5b374..47d7920a 100644 --- a/internal/recommendations/repo.go +++ b/internal/recommendations/repo.go @@ -39,6 +39,35 @@ func ensureCanonicalDimensions(vec []float32) ([]float32, error) { const embeddingLockSettingKey = "recommendations.embedding_lock" const minHNSWEfSearch = 200 +const tasteSeedCandidateQuery = ` + WITH watched_counts AS ( + SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id, + COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count + FROM user_watch_progress wp + LEFT JOIN episodes e ON e.content_id = wp.media_item_id + WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5)) + AND wp.updated_at > NOW() - INTERVAL '180 days' + GROUP BY 1 + ) + SELECT mi.content_id + FROM media_items mi + LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id + WHERE mi.status = 'matched' + AND mi.type IN ('movie', 'series') + AND mi.poster_path IS NOT NULL + AND mi.poster_path <> '' + ORDER BY COALESCE(wc.watch_count, 0) DESC, + CASE + WHEN mi.rating_imdb IS NOT NULL THEN 2 + WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1 + ELSE 0 + END DESC, + mi.rating_imdb DESC NULLS LAST, + CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST, + mi.year DESC NULLS LAST, + mi.content_id ASC + LIMIT $1 OFFSET $2` + // Repo provides database operations for the recommendation system. type Repo struct { pool *pgxpool.Pool @@ -1298,34 +1327,12 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S // GetTasteSeedCandidates returns movie/series content IDs ordered for the // taste-seeding picker: server engagement first (most-watched in the last -// 180 days), then TMDB rating, then recency. This blend ensures fresh servers -// (no watch history) still surface meaningful posters via TMDB ratings while -// established servers prioritize what users actually watch. Episodes are -// resolved to their parent series. Items without a poster are excluded. +// 180 days), then rating reliability and rating score, then recency. This keeps +// fresh servers from front-loading single-vote TMDB 10.0 obscurities while +// established servers prioritize what users actually watch. Episodes are resolved +// to their parent series. Items without a poster are excluded. func (r *Repo) GetTasteSeedCandidates(ctx context.Context, limit, offset int) ([]string, error) { - rows, err := r.pool.Query(ctx, ` - WITH watched_counts AS ( - SELECT COALESCE(e.series_id, wp.media_item_id) AS item_id, - COUNT(DISTINCT wp.user_id::text || ':' || COALESCE(wp.profile_id, '')) AS watch_count - FROM user_watch_progress wp - LEFT JOIN episodes e ON e.content_id = wp.media_item_id - WHERE (wp.completed = true OR (wp.duration_seconds > 0 AND wp.position_seconds / wp.duration_seconds >= 0.5)) - AND wp.updated_at > NOW() - INTERVAL '180 days' - GROUP BY 1 - ) - SELECT mi.content_id - FROM media_items mi - LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id - WHERE mi.status = 'matched' - AND mi.type IN ('movie', 'series') - AND mi.poster_path IS NOT NULL - AND mi.poster_path <> '' - ORDER BY COALESCE(wc.watch_count, 0) DESC, - mi.rating_tmdb DESC NULLS LAST, - mi.year DESC NULLS LAST, - mi.content_id ASC - LIMIT $1 OFFSET $2`, - limit, offset) + rows, err := r.pool.Query(ctx, tasteSeedCandidateQuery, limit, offset) if err != nil { return nil, fmt.Errorf("get taste seed candidates: %w", err) } diff --git a/internal/recommendations/repo_test.go b/internal/recommendations/repo_test.go new file mode 100644 index 00000000..da5e52ca --- /dev/null +++ b/internal/recommendations/repo_test.go @@ -0,0 +1,38 @@ +package recommendations + +import ( + "strings" + "testing" +) + +func TestTasteSeedCandidateQueryOrdersByReliableColdStartSignals(t *testing.T) { + query := strings.Join(strings.Fields(tasteSeedCandidateQuery), " ") + + assertQueryTermsInOrder(t, query, + "ORDER BY COALESCE(wc.watch_count, 0) DESC", + "WHEN mi.rating_imdb IS NOT NULL THEN 2", + "WHEN mi.rating_tmdb IS NOT NULL AND mi.rating_tmdb < 9.5 THEN 1", + "ELSE 0 END DESC", + "mi.rating_imdb DESC NULLS LAST", + "CASE WHEN mi.rating_tmdb < 9.5 THEN mi.rating_tmdb END DESC NULLS LAST", + "mi.year DESC NULLS LAST", + "mi.content_id ASC", + ) + + if strings.Contains(query, "ORDER BY COALESCE(wc.watch_count, 0) DESC, mi.rating_tmdb DESC NULLS LAST") { + t.Fatal("taste seed query must not rank cold-start candidates by raw TMDB rating directly") + } +} + +func assertQueryTermsInOrder(t *testing.T, query string, terms ...string) { + t.Helper() + + searchFrom := 0 + for _, term := range terms { + idx := strings.Index(query[searchFrom:], term) + if idx < 0 { + t.Fatalf("query term %q missing or out of order in query:\n%s", term, query) + } + searchFrom += idx + len(term) + } +} diff --git a/web/src/hooks/queries/tasteSeed.ts b/web/src/hooks/queries/tasteSeed.ts index ad61531d..853bd4cf 100644 --- a/web/src/hooks/queries/tasteSeed.ts +++ b/web/src/hooks/queries/tasteSeed.ts @@ -17,10 +17,10 @@ interface TasteSeedSubmitResponse { const TASTE_SEED_PAGE_SIZE = 30; /** - * Fetches popular catalog posters for the taste-seeding picker. Blends - * server-engagement popularity with TMDB rating so even a fresh server with no - * watch history surfaces meaningful posters. Items already favorited by the - * profile carry user_state.is_favorite=true so the UI can pre-select them. + * Fetches catalog posters for the taste-seeding picker. Blends server engagement + * with rating reliability and recency so even a fresh server with no watch + * history surfaces meaningful posters. Items already favorited by the profile + * carry user_state.is_favorite=true so the UI can pre-select them. */ export function useTasteSeedItems(enabled = true) { return useInfiniteQuery({