Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268) #116
@@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user