feat(taste): phase 4 — recommendation observability (#796)
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:
@@ -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<RecommendationMetrics> {
|
||||
return api.get<RecommendationMetrics>('/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<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<LBStatus>;
|
||||
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
||||
const tokenMutation = createTokenMutation(queryClient);
|
||||
const enabledMutation = createEnabledMutation(queryClient);
|
||||
|
||||
@@ -267,6 +273,46 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Recommendation metrics card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
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.
|
||||
</p>
|
||||
{#if $metrics.isPending}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else if $metrics.isError}
|
||||
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
|
||||
{:else if $metrics.data && $metrics.data.sources.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-text-secondary">
|
||||
<th class="py-1 font-medium">Surface</th>
|
||||
<th class="py-1 text-right font-medium">Plays</th>
|
||||
<th class="py-1 text-right font-medium">Skip rate</th>
|
||||
<th class="py-1 text-right font-medium">Avg completion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $metrics.data.sources as m (m.source)}
|
||||
<tr class="border-t border-border">
|
||||
<td class="py-1">{sourceLabel(m.source)}</td>
|
||||
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td>
|
||||
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No recommendation plays yet. Play something from For You, Discover, or a mix.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Profile card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Profile</h2>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user