test-web / test (push) Successful in 35s
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>
101 lines
2.6 KiB
TypeScript
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}`);
|
|
}
|