0d0a8f46b1
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
111 lines
3.7 KiB
Go
111 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|