feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and out of the systemMixWeights hard-code (daily_mix profile) into DB-backed settings with live effect (#1250) — the defaults-discovery lab per decision #1247: the operator turns knobs to find good values, which then get baked back into shipped defaults; end users and other operators should never need the card. - Migration 0040: recommendation_weight_profiles (radio / daily_mix, 8 weight columns), taste_tuning singleton (engagement half-life + completion-curve points), recommendation_tuning_audit (one row per change with a {field, old, new} diff — the trend view's markers, #1251). - internal/recsettings: boot reconcile seeds shipped defaults without clobbering tuned rows (coverart SettingsService pattern), validates patches (bounds, curve ordering), writes audit rows, and pushes daily_mix weights + taste config into package playlists. No-op patches write no audit row. - playlists gains SetSystemMixWeights / SetTasteConfig swap points under a RWMutex — no signature threading through the producers; the scheduler's taste rebuild reads the pushed config. - Radio reads its weight profile from the service per request; the 8 weight fields leave config.RecommendationConfig (YAML keeps only RecentlyPlayedHours / RadioSize / RadioSizeMax). - Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning, echoing current + shipped values. - Web: new admin Tuning tab — two weight profiles side by side, taste card, per-scope save (changed fields only) + reset, deviation dots against shipped defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import type { TuningSnapshot } from '$lib/api/tuning';
|
||||
|
||||
vi.mock('$lib/api/tuning', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getTuning: vi.fn(),
|
||||
patchTuning: vi.fn(),
|
||||
resetTuning: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
const pushToast = vi.fn();
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
|
||||
|
||||
import TuningPage from './+page.svelte';
|
||||
import { getTuning, patchTuning, resetTuning } from '$lib/api/tuning';
|
||||
|
||||
const weights = (over: Partial<Record<string, number>> = {}) => ({
|
||||
base_weight: 1,
|
||||
like_boost: 2,
|
||||
recency_weight: 1,
|
||||
skip_penalty: 2,
|
||||
jitter_magnitude: 0.1,
|
||||
context_weight: 0.5,
|
||||
similarity_weight: 1.5,
|
||||
taste_weight: 1.5,
|
||||
...over
|
||||
});
|
||||
|
||||
const taste = (over: Partial<Record<string, number>> = {}) => ({
|
||||
half_life_days: 75,
|
||||
engagement_hard_skip: 0.05,
|
||||
engagement_neutral: 0.3,
|
||||
engagement_full: 0.9,
|
||||
...over
|
||||
});
|
||||
|
||||
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
||||
return {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
taste: taste(),
|
||||
shipped: {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
taste: taste()
|
||||
},
|
||||
...over
|
||||
} as TuningSnapshot;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Admin tuning page', () => {
|
||||
test('renders both profiles and the taste card with current values', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
|
||||
const radioTaste = screen.getByLabelText(/taste weight/i, {
|
||||
selector: '#radio-taste_weight'
|
||||
}) as HTMLInputElement;
|
||||
expect(radioTaste.value).toBe('1');
|
||||
const halfLife = document.getElementById('taste-half_life_days') as HTMLInputElement;
|
||||
expect(halfLife.value).toBe('75');
|
||||
});
|
||||
|
||||
test('save sends only the changed fields for the 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('Radio')).toBeInTheDocument());
|
||||
const input = document.getElementById('radio-taste_weight') as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: '2.5' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
|
||||
await waitFor(() =>
|
||||
expect(patchTuning).toHaveBeenCalledWith('radio', { taste_weight: 2.5 })
|
||||
);
|
||||
});
|
||||
|
||||
test('save with no changes calls nothing and says so', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
|
||||
expect(patchTuning).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
|
||||
});
|
||||
|
||||
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 waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
|
||||
});
|
||||
|
||||
test('marks knobs that deviate from shipped defaults', async () => {
|
||||
const snap = snapshot();
|
||||
snap.profiles.daily_mix = weights({ similarity_weight: 4 });
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Daily mixes')).toBeInTheDocument());
|
||||
expect(
|
||||
screen.getByTitle(/deviates from the shipped default \(1\.5\)/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user