Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268) #116

Merged
bvandeusen merged 14 commits from dev into main 2026-08-03 08:38:25 -04:00
3 changed files with 167 additions and 6 deletions
Showing only changes of commit ca4832e620 - Show all commits
+11 -1
View File
@@ -26,14 +26,24 @@ export type TasteTuning = {
mood_scale: number; 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 = { export type TuningSnapshot = {
profiles: Record<'radio' | 'daily_mix', WeightProfile>; profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning; taste: TasteTuning;
discover: DiscoverTuning;
shipped: { shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>; profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning; taste: TasteTuning;
discover: DiscoverTuning;
}; };
}; };
+79 -2
View File
@@ -9,6 +9,7 @@
type TuningSnapshot, type TuningSnapshot,
type WeightProfile, type WeightProfile,
type TasteTuning, type TasteTuning,
type DiscoverTuning,
type TrendsResponse, type TrendsResponse,
type TrendSeries, type TrendSeries,
type TrendMarker 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.' } { 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 }[] = [ const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' }, { 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.' } { 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); let saving = $state<TuningScope | null>(null);
function fillForm(snap: TuningSnapshot) { 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 p of ['radio', 'daily_mix'] as const) {
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]); 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 tasteFields) f.taste[key] = String(snap.taste[key]);
for (const { key } of discoverFields) f.discover[key] = String(snap.discover[key]);
form = f; form = f;
} }
@@ -76,11 +88,17 @@
// A knob deviates when its CURRENT SAVED value differs from shipped; // A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults. // 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 (!snapshot) return false;
if (scope === 'taste') { if (scope === 'taste') {
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning]; 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 ( return (
snapshot.profiles[scope][key as keyof WeightProfile] !== snapshot.profiles[scope][key as keyof WeightProfile] !==
snapshot.shipped.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 { function currentValue(scope: TuningScope, key: string): number {
if (!snapshot) return 0; if (!snapshot) return 0;
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning]; 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]; return snapshot.profiles[scope][key as keyof WeightProfile];
} }
@@ -276,6 +295,7 @@
type="button" type="button"
disabled={saving !== null} disabled={saving !== null}
onclick={() => reset(p.scope)} 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" 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 Reset to defaults
@@ -328,6 +348,63 @@
type="button" type="button"
disabled={saving !== null} disabled={saving !== null}
onclick={() => reset('taste')} 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" 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 Reset to defaults
+77 -3
View File
@@ -43,13 +43,25 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
...over ...over
}); });
const discover = (over: Partial<Record<string, number>> = {}) => ({
tag_overlap_weight: 1,
snooze_days: 90,
...over
});
// NOTE the `as TuningSnapshot` cast below: it silences the type error when the
// server snapshot grows a field, so a missing key here surfaces as every test
// on this page throwing inside fillForm rather than as a compile failure. Add
// new scopes to BOTH this fixture and `shipped`.
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot { function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return { return {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(), taste: taste(),
discover: discover(),
shipped: { shipped: {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste() taste: taste(),
discover: discover()
}, },
...over ...over
} as TuningSnapshot; } as TuningSnapshot;
@@ -99,13 +111,17 @@ describe('Admin tuning page', () => {
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.')); await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
}); });
// Targets the button by its accessible name, not its position. This used to
// click the LAST reset button and assume that meant taste — which silently
// became the wrong scope the moment a card was added below it.
test('reset calls resetTuning for the scope', async () => { test('reset calls resetTuning for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot()); (getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot()); (resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage); render(TuningPage);
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i }); await fireEvent.click(
await fireEvent.click(resetButtons[resetButtons.length - 1]); screen.getByRole('button', { name: /reset taste profile build to defaults/i })
);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste')); await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
}); });
@@ -198,4 +214,62 @@ describe('Admin tuning page', () => {
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument() expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
); );
}); });
// --- Discover scope (#2377) ---
test('renders the Discover card with its two knobs', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
expect(screen.getByLabelText(/taste-tag weight/i)).toBeInTheDocument();
expect(screen.getByLabelText(/snooze length/i)).toBeInTheDocument();
});
test('saving Discover patches only the changed field under the discover scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), {
target: { value: '2.5' }
});
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 2.5 })
);
});
// 0 is the operator's off switch for the whole tag term, so the form must be
// able to send it — a falsy-value bug here would silently make the feature
// impossible to disable.
test('a Discover weight of 0 is sent, not dropped as falsy', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), { target: { value: '0' } });
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 0 })
);
});
test('reset targets the discover scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
await fireEvent.click(
screen.getByRole('button', { name: /reset discover requests to defaults/i })
);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('discover'));
});
test('a deviating Discover knob is dotted', async () => {
const snap = snapshot();
snap.discover = discover({ tag_overlap_weight: 3 });
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
expect(screen.getByTitle(/deviates from the shipped default \(1\)/i)).toBeInTheDocument();
});
}); });