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,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
)
|
||||
|
||||
func newTuningRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||
return r
|
||||
}
|
||||
|
||||
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
|
||||
t.Helper()
|
||||
var snap tuningSnapshot
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newTuningRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
snap := decodeTuning(t, rec)
|
||||
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
|
||||
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
|
||||
}
|
||||
if snap.Taste.HalfLifeDays != 75 {
|
||||
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
|
||||
}
|
||||
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
|
||||
t.Error("untouched values must equal shipped defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
r := newTuningRouter(h)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
|
||||
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
snap := decodeTuning(t, rec)
|
||||
if snap.Profiles["radio"].TasteWeight != 2.5 {
|
||||
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
|
||||
}
|
||||
// The change is live for the radio scoring path.
|
||||
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
|
||||
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reset status = %d, want 200", rec.Code)
|
||||
}
|
||||
snap = decodeTuning(t, rec)
|
||||
if snap.Profiles["radio"].TasteWeight != 1.0 {
|
||||
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationTuning_PatchErrors(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
r := newTuningRouter(h)
|
||||
cases := []struct {
|
||||
name, path, body string
|
||||
want int
|
||||
}{
|
||||
{"unknown scope", "/api/admin/recommendation-tuning/banana",
|
||||
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
|
||||
{"unknown field", "/api/admin/recommendation-tuning/radio",
|
||||
`{"values":{"vibes":1}}`, http.StatusBadRequest},
|
||||
{"out of range", "/api/admin/recommendation-tuning/taste",
|
||||
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
|
||||
{"empty values", "/api/admin/recommendation-tuning/radio",
|
||||
`{"values":{}}`, http.StatusBadRequest},
|
||||
{"bad json", "/api/admin/recommendation-tuning/radio",
|
||||
`{`, http.StatusBadRequest},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user