feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
test-web / test (push) Successful in 35s
test-web / test (push) Successful in 35s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<TuningScope | null>(null);
|
||||
|
||||
function fillForm(snap: TuningSnapshot) {
|
||||
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
|
||||
const f: Record<string, Record<string, string>> = {
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Discover request surface (#2377). Its own scope, not part of taste:
|
||||
the snooze length lives here, and a snooze deliberately carries no
|
||||
taste signal (#2374). -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Discover requests</h2>
|
||||
<p class="text-xs text-text-secondary">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each discoverFields as f (f.key)}
|
||||
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
|
||||
<label class="text-sm" for="discover-{f.key}" title={f.hint}>
|
||||
{f.label}
|
||||
{#if deviates('discover', f.key)}
|
||||
<span
|
||||
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
|
||||
title="Deviates from the shipped default ({snapshot.shipped.discover[f.key]})"
|
||||
></span>
|
||||
{/if}
|
||||
</label>
|
||||
<input
|
||||
id="discover-{f.key}"
|
||||
type="number"
|
||||
step={f.key === 'snooze_days' ? '1' : '0.1'}
|
||||
min="0"
|
||||
bind:value={form.discover[f.key]}
|
||||
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving !== null}
|
||||
onclick={() => save('discover')}
|
||||
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
Save discover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving !== null}
|
||||
onclick={() => reset('discover')}
|
||||
aria-label="Reset discover requests 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
|
||||
|
||||
Reference in New Issue
Block a user