feat(recommendation): For You exploration attribution — taste vs fresh picks
Milestone #127 step 2 (#1249). For You deliberately blends two populations — a head of top-scored taste picks and a tail sampled from deeper ranking (the freshness injection) — but the metrics judged it as one blob, so its skip rate couldn't distinguish "the taste engine is missing" from "the freshness tax is too high". That number decides the exploration share before we tune it. - Migration 0038: nullable pick_kind ('taste'|'fresh') on both playlist_tracks (stamped at snapshot build) and play_events (frozen at play-ingestion — the snapshot rebuilds daily, so attribution cannot be reconstructed at read time). - Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool fallback is all taste (top-N-by-score IS the taste mechanism). Other variants persist NULL. - Ingestion: for_you plays (live + offline replay) look the track up in the user's current snapshot; not found → unattributed, never guessed. - Metrics: For You's row gains a breakdown (taste / fresh / earlier unattributed plays), parent row stays the sum; web card renders the sub-rows indented with the same baseline deltas + low-data dimming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -24,11 +24,27 @@ ORDER BY started_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: InsertPlayEvent :one
|
||||
-- pick_kind is non-NULL only for source='for_you' plays whose track was
|
||||
-- found in the user's live For You snapshot at ingestion time (#1249).
|
||||
INSERT INTO play_events (
|
||||
user_id, track_id, session_id, started_at, client_id, source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetForYouPickKindForTrack :one
|
||||
-- Looks a track up in the user's CURRENT For You snapshot and returns
|
||||
-- its pick_kind ('taste'/'fresh'). Used at play-ingestion time to
|
||||
-- freeze exploration attribution onto the play_event — the snapshot is
|
||||
-- rebuilt daily, so attribution can't be reconstructed at read time
|
||||
-- (#1249). No row = track not in today's snapshot (caller stores NULL).
|
||||
SELECT pt.pick_kind
|
||||
FROM playlist_tracks pt
|
||||
JOIN playlists p ON p.id = pt.playlist_id
|
||||
WHERE p.user_id = $1
|
||||
AND p.system_variant = 'for_you'
|
||||
AND pt.track_id = $2
|
||||
LIMIT 1;
|
||||
|
||||
-- name: UpdatePlayEventEnded :one
|
||||
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
|
||||
-- completion_ratio is computed from duration_played_ms and the track duration
|
||||
|
||||
@@ -68,8 +68,9 @@ ORDER BY pt.position;
|
||||
-- name: AppendPlaylistTrack :one
|
||||
-- Inserts at the next available position. Snapshot fields are copied
|
||||
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||
-- is converted to seconds for the snapshot.
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
||||
-- is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||
-- playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||
SELECT
|
||||
sqlc.arg(playlist_id)::uuid,
|
||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
|
||||
@@ -77,7 +78,8 @@ SELECT
|
||||
t.title,
|
||||
artists.name,
|
||||
albums.title,
|
||||
(t.duration_ms / 1000)::integer
|
||||
(t.duration_ms / 1000)::integer,
|
||||
sqlc.narg(pick_kind)::text
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
-- name: RecommendationSourceMetricsForUser :many
|
||||
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
-- mean completion ratio over the completion_n plays that recorded one.
|
||||
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||
-- it is NULL for every other source, so those still group to one row.
|
||||
SELECT
|
||||
pe.source,
|
||||
pe.pick_kind,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
count(pe.completion_ratio)::bigint AS completion_n,
|
||||
@@ -20,5 +23,5 @@ SELECT
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
GROUP BY pe.source, pe.pick_kind
|
||||
ORDER BY plays DESC;
|
||||
|
||||
Reference in New Issue
Block a user