feat(discover): seed request suggestions from the taste profile — #2372
The Discover request surface was the one recommendation surface still on its M5c implementation from early May. #796's taste profile, #1488's taste_unheard bucket and #1490's folksonomy enrichment all modernized in-library surfaces; this one was never in scope for any of them, so it still projected raw likes + plays through artist_similarity_unmatched. Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed over every play of the artist. It is unbounded, and contribution is signal × similarity — so a handful of heavily-played artists monopolize all twelve slots, and their share GROWS the more the user listens. The surface entrenched harder the better it knew you, which is exactly backwards and matches the reported "goes stale once it has a strong signal of your taste". It also counted every play_event with no was_skipped filter, so skipping an artist repeatedly INCREASED its signal and pushed more of its neighbours at the user. ListMostPlayedTracksForUser and the taste engine both filter skips; this query was the odd one out. Seeds now come from taste_profile_artists.weight, which the taste engine has already engagement-graded, time-decayed and signed — an artist the user drifted away from stops contributing instead of accumulating forever, and can even contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is the profile, tier 2 is likes + completed plays for a user who has no profile rows yet (new account, or before the first daily recompute), so the surface never empties. The old unfiltered-play signal is gone, not kept behind a toggle. The signal is also log-damped, so one artist cannot take every slot even when its weight dwarfs the rest. $2 stays wired to the tier-2 decay: it is genuinely still used there, and dropping the parameter would have changed the generated signature. sqlc's image is not on this workstation and the change preserves the query signature exactly — same three params, same seven columns — so only the embedded SQL const moves. Both copies are edited and verified byte-identical rather than pulling a container onto the operator's machine; a malformed query fails the integration lane loudly, which is the real check either way. Four integration tests cover what changed: a taste weight alone seeds with no like or play; tier 2 does not run alongside tier 1; a non-positive weight never seeds (guarded by a second positive row, so an empty tier 1 can't make it pass for the wrong reason); and skip-only history seeds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1022,20 +1022,66 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
}
|
||||
|
||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||
WITH seeds AS (
|
||||
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||
--
|
||||
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||
-- from stops contributing instead of accumulating forever.
|
||||
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||
-- (new account, or before the first daily recompute).
|
||||
--
|
||||
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||
--
|
||||
-- Candidates already in the library, or already requested and not terminal,
|
||||
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||
WITH artist_plays AS (
|
||||
-- Completed plays only. The previous seed query counted every play_event,
|
||||
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
profile_seeds AS (
|
||||
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||
FROM taste_profile_artists tpa
|
||||
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||
),
|
||||
fallback_seeds AS (
|
||||
SELECT a.id AS artist_id,
|
||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
AS raw_signal
|
||||
FROM artists a
|
||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
LEFT JOIN play_events pe
|
||||
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||
GROUP BY a.id, gla.artist_id
|
||||
),
|
||||
seeds AS (
|
||||
SELECT s.artist_id,
|
||||
ln(1.0 + s.raw_signal) AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||
FROM (
|
||||
SELECT artist_id, raw_signal FROM profile_seeds
|
||||
UNION ALL
|
||||
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||
) s
|
||||
LEFT JOIN general_likes_artists gla
|
||||
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||
WHERE s.raw_signal > 0
|
||||
),
|
||||
contributions AS (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
|
||||
@@ -258,27 +258,66 @@ ORDER BY started_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: SuggestArtistsForUser :many
|
||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||||
-- artist_similarity_unmatched and filters out candidates already in
|
||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||||
WITH seeds AS (
|
||||
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||
--
|
||||
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||
-- from stops contributing instead of accumulating forever.
|
||||
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||
-- (new account, or before the first daily recompute).
|
||||
--
|
||||
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||
--
|
||||
-- Candidates already in the library, or already requested and not terminal,
|
||||
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||
WITH artist_plays AS (
|
||||
-- Completed plays only. The previous seed query counted every play_event,
|
||||
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
profile_seeds AS (
|
||||
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||
FROM taste_profile_artists tpa
|
||||
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||
),
|
||||
fallback_seeds AS (
|
||||
SELECT a.id AS artist_id,
|
||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
AS raw_signal
|
||||
FROM artists a
|
||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
LEFT JOIN play_events pe
|
||||
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||
GROUP BY a.id, gla.artist_id
|
||||
),
|
||||
seeds AS (
|
||||
SELECT s.artist_id,
|
||||
ln(1.0 + s.raw_signal) AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||
FROM (
|
||||
SELECT artist_id, raw_signal FROM profile_seeds
|
||||
UNION ALL
|
||||
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||
) s
|
||||
LEFT JOIN general_likes_artists gla
|
||||
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||
WHERE s.raw_signal > 0
|
||||
),
|
||||
contributions AS (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
|
||||
Reference in New Issue
Block a user