feat(taste): phase 4 — recommendation observability (#796)
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m24s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:28:30 -04:00
parent 6c26ba807e
commit 1a7515e6ea
9 changed files with 410 additions and 0 deletions
@@ -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
}
@@ -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;