Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268) #116
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,13 +43,25 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
|
||||
...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 {
|
||||
return {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
taste: taste(),
|
||||
discover: discover(),
|
||||
shipped: {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
taste: taste()
|
||||
taste: taste(),
|
||||
discover: discover()
|
||||
},
|
||||
...over
|
||||
} as TuningSnapshot;
|
||||
@@ -99,13 +111,17 @@ describe('Admin tuning page', () => {
|
||||
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 () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
|
||||
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
|
||||
await fireEvent.click(resetButtons[resetButtons.length - 1]);
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /reset taste profile build to defaults/i })
|
||||
);
|
||||
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
|
||||
});
|
||||
|
||||
@@ -198,4 +214,62 @@ describe('Admin tuning page', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user