fix(recommendations): improve taste seed cold start ranking

Closes #66
This commit is contained in:
Quick
2026-06-07 17:42:52 -04:00
parent ecd85f9ca1
commit 480ca44306
4 changed files with 81 additions and 36 deletions
+5 -5
View File
@@ -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{}})
+34 -27
View File
@@ -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)
}
+38
View File
@@ -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)
}
}
+4 -4
View File
@@ -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({