From c7adf2c87afc7f41d600b8f7098ec1a36f8a500f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 11 Jun 2026 23:55:08 -0400 Subject: [PATCH 1/3] fix(recommendation): broaden You-might-like fallback to liked album/track artists (#790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback pulled artists only from explicit artist-likes (general_likes_artists), but most users like albums and tracks far more than artists — so the artists row still came up thin (a couple of tiles) even with a rich library, while the albums row filled fine. Broaden both fallbacks to "entities you've shown affinity for": - artist fallback = explicit artist-likes ∪ artists of liked albums ∪ artists of liked tracks. - album fallback = explicit album-likes ∪ albums of liked tracks. New dedicated queries (ListYouMightLike{Artist,Album}FallbackForUser) replace the narrow Rediscover-fallback reuse; same projection so the Go layer still converts directly. (Aliased + fully-qualified the UNION arms — sqlc merges UNION scopes, so unqualified user_id was ambiguous across the three like tables.) Test: 12 liked TRACKS by distinct artists, no artist-likes → the artist row now fills from their artists (was empty before). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/dbq/you_might_like.sql.go | 148 ++++++++++++++++++ internal/db/queries/you_might_like.sql | 59 +++++++ internal/recommendation/home.go | 15 +- .../recommendation/home_integration_test.go | 34 ++++ 4 files changed, 249 insertions(+), 7 deletions(-) diff --git a/internal/db/dbq/you_might_like.sql.go b/internal/db/dbq/you_might_like.sql.go index 4f2a98c0..ed4027f1 100644 --- a/internal/db/dbq/you_might_like.sql.go +++ b/internal/db/dbq/you_might_like.sql.go @@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig return err } +const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many +WITH liked_album_ids AS ( + SELECT likb.album_id AS album_id + FROM general_likes_albums likb + WHERE likb.user_id = $1 + UNION + SELECT trk.album_id AS album_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +FROM liked_album_ids lai +JOIN albums ON albums.id = lai.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY md5(albums.id::text || current_date::text) +LIMIT $2 +` + +type ListYouMightLikeAlbumFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeAlbumFallbackForUserRow struct { + Album Album + ArtistName string +} + +// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS +// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser. +func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeAlbumFallbackForUserRow + for rows.Next() { + var i ListYouMightLikeAlbumFallbackForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Album.CoverArtSource, + &i.Album.CoverArtSourcesVersion, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name FROM you_might_like_albums yml @@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou return items, nil } +const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many +WITH liked_artist_ids AS ( + SELECT lika.artist_id AS artist_id + FROM general_likes_artists lika + WHERE lika.user_id = $1 + UNION + SELECT alb.artist_id AS artist_id + FROM general_likes_albums likb + JOIN albums alb ON alb.id = likb.album_id + WHERE likb.user_id = $1 + UNION + SELECT trk.artist_id AS artist_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM liked_artist_ids lai +JOIN artists ON artists.id = lai.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY md5(artists.id::text || current_date::text) +LIMIT $2 +` + +type ListYouMightLikeArtistFallbackForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeArtistFallbackForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// Broad "artists you've shown affinity for" pool to fill a thin +// You-might-like artists row: explicitly liked artists PLUS the artists of +// liked albums and liked tracks. Most users like albums/tracks far more than +// they like artists explicitly, so the union is much deeper than +// general_likes_artists alone. Daily-stable order; same projection as +// ListYouMightLikeArtistsForUser so the Go layer reuses it directly. +func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeArtistFallbackForUserRow + for rows.Next() { + var i ListYouMightLikeArtistFallbackForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.Artist.ArtistThumbPath, + &i.Artist.ArtistFanartPath, + &i.Artist.ArtistArtSource, + &i.Artist.ArtistArtSourcesVersion, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, cov.id AS cover_album_id, diff --git a/internal/db/queries/you_might_like.sql b/internal/db/queries/you_might_like.sql index c6038d5a..5b4697ab 100644 --- a/internal/db/queries/you_might_like.sql +++ b/internal/db/queries/you_might_like.sql @@ -64,3 +64,62 @@ LEFT JOIN LATERAL ( WHERE yml.user_id = $1 ORDER BY yml.rank LIMIT $2; + +-- name: ListYouMightLikeArtistFallbackForUser :many +-- Broad "artists you've shown affinity for" pool to fill a thin +-- You-might-like artists row: explicitly liked artists PLUS the artists of +-- liked albums and liked tracks. Most users like albums/tracks far more than +-- they like artists explicitly, so the union is much deeper than +-- general_likes_artists alone. Daily-stable order; same projection as +-- ListYouMightLikeArtistsForUser so the Go layer reuses it directly. +WITH liked_artist_ids AS ( + SELECT lika.artist_id AS artist_id + FROM general_likes_artists lika + WHERE lika.user_id = $1 + UNION + SELECT alb.artist_id AS artist_id + FROM general_likes_albums likb + JOIN albums alb ON alb.id = likb.album_id + WHERE likb.user_id = $1 + UNION + SELECT trk.artist_id AS artist_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM liked_artist_ids lai +JOIN artists ON artists.id = lai.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY md5(artists.id::text || current_date::text) +LIMIT $2; + +-- name: ListYouMightLikeAlbumFallbackForUser :many +-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS +-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser. +WITH liked_album_ids AS ( + SELECT likb.album_id AS album_id + FROM general_likes_albums likb + WHERE likb.user_id = $1 + UNION + SELECT trk.album_id AS album_id + FROM general_likes likt + JOIN tracks trk ON trk.id = likt.track_id + WHERE likt.user_id = $1 +) +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM liked_album_ids lai +JOIN albums ON albums.id = lai.album_id +JOIN artists ON artists.id = albums.artist_id +ORDER BY md5(albums.id::text || current_date::text) +LIMIT $2; diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go index eab129db..c61c11c5 100644 --- a/internal/recommendation/home.go +++ b/internal/recommendation/home.go @@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom const youMightLikeFallbackFetch = 100 // topUpYouMightLikeArtists fills a thin You-might-like artist row from the -// user's liked artists (daily-stable sample), excluding what's already shown -// on Home (the section itself, Rediscover, Most/Last Played) so the fallback -// neither duplicates a tile nor suggests an actively-played artist. The liked -// pool dwarfs the similarity roll-up, so the same exclusions still fill it. -// Best-effort: a query error leaves the section as-is. +// artists the user has shown affinity for — explicit artist-likes PLUS the +// artists of liked albums and liked tracks (most users like albums/tracks far +// more than artists, so the broad pool is what actually fills the row). +// Excludes what's already shown on Home (the section itself, Rediscover, +// Most/Last Played) so the fallback neither duplicates a tile nor suggests an +// actively-played artist. Best-effort: a query error leaves the section as-is. func topUpYouMightLikeArtists( ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload, ) []dbq.ListYouMightLikeArtistsForUserRow { @@ -218,7 +219,7 @@ func topUpYouMightLikeArtists( for _, r := range result { excluded[r.Artist.ID] = struct{}{} } - fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{ + fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{ UserID: userID, Limit: youMightLikeFallbackFetch, }) if err != nil { @@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums( for _, r := range result { excluded[r.Album.ID] = struct{}{} } - fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{ + fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{ UserID: userID, Limit: youMightLikeFallbackFetch, }) if err != nil { diff --git a/internal/recommendation/home_integration_test.go b/internal/recommendation/home_integration_test.go index 10cf19ae..c4353f28 100644 --- a/internal/recommendation/home_integration_test.go +++ b/internal/recommendation/home_integration_test.go @@ -48,6 +48,40 @@ func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) { } } +// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback +// must surface the artists of LIKED TRACKS even with no explicit artist-likes +// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct +// artists, no artist-likes, no plays → the artist row fills from them. +func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "home-yml-trackartists") + fromLiked := make(map[pgtype.UUID]bool, 12) + for i := 0; i < 12; i++ { + a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "") + al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i)) + tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i)) + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil { + t.Fatalf("like track: %v", err) + } + fromLiked[a.ID] = true + } + + got, err := HomeData(context.Background(), pool, user.ID) + if err != nil { + t.Fatalf("HomeData: %v", err) + } + if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit { + t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)", + len(got.YouMightLikeArtists), HomeYouMightLikeLimit) + } + for _, r := range got.YouMightLikeArtists { + if !fromLiked[r.Artist.ID] { + t.Errorf("artist %s not from the liked-track pool", r.Artist.Name) + } + } +} + func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) { pool := newPool(t) user := seedUser(t, pool, "home-newuser") From 6c26ba807ed9619aa9b133e2eb17e03d7e3348e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 12 Jun 2026 00:05:50 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(taste):=20phase=202b=20=E2=80=94=20tas?= =?UTF-8?q?te=5Foverlap=20candidate=20arm=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by the user's top positively-weighted taste-profile artists ($10 K, weight > 0, deterministic weight-DESC,id order so it doesn't reintroduce same-day nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores the fit. Empty for cold-start users (no profile). - CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via systemForYouSourceLimits. - You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively- engaged artists, so flooding its pool with top-taste (mostly already-played) artists would just feed the read-time dedup. - Test: positive-weight artist's track enters via the arm; negative-weight one is excluded (weight > 0). Existing pool tests unaffected (no profile seeded). Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch already inject taste broadly (top-played seed ≈ top-taste artist). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/dbq/recommendation.sql.go | 23 +++++++- internal/db/queries/recommendation.sql | 21 ++++++- internal/playlists/system.go | 4 ++ internal/playlists/you_might_like.go | 8 ++- internal/recommendation/candidates.go | 6 ++ internal/recommendation/candidates_v2_test.go | 55 +++++++++++++++++++ 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index f508a031..add1e34e 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -737,6 +737,17 @@ likes_overlap AS ( ORDER BY random() LIMIT $8 ), +taste_overlap AS ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score + FROM taste_profile_artists tpa + JOIN tracks t ON t.artist_id = tpa.artist_id + WHERE tpa.user_id = $1 + AND tpa.weight > 0 + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ORDER BY tpa.weight DESC, t.id + LIMIT $10 +), random_fill AS ( SELECT t.id AS track_id, 0.0::float8 AS sim_score FROM tracks t @@ -747,6 +758,7 @@ random_fill AS ( UNION SELECT track_id FROM similar_artists UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM likes_overlap + UNION SELECT track_id FROM taste_overlap ) ORDER BY random() LIMIT $9 @@ -763,6 +775,7 @@ FROM ( UNION ALL SELECT track_id, sim_score FROM similar_artists UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap + UNION ALL SELECT track_id, sim_score FROM taste_overlap UNION ALL SELECT track_id, sim_score FROM random_fill ) u JOIN tracks t ON t.id = u.track_id @@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct { Limit_3 int32 Limit_4 int32 Limit_5 int32 + Limit_6 int32 } type LoadRadioCandidatesV2Row struct { @@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct { SimilarityScore interface{} } -// M4c: similarity-driven candidate pool. 5-way UNION: +// M4c: similarity-driven candidate pool. 6-way UNION: // // $1 user_id, $2 seed_track_id, $3 recently_played_hours, // $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, -// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. +// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K, +// $10 taste_overlap K (#796 phase 2b — tracks by the user's top +// positively-weighted taste-profile artists, so taste-relevant tracks +// enter the pool even when the similarity/random arms miss them; scored +// in Go via TasteMatch, so sim_score here is 0 pool-inclusion). // // Returns same shape as LoadRadioCandidates plus similarity_score column. func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { @@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid arg.Limit_3, arg.Limit_4, arg.Limit_5, + arg.Limit_6, ) if err != nil { return nil, err diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 7611daac..05882dbe 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -33,10 +33,14 @@ WHERE t.id <> $2 ); -- name: LoadRadioCandidatesV2 :many --- M4c: similarity-driven candidate pool. 5-way UNION: +-- M4c: similarity-driven candidate pool. 6-way UNION: -- $1 user_id, $2 seed_track_id, $3 recently_played_hours, -- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, --- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. +-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K, +-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top +-- positively-weighted taste-profile artists, so taste-relevant tracks +-- enter the pool even when the similarity/random arms miss them; scored +-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion). -- Returns same shape as LoadRadioCandidates plus similarity_score column. WITH @@ -109,6 +113,17 @@ likes_overlap AS ( ORDER BY random() LIMIT $8 ), +taste_overlap AS ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score + FROM taste_profile_artists tpa + JOIN tracks t ON t.artist_id = tpa.artist_id + WHERE tpa.user_id = $1 + AND tpa.weight > 0 + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ORDER BY tpa.weight DESC, t.id + LIMIT $10 +), random_fill AS ( SELECT t.id AS track_id, 0.0::float8 AS sim_score FROM tracks t @@ -119,6 +134,7 @@ random_fill AS ( UNION SELECT track_id FROM similar_artists UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM likes_overlap + UNION SELECT track_id FROM taste_overlap ) ORDER BY random() LIMIT $9 @@ -135,6 +151,7 @@ FROM ( UNION ALL SELECT track_id, sim_score FROM similar_artists UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap + UNION ALL SELECT track_id, sim_score FROM taste_overlap UNION ALL SELECT track_id, sim_score FROM random_fill ) u JOIN tracks t ON t.id = u.track_id diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 1efdba40..b618d9d7 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits { TagOverlap: 60, LikesOverlap: 40, RandomFill: 150, + // For-You / You-might-like are the taste-driven surfaces, so pull a + // deep slice of the user's top taste-profile artists into the pool + // (#796 phase 2b). Empty for cold-start users (no profile yet). + TasteOverlap: 80, } } diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go index e2d8b47e..3d40e919 100644 --- a/internal/playlists/you_might_like.go +++ b/internal/playlists/you_might_like.go @@ -89,9 +89,15 @@ func buildYouMightLike( } zeroVec := recommendation.SessionVector{Seed: true} + // You-might-like surfaces in-library artists the user does NOT actively + // engage with, so it deliberately skips the taste_overlap arm — that arm + // pulls top-taste (mostly already-played) artists, which would crowd the + // pool with entities the read-time dedup then strips. For-You/radio keep it. + ymlLimits := systemForYouSourceLimits() + ymlLimits.TasteOverlap = 0 cands, err := recommendation.LoadCandidatesFromSimilarity( ctx, q, userID, seed, 1, zeroVec, - []pgtype.UUID{seed}, systemForYouSourceLimits(), + []pgtype.UUID{seed}, ymlLimits, ) if err != nil { logger.Warn("you-might-like: candidate load failed; skipping", diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index ad0d6d39..81c9f6bf 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -72,6 +72,10 @@ type CandidateSourceLimits struct { TagOverlap int LikesOverlap int RandomFill int + // TasteOverlap (#796 phase 2b): tracks by the user's top positively- + // weighted taste-profile artists. 0 disables the arm (e.g. cold-start + // users have an empty profile, so it contributes nothing anyway). + TasteOverlap int } // DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. @@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits { TagOverlap: 20, LikesOverlap: 20, RandomFill: 30, + TasteOverlap: 20, } } @@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity( Limit_3: int32(limits.TagOverlap), Limit_4: int32(limits.LikesOverlap), Limit_5: int32(limits.RandomFill), + Limit_6: int32(limits.TasteOverlap), }) if err != nil { return nil, err diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go index 294b0e58..0fa19b55 100644 --- a/internal/recommendation/candidates_v2_test.go +++ b/internal/recommendation/candidates_v2_test.go @@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { } } +// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by +// a positively-weighted taste-profile artist enters the pool via taste_overlap +// even with every other arm disabled; a negatively-weighted artist's track does +// not (the WHERE weight > 0 filter). +func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) { + f := newFixture(t, 2) // seed + 1 other, both by the fixture artist + seed := f.tracks[0] + target := f.tracks[1] + ctx := context.Background() + + // Fixture artist gets a positive taste weight. + if _, err := f.pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`, + f.user, seed.ArtistID); err != nil { + t.Fatalf("insert taste (positive): %v", err) + } + + // A second artist with a NEGATIVE weight — its track must be excluded. + negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"}) + negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID}) + negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID, + FilePath: "/tmp/neg.flac", DurationMs: 180_000, + }) + if _, err := f.pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`, + f.user, negArtist.ID); err != nil { + t.Fatalf("insert taste (negative): %v", err) + } + + // Only the taste_overlap arm is enabled. + limits := CandidateSourceLimits{TasteOverlap: 10} + got, err := LoadCandidatesFromSimilarity( + ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, + ) + if err != nil { + t.Fatalf("load: %v", err) + } + var sawTarget, sawNeg bool + for _, c := range got { + switch c.Track.ID { + case target.ID: + sawTarget = true + case negTrack.ID: + sawNeg = true + } + } + if !sawTarget { + t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)") + } + if sawNeg { + t.Error("negative-taste-artist track present (weight > 0 filter failed)") + } +} + func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { f := newFixture(t, 1) // just the seed seed := f.tracks[0] From 1a7515e6ea3018fc4107d2b94885e873765622e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 12 Jun 2026 00:28:30 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(taste):=20phase=204=20=E2=80=94=20reco?= =?UTF-8?q?mmendation=20observability=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-source play outcomes so the operator can see whether each recommendation surface is landing and tune the now-operator-tunable taste weights. Server: - query RecommendationSourceMetricsForUser: groups the user's play_events by source (system-playlist surface), reporting plays / skips / avg completion over a window; NULL-source (library/radio) plays excluded. - GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) → {window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}. - handler test: 401 unauth; per-source aggregation + NULL-source exclusion + skip_rate / avg_completion math. Web: - lib/api/metrics.ts: query + friendly source labels. - settings page gains a "Recommendation metrics" card (table of surface / plays / skip rate / avg completion), with loading/error/empty states. - settings tests mock the new query (manual subscribe-store, hoisting-safe). Note: You-might-like plays aren't source-tagged (it's a Home row, not a system playlist), so this covers For-You / Discover / the mixes. Tagging YML plays would be a client follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/api.go | 1 + internal/api/me_recommendation_metrics.go | 94 +++++++++++++++ .../api/me_recommendation_metrics_test.go | 108 ++++++++++++++++++ internal/db/dbq/recommendation_metrics.sql.go | 73 ++++++++++++ .../db/queries/recommendation_metrics.sql | 22 ++++ web/src/lib/api/metrics.ts | 46 ++++++++ web/src/routes/settings/+page.svelte | 46 ++++++++ web/src/routes/settings/Appearance.test.ts | 10 ++ web/src/routes/settings/settings.test.ts | 10 ++ 9 files changed, 410 insertions(+) create mode 100644 internal/api/me_recommendation_metrics.go create mode 100644 internal/api/me_recommendation_metrics_test.go create mode 100644 internal/db/dbq/recommendation_metrics.sql.go create mode 100644 internal/db/queries/recommendation_metrics.sql create mode 100644 web/src/lib/api/metrics.ts diff --git a/internal/api/api.go b/internal/api/api.go index ff425bb9..4639cca2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Post("/auth/logout", h.handleLogout) authed.Get("/me", h.handleGetMe) authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus) + authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics) authed.Get("/me/listenbrainz", h.handleGetListenBrainz) authed.Put("/me/listenbrainz", h.handlePutListenBrainz) authed.Get("/me/history", h.handleGetMyHistory) diff --git a/internal/api/me_recommendation_metrics.go b/internal/api/me_recommendation_metrics.go new file mode 100644 index 00000000..05a15a75 --- /dev/null +++ b/internal/api/me_recommendation_metrics.go @@ -0,0 +1,94 @@ +package api + +import ( + "net/http" + "strconv" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +const ( + recMetricsDefaultDays = 30 + recMetricsMaxDays = 365 +) + +// recommendationMetric is one recommendation surface's outcomes. +type recommendationMetric struct { + Source string `json:"source"` // 'for_you' | 'discover' | mixes + Plays int64 `json:"plays"` // plays launched from this surface + Skips int64 `json:"skips"` // of those, marked skipped + SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1] + AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1] +} + +type recommendationMetricsResp struct { + WindowDays int `json:"window_days"` + Sources []recommendationMetric `json:"sources"` +} + +// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics. +// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the +// caller over the last `days` (default 30, capped at 365), so the operator can +// see which recommendation surfaces are landing and tune the taste weights. +// Only plays tagged with a system-playlist source count; library/radio plays +// (no source) are excluded. +func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) { + caller, ok := requireUser(w, r) + if !ok { + return + } + days, ok := parseMetricsDays(w, r) + if !ok { + return + } + + rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(), + dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)}) + if err != nil { + h.logger.Error("api: recommendation metrics", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + + out := recommendationMetricsResp{ + WindowDays: days, + Sources: make([]recommendationMetric, 0, len(rows)), + } + for _, row := range rows { + source := "" + if row.Source != nil { + source = *row.Source + } + var skipRate float64 + if row.Plays > 0 { + skipRate = float64(row.Skips) / float64(row.Plays) + } + out.Sources = append(out.Sources, recommendationMetric{ + Source: source, + Plays: row.Plays, + Skips: row.Skips, + SkipRate: skipRate, + AvgCompletion: row.AvgCompletion, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// parseMetricsDays reads the `days` query param (default 30, capped at 365). +// Writes a 400 and returns ok=false on a malformed value. +func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) { + v := r.URL.Query().Get("days") + if v == "" { + return recMetricsDefaultDays, true + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, apierror.BadRequest("bad_request", "invalid days")) + return 0, false + } + if n > recMetricsMaxDays { + n = recMetricsMaxDays + } + return n, true +} diff --git a/internal/api/me_recommendation_metrics_test.go b/internal/api/me_recommendation_metrics_test.go new file mode 100644 index 00000000..2a1af4c3 --- /dev/null +++ b/internal/api/me_recommendation_metrics_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func newMetricsRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics) + return r +} + +// seedSourcedPlay inserts a play_event with an explicit source + completion + +// skip flag. A nil source inserts NULL (library/radio play). +func seedSourcedPlay( + t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID, + source *string, completion float64, skipped bool, +) { + t.Helper() + if _, err := h.pool.Exec(context.Background(), + `INSERT INTO play_events + (user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped) + VALUES ($1, $2, $3, now(), $4, $5, $6)`, + userID, trackID, sessionID, source, completion, skipped); err != nil { + t.Fatalf("seed sourced play: %v", err) + } +} + +func TestRecommendationMetrics_NoSession401(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil) + rec := httptest.NewRecorder() + newMetricsRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "metrics", "pw", false) + artist := seedArtist(t, pool, "MetricArtist") + album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020) + tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000) + session := seedPlaySession(t, pool, user.ID, time.Now()) + + forYou := "for_you" + discover := "discover" + // for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667. + seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false) + seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false) + seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true) + // discover: 1 play. + seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false) + // library play (NULL source) — must be excluded. + seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false) + + req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil) + req = withUser(req, user) + rec := httptest.NewRecorder() + newMetricsRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var resp recommendationMetricsResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.WindowDays != recMetricsDefaultDays { + t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays) + } + bySource := map[string]recommendationMetric{} + for _, m := range resp.Sources { + bySource[m.Source] = m + } + if _, present := bySource[""]; present { + t.Error("NULL-source (library) plays should be excluded") + } + fy, ok := bySource["for_you"] + if !ok { + t.Fatal("for_you metrics missing") + } + if fy.Plays != 3 || fy.Skips != 1 { + t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips) + } + if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 { + t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate) + } + if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 { + t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion) + } + if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 { + t.Errorf("discover metrics = %+v, want plays=1 skips=0", d) + } +} diff --git a/internal/db/dbq/recommendation_metrics.sql.go b/internal/db/dbq/recommendation_metrics.sql.go new file mode 100644 index 00000000..db1f6e77 --- /dev/null +++ b/internal/db/dbq/recommendation_metrics.sql.go @@ -0,0 +1,73 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: recommendation_metrics.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many + +SELECT + pe.source, + count(*)::bigint AS plays, + count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, + COALESCE( + avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL), + 0)::float8 AS avg_completion +FROM play_events pe +WHERE pe.user_id = $1 + AND pe.source IS NOT NULL + AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') +GROUP BY pe.source +ORDER BY plays DESC +` + +type RecommendationSourceMetricsForUserParams struct { + UserID pgtype.UUID + Column2 float64 +} + +type RecommendationSourceMetricsForUserRow struct { + Source *string + Plays int64 + Skips int64 + AvgCompletion float64 +} + +// Recommendation observability (#796 phase 4). Per-source play outcomes so the +// operator can see whether each recommendation surface is landing and tune the +// taste weights. Source is stamped on play_events when a play is launched from +// a system-playlist surface ('for_you' | 'discover' | the discovery mixes); +// NULL for library / radio / user-playlist plays, which are excluded here. +// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the +// mean completion ratio over plays that recorded one (0 when none did). +func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) { + rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecommendationSourceMetricsForUserRow + for rows.Next() { + var i RecommendationSourceMetricsForUserRow + if err := rows.Scan( + &i.Source, + &i.Plays, + &i.Skips, + &i.AvgCompletion, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/recommendation_metrics.sql b/internal/db/queries/recommendation_metrics.sql new file mode 100644 index 00000000..c7d04159 --- /dev/null +++ b/internal/db/queries/recommendation_metrics.sql @@ -0,0 +1,22 @@ +-- Recommendation observability (#796 phase 4). Per-source play outcomes so the +-- operator can see whether each recommendation surface is landing and tune the +-- taste weights. Source is stamped on play_events when a play is launched from +-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes); +-- NULL for library / radio / user-playlist plays, which are excluded here. + +-- name: RecommendationSourceMetricsForUser :many +-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the +-- mean completion ratio over plays that recorded one (0 when none did). +SELECT + pe.source, + count(*)::bigint AS plays, + count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, + COALESCE( + avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL), + 0)::float8 AS avg_completion +FROM play_events pe +WHERE pe.user_id = $1 + AND pe.source IS NOT NULL + AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') +GROUP BY pe.source +ORDER BY plays DESC; diff --git a/web/src/lib/api/metrics.ts b/web/src/lib/api/metrics.ts new file mode 100644 index 00000000..be1fb87d --- /dev/null +++ b/web/src/lib/api/metrics.ts @@ -0,0 +1,46 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; + +// Mirrors internal/api/me_recommendation_metrics.go. +export type RecommendationMetric = { + source: string; + plays: number; + skips: number; + skip_rate: number; + avg_completion: number; +}; + +export type RecommendationMetrics = { + window_days: number; + sources: RecommendationMetric[]; +}; + +export function getRecommendationMetrics(): Promise { + return api.get('/api/me/recommendation-metrics'); +} + +export const REC_METRICS_QUERY_KEY = ['settings', 'recommendation-metrics'] as const; + +export function createRecommendationMetricsQuery() { + return createQuery({ + queryKey: REC_METRICS_QUERY_KEY, + queryFn: getRecommendationMetrics, + staleTime: 60_000 + }); +} + +// Friendly labels for the system-playlist source keys (play_events.source). +const SOURCE_LABELS: Record = { + for_you: 'For You', + discover: 'Discover', + deep_cuts: 'Deep cuts', + rediscover: 'Rediscover', + new_for_you: 'New for you', + on_this_day: 'On this day', + first_listens: 'First listens', + songs_like_artist: 'Songs like…' +}; + +export function sourceLabel(source: string): string { + return SOURCE_LABELS[source] ?? source; +} diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 453b5245..eef29ee9 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -8,6 +8,11 @@ createEnabledMutation, type LBStatus } from '$lib/api/listenbrainz'; + import { + createRecommendationMetricsQuery, + sourceLabel, + type RecommendationMetrics + } from '$lib/api/metrics'; import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte'; import { player, setCrossfade } from '$lib/player/store.svelte'; import { @@ -24,6 +29,7 @@ const queryClient = useQueryClient(); const status = createLBStatusQuery() as CreateQueryResult; + const metrics = createRecommendationMetricsQuery() as CreateQueryResult; const tokenMutation = createTokenMutation(queryClient); const enabledMutation = createEnabledMutation(queryClient); @@ -267,6 +273,46 @@ {/if} + +
+

Recommendation metrics

+

+ How plays launched from each recommendation surface land, over the last + {$metrics.data?.window_days ?? 30} days. Lower skip rate and higher average + completion mean the surface is hitting. +

+ {#if $metrics.isPending} +

Loading…

+ {:else if $metrics.isError} +

Couldn't load metrics.

+ {:else if $metrics.data && $metrics.data.sources.length > 0} + + + + + + + + + + + {#each $metrics.data.sources as m (m.source)} + + + + + + + {/each} + +
SurfacePlaysSkip rateAvg completion
{sourceLabel(m.source)}{m.plays}{(m.skip_rate * 100).toFixed(0)}%{(m.avg_completion * 100).toFixed(0)}%
+ {:else} +

+ No recommendation plays yet. Play something from For You, Discover, or a mix. +

+ {/if} +
+

Profile

diff --git a/web/src/routes/settings/Appearance.test.ts b/web/src/routes/settings/Appearance.test.ts index 0de00816..d254e97b 100644 --- a/web/src/routes/settings/Appearance.test.ts +++ b/web/src/routes/settings/Appearance.test.ts @@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => { }; }); +vi.mock('$lib/api/metrics', () => ({ + createRecommendationMetricsQuery: () => ({ + subscribe: (run: (v: unknown) => void) => { + run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } }); + return () => {}; + } + }), + sourceLabel: (s: string) => s +})); + beforeEach(() => { globalThis.localStorage.clear(); document.documentElement.removeAttribute('data-theme'); diff --git a/web/src/routes/settings/settings.test.ts b/web/src/routes/settings/settings.test.ts index c3db35fd..ae913c07 100644 --- a/web/src/routes/settings/settings.test.ts +++ b/web/src/routes/settings/settings.test.ts @@ -20,6 +20,16 @@ vi.mock('$lib/api/me', () => ({ regenerateAPIToken: vi.fn() })); +vi.mock('$lib/api/metrics', () => ({ + createRecommendationMetricsQuery: () => ({ + subscribe: (run: (v: unknown) => void) => { + run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } }); + return () => {}; + } + }), + sourceLabel: (s: string) => s +})); + import SettingsPage from './+page.svelte'; import { createLBStatusQuery,