Files
minstrel/web/src/lib/api/tuning.ts
T
bvandeusenandClaude Opus 5 ca4832e620
test-web / test (push) Successful in 35s
feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
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>
2026-08-02 23:49:17 -04:00

101 lines
2.6 KiB
TypeScript

import { api } from './client';
// Mirrors internal/api/admin_recommendation_tuning.go (#1250): the
// recommendation tuning lab. Field names are the server's snake_case
// keys, used verbatim in PATCH bodies.
export type WeightProfile = {
base_weight: number;
like_boost: number;
recency_weight: number;
skip_penalty: number;
jitter_magnitude: number;
context_weight: number;
similarity_weight: number;
taste_weight: number;
context_time_weight: number;
};
export type TasteTuning = {
half_life_days: number;
engagement_hard_skip: number;
engagement_neutral: number;
engagement_full: number;
enriched_tag_scale: number;
era_scale: number;
mood_scale: number;
};
// 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;
};
};
export function getTuning(): Promise<TuningSnapshot> {
return api.get<TuningSnapshot>('/api/admin/recommendation-tuning');
}
export function patchTuning(
scope: TuningScope,
values: Record<string, number>
): Promise<TuningSnapshot> {
return api.patch<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}`, { values });
}
export function resetTuning(scope: TuningScope): Promise<TuningSnapshot> {
return api.post<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}/reset`, {});
}
// Trends (#1251): weekly per-surface outcome series with knob-turn
// markers. Mirrors internal/api/admin_recommendation_trends.go.
export type TrendPoint = {
week_start: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
taste_hit_rate: number;
};
export type TrendSeries = {
key: string;
label: string;
intent: string;
plays: number;
points: TrendPoint[];
};
export type TrendMarker = {
changed_at: string;
scope: string;
action: string;
changes: { field: string; old: number; new: number }[];
};
export type TrendsResponse = {
weeks: number;
series: TrendSeries[];
markers: TrendMarker[];
};
export function getTrends(weeks = 12): Promise<TrendsResponse> {
return api.get<TrendsResponse>(`/api/admin/recommendation-trends?weeks=${weeks}`);
}