From ca4832e62069085e6edd9f68172fe9db851a5b07 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 2 Aug 2026 23:49:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(discover):=20Discover=20tuning=20card=20on?= =?UTF-8?q?=20the=20admin=20lab=20=E2=80=94=20#2377=20(web=20admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule #25/#27: the two knobs slice 6 added server-side are now touchable — taste-tag weight and snooze length, with deviation dots, save, and reset, matching the existing profile/taste cards. Copy states what each knob does AND what it doesn't: the tag-weight hint says 0 turns the term off and that an untagged candidate is never penalised, and the snooze hint says it records no opinion about the artist and never feeds the taste profile. Those are the two properties most likely to be assumed backwards by whoever turns these next. Also fixed a latent fragility the new card exposed rather than caused: all three reset buttons had the accessible name "Reset to defaults", so the existing test picked the LAST one and assumed that meant taste. Adding a card below it would have silently retargeted that assertion at the wrong scope. Each reset button now names its scope — better for screen readers too, since three identical buttons on one page is a real a11y defect — and the test selects by name instead of position. The page's test fixture needed the new `discover` key in both `snapshot` and `shipped`: the `as TuningSnapshot` cast means a missing field is not a compile error, it's every test on the page throwing inside fillForm. Noted that in the fixture so the next scope doesn't rediscover it. Includes a test that a weight of 0 is actually SENT rather than dropped as falsy — the off switch is the one value a truthiness bug would eat. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/lib/api/tuning.ts | 12 +++- web/src/routes/admin/tuning/+page.svelte | 81 +++++++++++++++++++++- web/src/routes/admin/tuning/tuning.test.ts | 80 ++++++++++++++++++++- 3 files changed, 167 insertions(+), 6 deletions(-) diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index fae0f40f..10d1979d 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -26,14 +26,24 @@ export type TasteTuning = { mood_scale: number; }; -export type TuningScope = 'radio' | 'daily_mix' | 'taste'; +// Discover request-surface knobs (#2377). Its own scope rather than part of +// taste: snooze_days belongs here, and a snooze must never be read as taste +// signal (#2374). +export type DiscoverTuning = { + tag_overlap_weight: number; + snooze_days: number; +}; + +export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover'; export type TuningSnapshot = { profiles: Record<'radio' | 'daily_mix', WeightProfile>; taste: TasteTuning; + discover: DiscoverTuning; shipped: { profiles: Record<'radio' | 'daily_mix', WeightProfile>; taste: TasteTuning; + discover: DiscoverTuning; }; }; diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index 79e96668..33bb0b19 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -9,6 +9,7 @@ type TuningSnapshot, type WeightProfile, type TasteTuning, + type DiscoverTuning, type TrendsResponse, type TrendSeries, type TrendMarker @@ -43,6 +44,11 @@ { key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' } ]; + const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [ + { key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: "How strongly a candidate's tags matching your taste boosts it. score x (1 + w x overlap), so 0 turns the tag term off and ranks on similarity alone. An artist with no cached tags is never penalised." }, + { key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' } + ]; + const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [ { scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' }, { scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' } @@ -55,11 +61,17 @@ let saving = $state(null); function fillForm(snap: TuningSnapshot) { - const f: Record> = { radio: {}, daily_mix: {}, taste: {} }; + const f: Record> = { + radio: {}, + daily_mix: {}, + taste: {}, + discover: {} + }; for (const p of ['radio', 'daily_mix'] as const) { for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]); } for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]); + for (const { key } of discoverFields) f.discover[key] = String(snap.discover[key]); form = f; } @@ -76,11 +88,17 @@ // A knob deviates when its CURRENT SAVED value differs from shipped; // the dot marks where this install has drifted from defaults. - function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean { + function deviates(scope: TuningScope, key: string): boolean { if (!snapshot) return false; if (scope === 'taste') { return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning]; } + if (scope === 'discover') { + return ( + snapshot.discover[key as keyof DiscoverTuning] !== + snapshot.shipped.discover[key as keyof DiscoverTuning] + ); + } return ( snapshot.profiles[scope][key as keyof WeightProfile] !== snapshot.shipped.profiles[scope][key as keyof WeightProfile] @@ -90,6 +108,7 @@ function currentValue(scope: TuningScope, key: string): number { if (!snapshot) return 0; if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning]; + if (scope === 'discover') return snapshot.discover[key as keyof DiscoverTuning]; return snapshot.profiles[scope][key as keyof WeightProfile]; } @@ -276,6 +295,7 @@ type="button" disabled={saving !== null} onclick={() => reset(p.scope)} + aria-label="Reset {p.label} to defaults" class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50" > Reset to defaults @@ -328,6 +348,63 @@ type="button" disabled={saving !== null} onclick={() => reset('taste')} + aria-label="Reset taste profile build to defaults" + class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50" + > + Reset to defaults + + + + + +
+
+

Discover requests

+

+ How the Discover suggestion deck ranks out-of-library artists. Tag coverage for + artists you don't own is partial by nature, so an untagged candidate keeps its + similarity score rather than being pushed down. +

+
+
+ {#each discoverFields as f (f.key)} +
+ + +
+ {/each} +
+
+ +