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:
@@ -25,6 +25,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
@@ -146,6 +147,16 @@ func run() error {
|
|||||||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||||||
coverEnricher.DataDir = cfg.Storage.DataDir
|
coverEnricher.DataDir = cfg.Storage.DataDir
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||||||
|
// boot and pushes the daily_mix weights + taste config into package
|
||||||
|
// playlists — must precede the scheduler so the first builds score
|
||||||
|
// with the operator's tuned values, not the pre-push literals.
|
||||||
|
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("recommendation settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
@@ -294,6 +305,7 @@ func run() error {
|
|||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
|
srv.RecSettings = recSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Admin recommendation-tuning endpoints (#1250): the defaults-
|
||||||
|
// discovery lab. GET returns current values + shipped defaults for
|
||||||
|
// every scope; PATCH applies a partial update to one scope; reset
|
||||||
|
// restores a scope to shipped defaults. Every change writes an audit
|
||||||
|
// row (consumed by the metrics trend view, #1251).
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
|
// snake_case field names the PATCH body accepts.
|
||||||
|
type weightsResp struct {
|
||||||
|
BaseWeight float64 `json:"base_weight"`
|
||||||
|
LikeBoost float64 `json:"like_boost"`
|
||||||
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
|
ContextWeight float64 `json:"context_weight"`
|
||||||
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
|
return weightsResp{
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasteTuningResp struct {
|
||||||
|
HalfLifeDays float64 `json:"half_life_days"`
|
||||||
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
|
return tasteTuningResp{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
|
// current values alongside shipped defaults so the card can mark
|
||||||
|
// which knobs deviate.
|
||||||
|
type tuningSnapshot struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Shipped struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
} `json:"shipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||||
|
var out tuningSnapshot
|
||||||
|
out.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
|
}
|
||||||
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
|
}
|
||||||
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
|
||||||
|
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchTuningReq carries the partial update: field name → new value,
|
||||||
|
// using the same snake_case names the GET response emits.
|
||||||
|
type patchTuningReq struct {
|
||||||
|
Values map[string]float64 `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePatchRecommendationTuning implements
|
||||||
|
// PATCH /api/admin/recommendation-tuning/{scope}.
|
||||||
|
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
var body patchTuningReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Values) == 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if scope == recsettings.ScopeTaste {
|
||||||
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
|
} else {
|
||||||
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRecommendationTuning implements
|
||||||
|
// POST /api/admin/recommendation-tuning/{scope}/reset.
|
||||||
|
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTuningErr maps recsettings validation errors to 400s and
|
||||||
|
// everything else to a logged 500.
|
||||||
|
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownScope):
|
||||||
|
writeErr(w, &apierror.Error{
|
||||||
|
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
|
||||||
|
})
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("tuning update failed", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -22,16 +22,18 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
@@ -201,6 +203,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): scoring-weight
|
||||||
|
// profiles + taste-build knobs, DB-backed, live effect.
|
||||||
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
})
|
})
|
||||||
|
|
||||||
authed.Get("/playlists", h.handleListPlaylists)
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
@@ -223,6 +231,7 @@ type handlers struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
|
recSettings *recsettings.Service
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dbtest.ResetDB(t, pool)
|
dbtest.ResetDB(t, pool)
|
||||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
|
recSettings, err := recsettings.New(context.Background(), pool, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings: %v", err)
|
||||||
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||||
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
+4
-10
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RadioResponse is the body of GET /api/radio.
|
// RadioResponse is the body of GET /api/radio.
|
||||||
@@ -100,16 +101,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
weights := recommendation.ScoringWeights{
|
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||||
BaseWeight: h.recCfg.BaseWeight,
|
// read per request so an admin change takes effect live.
|
||||||
LikeBoost: h.recCfg.LikeBoost,
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||||
RecencyWeight: h.recCfg.RecencyWeight,
|
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
|
||||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
|
||||||
TasteWeight: h.recCfg.TasteWeight,
|
|
||||||
}
|
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
out := make([]TrackRef, 0, len(picks)+1)
|
out := make([]TrackRef, 0, len(picks)+1)
|
||||||
|
|||||||
@@ -88,20 +88,14 @@ type EventsConfig struct {
|
|||||||
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
|
// RecommendationConfig holds the radio path's operational knobs. The
|
||||||
// All weights are operator-tunable; defaults match the spec recommendations.
|
// scoring WEIGHTS moved to DB-backed admin settings (#1250,
|
||||||
|
// internal/recsettings) — YAML is bootstrap-only; anything an operator
|
||||||
|
// tunes lives in the UI with live effect.
|
||||||
type RecommendationConfig struct {
|
type RecommendationConfig struct {
|
||||||
BaseWeight float64 `yaml:"base_weight"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
LikeBoost float64 `yaml:"like_boost"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RecencyWeight float64 `yaml:"recency_weight"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
|
||||||
SimilarityWeight float64 `yaml:"similarity_weight"`
|
|
||||||
TasteWeight float64 `yaml:"taste_weight"`
|
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
|
||||||
RadioSize int `yaml:"radio_size"`
|
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -120,16 +114,6 @@ func Default() Config {
|
|||||||
SkipMaxDurationPlayedMs: 30000,
|
SkipMaxDurationPlayedMs: 30000,
|
||||||
},
|
},
|
||||||
Recommendation: RecommendationConfig{
|
Recommendation: RecommendationConfig{
|
||||||
BaseWeight: 1.0,
|
|
||||||
LikeBoost: 2.0,
|
|
||||||
RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0,
|
|
||||||
JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0,
|
|
||||||
SimilarityWeight: 2.0,
|
|
||||||
// Radio is seed-directed (the user picked a direction), so taste
|
|
||||||
// is a lighter nudge here than in the daily mixes (1.5).
|
|
||||||
TasteWeight: 1.0,
|
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
|
|||||||
@@ -437,6 +437,27 @@ type PlaylistTrack struct {
|
|||||||
PickKind *string
|
PickKind *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecommendationTuningAudit struct {
|
||||||
|
ID int64
|
||||||
|
ChangedAt pgtype.Timestamptz
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationWeightProfile struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type RegistrationSetting struct {
|
type RegistrationSetting struct {
|
||||||
ID bool
|
ID bool
|
||||||
Mode string
|
Mode string
|
||||||
@@ -525,6 +546,15 @@ type TasteProfileTag struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteTuning struct {
|
||||||
|
Singleton bool
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: recommendation_tuning.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||||
|
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTasteTuning)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTuningAudit = `-- name: InsertTuningAudit :exec
|
||||||
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTuningAuditParams struct {
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes is a jsonb array of {field, old, new} objects.
|
||||||
|
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTuningAudit = `-- name: ListTuningAudit :many
|
||||||
|
SELECT id, changed_at, scope, action, changes
|
||||||
|
FROM recommendation_tuning_audit
|
||||||
|
ORDER BY changed_at DESC, id DESC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Newest first; consumed by the metrics trend view (#1251) to annotate
|
||||||
|
// knob turns on the timeline.
|
||||||
|
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTuningAudit, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationTuningAudit
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationTuningAudit
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ChangedAt,
|
||||||
|
&i.Scope,
|
||||||
|
&i.Action,
|
||||||
|
&i.Changes,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
||||||
|
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWeightProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeightProfile
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
||||||
|
UPDATE taste_tuning
|
||||||
|
SET half_life_days = $1,
|
||||||
|
engagement_hard_skip = $2,
|
||||||
|
engagement_neutral = $3,
|
||||||
|
engagement_full = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTasteTuningParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateTasteTuning,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||||
|
UPDATE recommendation_weight_profiles
|
||||||
|
SET base_weight = $2,
|
||||||
|
like_boost = $3,
|
||||||
|
recency_weight = $4,
|
||||||
|
skip_penalty = $5,
|
||||||
|
jitter_magnitude = $6,
|
||||||
|
context_weight = $7,
|
||||||
|
similarity_weight = $8,
|
||||||
|
taste_weight = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE profile = $1
|
||||||
|
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateWeightProfileParams struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateWeightProfile,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
)
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
|
INSERT INTO taste_tuning (
|
||||||
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
engagement_neutral, engagement_full
|
||||||
|
) VALUES (true, $1, $2, $3, $4)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTasteTuningDefaultsParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
||||||
|
|
||||||
|
INSERT INTO recommendation_weight_profiles (
|
||||||
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
|
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (profile) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertWeightProfileDefaultsParams struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
|
// recsettings boot reconcile; shipped defaults live in Go only.
|
||||||
|
// Boot reconcile: insert the shipped defaults for a profile if the row
|
||||||
|
// doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
|
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE recommendation_tuning_audit;
|
||||||
|
DROP TABLE taste_tuning;
|
||||||
|
DROP TABLE recommendation_weight_profiles;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Recommendation tuning lab (milestone #127, Scribe #1250).
|
||||||
|
--
|
||||||
|
-- The scoring weights move out of YAML (radio profile) and out of the
|
||||||
|
-- systemMixWeights hard-code (daily_mix profile) into DB-backed
|
||||||
|
-- settings with live effect — the defaults-discovery lab (decision
|
||||||
|
-- #1247): the operator turns knobs here to FIND good values, which
|
||||||
|
-- then get baked into shipped defaults; end users and other operators
|
||||||
|
-- should never need this card.
|
||||||
|
--
|
||||||
|
-- Rows are seeded by the recsettings service's boot reconcile (the
|
||||||
|
-- coverart SettingsService pattern), not by this migration, so shipped
|
||||||
|
-- defaults live in exactly one place (Go).
|
||||||
|
|
||||||
|
CREATE TABLE recommendation_weight_profiles (
|
||||||
|
profile text PRIMARY KEY
|
||||||
|
CONSTRAINT recommendation_weight_profiles_profile_check
|
||||||
|
CHECK (profile IN ('radio', 'daily_mix')),
|
||||||
|
base_weight double precision NOT NULL,
|
||||||
|
like_boost double precision NOT NULL,
|
||||||
|
recency_weight double precision NOT NULL,
|
||||||
|
skip_penalty double precision NOT NULL,
|
||||||
|
jitter_magnitude double precision NOT NULL,
|
||||||
|
context_weight double precision NOT NULL,
|
||||||
|
similarity_weight double precision NOT NULL,
|
||||||
|
taste_weight double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Taste-profile build knobs: engagement half-life + the completion→
|
||||||
|
-- engagement curve points (taste.Config's tunable subset).
|
||||||
|
CREATE TABLE taste_tuning (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true
|
||||||
|
CONSTRAINT taste_tuning_singleton_check CHECK (singleton),
|
||||||
|
half_life_days double precision NOT NULL,
|
||||||
|
engagement_hard_skip double precision NOT NULL,
|
||||||
|
engagement_neutral double precision NOT NULL,
|
||||||
|
engagement_full double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Every knob turn writes one audit row; the metrics trend view
|
||||||
|
-- (#1251) annotates these on its timeline so cause→effect is visible
|
||||||
|
-- after a change. changes is a jsonb array of {field, old, new}.
|
||||||
|
CREATE TABLE recommendation_tuning_audit (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
scope text NOT NULL
|
||||||
|
CONSTRAINT recommendation_tuning_audit_scope_check
|
||||||
|
CHECK (scope IN ('radio', 'daily_mix', 'taste')),
|
||||||
|
action text NOT NULL
|
||||||
|
CONSTRAINT recommendation_tuning_audit_action_check
|
||||||
|
CHECK (action IN ('update', 'reset')),
|
||||||
|
changes jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX recommendation_tuning_audit_changed_at_idx
|
||||||
|
ON recommendation_tuning_audit (changed_at DESC);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
|
-- recsettings boot reconcile; shipped defaults live in Go only.
|
||||||
|
|
||||||
|
-- name: UpsertWeightProfileDefaults :exec
|
||||||
|
-- Boot reconcile: insert the shipped defaults for a profile if the row
|
||||||
|
-- doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
|
INSERT INTO recommendation_weight_profiles (
|
||||||
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
|
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (profile) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: ListWeightProfiles :many
|
||||||
|
SELECT * FROM recommendation_weight_profiles ORDER BY profile;
|
||||||
|
|
||||||
|
-- name: UpdateWeightProfile :one
|
||||||
|
UPDATE recommendation_weight_profiles
|
||||||
|
SET base_weight = $2,
|
||||||
|
like_boost = $3,
|
||||||
|
recency_weight = $4,
|
||||||
|
skip_penalty = $5,
|
||||||
|
jitter_magnitude = $6,
|
||||||
|
context_weight = $7,
|
||||||
|
similarity_weight = $8,
|
||||||
|
taste_weight = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE profile = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpsertTasteTuningDefaults :exec
|
||||||
|
INSERT INTO taste_tuning (
|
||||||
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
engagement_neutral, engagement_full
|
||||||
|
) VALUES (true, $1, $2, $3, $4)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: GetTasteTuning :one
|
||||||
|
SELECT * FROM taste_tuning WHERE singleton = true;
|
||||||
|
|
||||||
|
-- name: UpdateTasteTuning :one
|
||||||
|
UPDATE taste_tuning
|
||||||
|
SET half_life_days = $1,
|
||||||
|
engagement_hard_skip = $2,
|
||||||
|
engagement_neutral = $3,
|
||||||
|
engagement_full = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: InsertTuningAudit :exec
|
||||||
|
-- changes is a jsonb array of {field, old, new} objects.
|
||||||
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: ListTuningAudit :many
|
||||||
|
-- Newest first; consumed by the metrics trend view (#1251) to annotate
|
||||||
|
-- knob turns on the timeline.
|
||||||
|
SELECT id, changed_at, scope, action, changes
|
||||||
|
FROM recommendation_tuning_audit
|
||||||
|
ORDER BY changed_at DESC, id DESC
|
||||||
|
LIMIT $1;
|
||||||
@@ -62,6 +62,11 @@ var dataTables = []string{
|
|||||||
// (never recreates the singleton, seeded once by 0018); ResetDB
|
// (never recreates the singleton, seeded once by 0018); ResetDB
|
||||||
// resets its counter via UPDATE below instead.
|
// resets its counter via UPDATE below instead.
|
||||||
"cover_art_provider_settings",
|
"cover_art_provider_settings",
|
||||||
|
// recsettings.New reconciles shipped defaults on every construction
|
||||||
|
// (#1250), so truncating gives each test pristine tuning values.
|
||||||
|
"recommendation_weight_profiles",
|
||||||
|
"taste_tuning",
|
||||||
|
"recommendation_tuning_audit",
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiv
|
|||||||
// build can read a fresh profile (phase-2 consumption); both steps are
|
// build can read a fresh profile (phase-2 consumption); both steps are
|
||||||
// best-effort and a failure in one is logged without blocking the other.
|
// best-effort and a failure in one is logged without blocking the other.
|
||||||
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
|
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
|
||||||
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil {
|
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, currentTasteConfig()); err != nil {
|
||||||
s.logger.Warn("scheduler: taste profile rebuild failed",
|
s.logger.Warn("scheduler: taste profile rebuild failed",
|
||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
|
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
|
||||||
@@ -175,24 +177,64 @@ func pickKindForMixTier(tier int32) string {
|
|||||||
|
|
||||||
const systemMixLength = 25
|
const systemMixLength = 25
|
||||||
|
|
||||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
// systemMixWeights are the scoring weights used by the daily builds.
|
||||||
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
|
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
|
||||||
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
|
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
|
||||||
// scores within a day, but near-tied candidates reshuffle across days
|
// scores within a day, but near-tied candidates reshuffle across days
|
||||||
// so the playlist doesn't feel frozen.
|
// so the playlist doesn't feel frozen.
|
||||||
var systemMixWeights = recommendation.ScoringWeights{
|
//
|
||||||
BaseWeight: 1.0,
|
// DB-tunable since #1250: the recsettings service pushes the current
|
||||||
LikeBoost: 2.0,
|
// daily_mix profile via SetSystemMixWeights at boot and on every admin
|
||||||
RecencyWeight: 1.0,
|
// change (coverart Configure() pattern — no signature threading, live
|
||||||
SkipPenalty: 2.0,
|
// effect without restart). The literal here is only the pre-push
|
||||||
JitterMagnitude: 0.1,
|
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
|
||||||
ContextWeight: 0.5,
|
// which must stay in sync with it.
|
||||||
SimilarityWeight: 1.5,
|
var (
|
||||||
// Taste profile (#796 phase 2): the daily mixes are the primary
|
systemTuningMu sync.RWMutex
|
||||||
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
systemMixWeights = recommendation.ScoringWeights{
|
||||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
BaseWeight: 1.0,
|
||||||
// while passive avoidance (negative) gently demotes.
|
LikeBoost: 2.0,
|
||||||
TasteWeight: 1.5,
|
RecencyWeight: 1.0,
|
||||||
|
SkipPenalty: 2.0,
|
||||||
|
JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 0.5,
|
||||||
|
SimilarityWeight: 1.5,
|
||||||
|
// Taste profile (#796 phase 2): the daily mixes are the primary
|
||||||
|
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
||||||
|
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||||
|
// while passive avoidance (negative) gently demotes.
|
||||||
|
TasteWeight: 1.5,
|
||||||
|
}
|
||||||
|
systemTasteConfig = taste.DefaultConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSystemMixWeights installs the current daily_mix scoring weights.
|
||||||
|
// Called by the recsettings service at boot and on admin updates.
|
||||||
|
func SetSystemMixWeights(w recommendation.ScoringWeights) {
|
||||||
|
systemTuningMu.Lock()
|
||||||
|
defer systemTuningMu.Unlock()
|
||||||
|
systemMixWeights = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTasteConfig installs the taste-profile build configuration
|
||||||
|
// (half-life + engagement curve, #1250). Same push model as
|
||||||
|
// SetSystemMixWeights.
|
||||||
|
func SetTasteConfig(c taste.Config) {
|
||||||
|
systemTuningMu.Lock()
|
||||||
|
defer systemTuningMu.Unlock()
|
||||||
|
systemTasteConfig = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentSystemMixWeights() recommendation.ScoringWeights {
|
||||||
|
systemTuningMu.RLock()
|
||||||
|
defer systemTuningMu.RUnlock()
|
||||||
|
return systemMixWeights
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentTasteConfig() taste.Config {
|
||||||
|
systemTuningMu.RLock()
|
||||||
|
defer systemTuningMu.RUnlock()
|
||||||
|
return systemTasteConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
||||||
@@ -355,9 +397,10 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
|||||||
sort.SliceStable(ordered, func(i, j int) bool {
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||||
})
|
})
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
pairs := make([]scored, len(ordered))
|
pairs := make([]scored, len(ordered))
|
||||||
for i, c := range ordered {
|
for i, c := range ordered {
|
||||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
|
||||||
}
|
}
|
||||||
sort.SliceStable(pairs, func(i, j int) bool {
|
sort.SliceStable(pairs, func(i, j int) bool {
|
||||||
if pairs[i].score != pairs[j].score {
|
if pairs[i].score != pairs[j].score {
|
||||||
@@ -784,11 +827,12 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
|||||||
capped = capped[:n]
|
capped = capped[:n]
|
||||||
}
|
}
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
out := make([]rankedCandidate, len(capped))
|
out := make([]rankedCandidate, len(capped))
|
||||||
for i, c := range capped {
|
for i, c := range capped {
|
||||||
out[i] = rankedCandidate{
|
out[i] = rankedCandidate{
|
||||||
TrackID: c.Track.ID,
|
TrackID: c.Track.ID,
|
||||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -815,6 +859,7 @@ func pickHeadAndTail(
|
|||||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
|
|
||||||
total := headN + tailN
|
total := headN + tailN
|
||||||
if len(capped) <= total {
|
if len(capped) <= total {
|
||||||
@@ -828,7 +873,7 @@ func pickHeadAndTail(
|
|||||||
for i := 0; i < total; i++ {
|
for i := 0; i < total; i++ {
|
||||||
out[i] = rankedCandidate{
|
out[i] = rankedCandidate{
|
||||||
TrackID: capped[i].Track.ID,
|
TrackID: capped[i].Track.ID,
|
||||||
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
|
Score: recommendation.Score(capped[i].Inputs, weights, now, rng.Float64),
|
||||||
PickKind: pickKindTaste,
|
PickKind: pickKindTaste,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -875,7 +920,7 @@ func pickHeadAndTail(
|
|||||||
}
|
}
|
||||||
out[i] = rankedCandidate{
|
out[i] = rankedCandidate{
|
||||||
TrackID: c.Track.ID,
|
TrackID: c.Track.ID,
|
||||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
|
||||||
PickKind: kind,
|
PickKind: kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,11 +127,12 @@ func rollUpCandidates(
|
|||||||
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
) (albumIDs, artistIDs []pgtype.UUID) {
|
) (albumIDs, artistIDs []pgtype.UUID) {
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
albumScores := map[pgtype.UUID][]float64{}
|
albumScores := map[pgtype.UUID][]float64{}
|
||||||
artistScores := map[pgtype.UUID][]float64{}
|
artistScores := map[pgtype.UUID][]float64{}
|
||||||
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
|
s := recommendation.Score(c.Inputs, weights, now, rng.Float64)
|
||||||
if c.Track.AlbumID.Valid {
|
if c.Track.AlbumID.Valid {
|
||||||
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
||||||
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// patch.go — field-name mapping + validation for the tuning patches.
|
||||||
|
// Wire field names are the snake_case column names; the admin API and
|
||||||
|
// web card use them verbatim.
|
||||||
|
package recsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnknownScope = errors.New("unknown tuning scope")
|
||||||
|
ErrUnknownField = errors.New("unknown tuning field")
|
||||||
|
ErrOutOfRange = errors.New("tuning value out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightBound caps every scoring weight's magnitude. The scoring
|
||||||
|
// terms are all in [-1, 1] before weighting, so ±10 is far past any
|
||||||
|
// useful setting — the bound exists to catch typos (e.g. 100 for
|
||||||
|
// 1.00), not to constrain exploration.
|
||||||
|
const weightBound = 10.0
|
||||||
|
|
||||||
|
// weightField describes one patchable ScoringWeights field.
|
||||||
|
type weightField struct {
|
||||||
|
get func(recommendation.ScoringWeights) float64
|
||||||
|
set func(*recommendation.ScoringWeights, float64)
|
||||||
|
// nonNegative marks fields where a negative value is meaningless
|
||||||
|
// (a negative jitter magnitude or skip penalty inverts intent in a
|
||||||
|
// way the score formula already expresses through its sign).
|
||||||
|
nonNegative bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var weightFields = map[string]weightField{
|
||||||
|
"base_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.BaseWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.BaseWeight = v },
|
||||||
|
},
|
||||||
|
"like_boost": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.LikeBoost },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.LikeBoost = v },
|
||||||
|
},
|
||||||
|
"recency_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.RecencyWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.RecencyWeight = v },
|
||||||
|
},
|
||||||
|
"skip_penalty": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.SkipPenalty },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.SkipPenalty = v },
|
||||||
|
nonNegative: true,
|
||||||
|
},
|
||||||
|
"jitter_magnitude": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.JitterMagnitude },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.JitterMagnitude = v },
|
||||||
|
nonNegative: true,
|
||||||
|
},
|
||||||
|
"context_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.ContextWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextWeight = v },
|
||||||
|
},
|
||||||
|
"similarity_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.SimilarityWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.SimilarityWeight = v },
|
||||||
|
},
|
||||||
|
"taste_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWeightPatch validates and applies a partial update, returning
|
||||||
|
// the new weights and the list of actual changes (values equal to the
|
||||||
|
// current setting are dropped, so a re-submitted form is a no-op).
|
||||||
|
func applyWeightPatch(
|
||||||
|
current recommendation.ScoringWeights, patch map[string]float64,
|
||||||
|
) (recommendation.ScoringWeights, []fieldChange, error) {
|
||||||
|
next := current
|
||||||
|
var changes []fieldChange
|
||||||
|
for field, v := range patch {
|
||||||
|
f, ok := weightFields[field]
|
||||||
|
if !ok {
|
||||||
|
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||||
|
}
|
||||||
|
if v < -weightBound || v > weightBound {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (|v| must be <= %v)",
|
||||||
|
ErrOutOfRange, field, v, weightBound)
|
||||||
|
}
|
||||||
|
if f.nonNegative && v < 0 {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be >= 0)",
|
||||||
|
ErrOutOfRange, field, v)
|
||||||
|
}
|
||||||
|
old := f.get(next)
|
||||||
|
if old == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.set(&next, v)
|
||||||
|
changes = append(changes, fieldChange{Field: field, Old: old, New: v})
|
||||||
|
}
|
||||||
|
return next, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste tuning bounds. The half-life window is generous — from "taste
|
||||||
|
// is last week" to "taste is a decade" — and the curve points must
|
||||||
|
// stay ordered inside [0, 1] or the engagement ramps degenerate.
|
||||||
|
const (
|
||||||
|
tasteHalfLifeMin = 1.0
|
||||||
|
tasteHalfLifeMax = 3650.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyTastePatch validates and applies a partial taste update. The
|
||||||
|
// curve-ordering invariant (hard_skip < neutral < full) is checked on
|
||||||
|
// the PATCHED result, so a patch may move several points at once.
|
||||||
|
func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning, []fieldChange, error) {
|
||||||
|
next := current
|
||||||
|
var changes []fieldChange
|
||||||
|
for field, v := range patch {
|
||||||
|
var target *float64
|
||||||
|
switch field {
|
||||||
|
case "half_life_days":
|
||||||
|
if v < tasteHalfLifeMin || v > tasteHalfLifeMax {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
|
||||||
|
ErrOutOfRange, field, v, tasteHalfLifeMin, tasteHalfLifeMax)
|
||||||
|
}
|
||||||
|
target = &next.HalfLifeDays
|
||||||
|
case "engagement_hard_skip":
|
||||||
|
target = &next.EngagementHardSkip
|
||||||
|
case "engagement_neutral":
|
||||||
|
target = &next.EngagementNeutral
|
||||||
|
case "engagement_full":
|
||||||
|
target = &next.EngagementFull
|
||||||
|
default:
|
||||||
|
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||||
|
}
|
||||||
|
if field != "half_life_days" && (v < 0 || v > 1) {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, 1])",
|
||||||
|
ErrOutOfRange, field, v)
|
||||||
|
}
|
||||||
|
if *target == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
|
||||||
|
*target = v
|
||||||
|
}
|
||||||
|
if !(next.EngagementHardSkip < next.EngagementNeutral &&
|
||||||
|
next.EngagementNeutral < next.EngagementFull) {
|
||||||
|
return current, nil, fmt.Errorf(
|
||||||
|
"%w: engagement curve must satisfy hard_skip < neutral < full (got %v < %v < %v)",
|
||||||
|
ErrOutOfRange, next.EngagementHardSkip, next.EngagementNeutral, next.EngagementFull)
|
||||||
|
}
|
||||||
|
return next, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffWeights returns per-field changes from a to b (empty when equal).
|
||||||
|
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
|
||||||
|
var out []fieldChange
|
||||||
|
for field, f := range weightFields {
|
||||||
|
if f.get(a) != f.get(b) {
|
||||||
|
out = append(out, fieldChange{Field: field, Old: f.get(a), New: f.get(b)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffTaste returns per-field changes from a to b (empty when equal).
|
||||||
|
func diffTaste(a, b TasteTuning) []fieldChange {
|
||||||
|
var out []fieldChange
|
||||||
|
add := func(field string, oldV, newV float64) {
|
||||||
|
if oldV != newV {
|
||||||
|
out = append(out, fieldChange{Field: field, Old: oldV, New: newV})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("half_life_days", a.HalfLifeDays, b.HalfLifeDays)
|
||||||
|
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
|
||||||
|
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
||||||
|
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
// Package recsettings is the DB-backed home of the recommendation
|
||||||
|
// tuning knobs (#1250): the two scoring-weight profiles (radio /
|
||||||
|
// daily_mix) and the taste-profile build settings (engagement
|
||||||
|
// half-life + completion curve). It follows the coverart
|
||||||
|
// SettingsService pattern — boot reconcile seeds shipped defaults for
|
||||||
|
// missing rows, values are cached under a RWMutex, and every change
|
||||||
|
// takes effect live (the daily_mix profile + taste config are pushed
|
||||||
|
// into package playlists; radio reads the cache per request).
|
||||||
|
//
|
||||||
|
// Framing (decision #1247): this is the defaults-discovery lab. The
|
||||||
|
// operator turns knobs to FIND good values; found-good values get
|
||||||
|
// baked back into the Shipped* functions below as new shipped
|
||||||
|
// defaults. End users and other operators should never need the card.
|
||||||
|
package recsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope names — the three tunable groups. radio + daily_mix are weight
|
||||||
|
// profiles; taste is the profile-build settings singleton.
|
||||||
|
const (
|
||||||
|
ScopeRadio = "radio"
|
||||||
|
ScopeDailyMix = "daily_mix"
|
||||||
|
ScopeTaste = "taste"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||||
|
// half-life and the completion→engagement curve points.
|
||||||
|
type TasteTuning struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||||
|
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
||||||
|
// rule: config in UI). Radio is seed-directed (the user picked a
|
||||||
|
// direction), so taste is a lighter nudge than in the daily mixes.
|
||||||
|
func ShippedRadioWeights() recommendation.ScoringWeights {
|
||||||
|
return recommendation.ScoringWeights{
|
||||||
|
BaseWeight: 1.0,
|
||||||
|
LikeBoost: 2.0,
|
||||||
|
RecencyWeight: 1.0,
|
||||||
|
SkipPenalty: 1.0,
|
||||||
|
JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 2.0,
|
||||||
|
SimilarityWeight: 2.0,
|
||||||
|
TasteWeight: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
|
||||||
|
// Must stay in sync with the pre-push literal in playlists/system.go.
|
||||||
|
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||||
|
return recommendation.ScoringWeights{
|
||||||
|
BaseWeight: 1.0,
|
||||||
|
LikeBoost: 2.0,
|
||||||
|
RecencyWeight: 1.0,
|
||||||
|
SkipPenalty: 2.0,
|
||||||
|
JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 0.5,
|
||||||
|
SimilarityWeight: 1.5,
|
||||||
|
TasteWeight: 1.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
||||||
|
func ShippedTasteTuning() TasteTuning {
|
||||||
|
d := taste.DefaultConfig()
|
||||||
|
return TasteTuning{
|
||||||
|
HalfLifeDays: d.HalfLifeDays,
|
||||||
|
EngagementHardSkip: d.Engagement.HardSkip,
|
||||||
|
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||||||
|
EngagementFull: d.Engagement.FullCompletion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service caches the tuning values and owns their DB persistence +
|
||||||
|
// audit trail. Construct with New at boot.
|
||||||
|
type Service struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
profiles map[string]recommendation.ScoringWeights
|
||||||
|
taste TasteTuning
|
||||||
|
}
|
||||||
|
|
||||||
|
// New boots the service: seeds shipped defaults for missing rows,
|
||||||
|
// loads the current values, and pushes the daily_mix weights + taste
|
||||||
|
// config into package playlists so the daily builds pick them up.
|
||||||
|
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
|
||||||
|
s := &Service{
|
||||||
|
pool: pool,
|
||||||
|
logger: logger,
|
||||||
|
profiles: map[string]recommendation.ScoringWeights{},
|
||||||
|
}
|
||||||
|
if err := s.reconcile(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("recsettings boot: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcile seeds missing rows with shipped defaults, reads everything
|
||||||
|
// back into the cache, and pushes the playlist-side values.
|
||||||
|
func (s *Service) reconcile(ctx context.Context) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
for profile, w := range map[string]recommendation.ScoringWeights{
|
||||||
|
ScopeRadio: ShippedRadioWeights(),
|
||||||
|
ScopeDailyMix: ShippedDailyMixWeights(),
|
||||||
|
} {
|
||||||
|
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
|
||||||
|
return fmt.Errorf("seed profile %q: %w", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st := ShippedTasteTuning()
|
||||||
|
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
|
||||||
|
HalfLifeDays: st.HalfLifeDays,
|
||||||
|
EngagementHardSkip: st.EngagementHardSkip,
|
||||||
|
EngagementNeutral: st.EngagementNeutral,
|
||||||
|
EngagementFull: st.EngagementFull,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("seed taste tuning: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListWeightProfiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list weight profiles: %w", err)
|
||||||
|
}
|
||||||
|
tt, err := q.GetTasteTuning(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get taste tuning: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.profiles = map[string]recommendation.ScoringWeights{}
|
||||||
|
for _, r := range rows {
|
||||||
|
s.profiles[r.Profile] = weightsFromRow(r)
|
||||||
|
}
|
||||||
|
s.taste = TasteTuning{
|
||||||
|
HalfLifeDays: tt.HalfLifeDays,
|
||||||
|
EngagementHardSkip: tt.EngagementHardSkip,
|
||||||
|
EngagementNeutral: tt.EngagementNeutral,
|
||||||
|
EngagementFull: tt.EngagementFull,
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.push()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// push installs the daily-build values into package playlists (the
|
||||||
|
// coverart Configure() pattern). Radio needs no push — its handler
|
||||||
|
// reads Weights(ScopeRadio) per request.
|
||||||
|
func (s *Service) push() {
|
||||||
|
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
|
||||||
|
playlists.SetTasteConfig(s.TasteConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weights returns the cached weights for a profile scope. Unknown
|
||||||
|
// scopes return the shipped radio defaults (defensive; callers pass
|
||||||
|
// the Scope* constants).
|
||||||
|
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if w, ok := s.profiles[profile]; ok {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
return ShippedRadioWeights()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste returns the cached taste-tuning values.
|
||||||
|
func (s *Service) Taste() TasteTuning {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.taste
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasteConfig assembles the full taste.Config the profile builder
|
||||||
|
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
||||||
|
// plus the tuned half-life and curve. WindowDays scales with the
|
||||||
|
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
|
||||||
|
// window is a query-cost bound, not an independent knob.
|
||||||
|
func (s *Service) TasteConfig() taste.Config {
|
||||||
|
t := s.Taste()
|
||||||
|
cfg := taste.DefaultConfig()
|
||||||
|
cfg.HalfLifeDays = t.HalfLifeDays
|
||||||
|
cfg.WindowDays = t.HalfLifeDays * 3.6
|
||||||
|
cfg.Engagement = taste.EngagementParams{
|
||||||
|
HardSkip: t.EngagementHardSkip,
|
||||||
|
NeutralCompletion: t.EngagementNeutral,
|
||||||
|
FullCompletion: t.EngagementFull,
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldChange is one entry of an audit row's changes array.
|
||||||
|
type fieldChange struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Old float64 `json:"old"`
|
||||||
|
New float64 `json:"new"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile applies a partial update to one weight profile.
|
||||||
|
// Unknown fields and out-of-range values reject the whole patch. A
|
||||||
|
// no-op patch (all values equal to current) writes no audit row.
|
||||||
|
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
|
||||||
|
if profile != ScopeRadio && profile != ScopeDailyMix {
|
||||||
|
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
|
||||||
|
}
|
||||||
|
current := s.Weights(profile)
|
||||||
|
next, changes, err := applyWeightPatch(current, patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistProfile(ctx, profile, next, "update", changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaste applies a partial update to the taste tuning singleton.
|
||||||
|
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
|
||||||
|
current := s.Taste()
|
||||||
|
next, changes, err := applyTastePatch(current, patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistTaste(ctx, next, "update", changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset restores a scope to its shipped defaults, with one audit row
|
||||||
|
// carrying the full diff. A scope already at defaults is a no-op.
|
||||||
|
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||||
|
switch scope {
|
||||||
|
case ScopeRadio, ScopeDailyMix:
|
||||||
|
shipped := ShippedRadioWeights()
|
||||||
|
if scope == ScopeDailyMix {
|
||||||
|
shipped = ShippedDailyMixWeights()
|
||||||
|
}
|
||||||
|
changes := diffWeights(s.Weights(scope), shipped)
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistProfile(ctx, scope, shipped, "reset", changes)
|
||||||
|
case ScopeTaste:
|
||||||
|
shipped := ShippedTasteTuning()
|
||||||
|
changes := diffTaste(s.Taste(), shipped)
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistTaste(ctx, shipped, "reset", changes)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistProfile writes the profile row + audit entry, refreshes the
|
||||||
|
// cache, and pushes daily-build values.
|
||||||
|
func (s *Service) persistProfile(
|
||||||
|
ctx context.Context, profile string, w recommendation.ScoringWeights,
|
||||||
|
action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
|
||||||
|
return fmt.Errorf("update profile %q: %w", profile, err)
|
||||||
|
}
|
||||||
|
if err := s.audit(ctx, q, profile, action, changes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.profiles[profile] = w
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.push()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistTaste(
|
||||||
|
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("update taste tuning: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.taste = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.push()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit writes one recommendation_tuning_audit row. Changes are
|
||||||
|
// sorted by field so rows are deterministic and diff-friendly.
|
||||||
|
func (s *Service) audit(
|
||||||
|
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
|
||||||
|
payload, err := json.Marshal(changes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal audit changes: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
|
||||||
|
Scope: scope, Action: action, Changes: payload,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("insert audit row: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
|
||||||
|
return dbq.UpsertWeightProfileDefaultsParams{
|
||||||
|
Profile: profile,
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
||||||
|
return dbq.UpdateWeightProfileParams{
|
||||||
|
Profile: profile,
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
||||||
|
return recommendation.ScoringWeights{
|
||||||
|
BaseWeight: r.BaseWeight,
|
||||||
|
LikeBoost: r.LikeBoost,
|
||||||
|
RecencyWeight: r.RecencyWeight,
|
||||||
|
SkipPenalty: r.SkipPenalty,
|
||||||
|
JitterMagnitude: r.JitterMagnitude,
|
||||||
|
ContextWeight: r.ContextWeight,
|
||||||
|
SimilarityWeight: r.SimilarityWeight,
|
||||||
|
TasteWeight: r.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package recsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in -short mode")
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if err := db.Migrate(dsn, logger); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
dbtest.ResetDB(t, pool)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newService(t *testing.T, pool *pgxpool.Pool) *Service {
|
||||||
|
t.Helper()
|
||||||
|
s, err := New(context.Background(), pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings.New: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditRows(t *testing.T, pool *pgxpool.Pool) []struct {
|
||||||
|
Scope, Action string
|
||||||
|
Changes []fieldChange
|
||||||
|
} {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := pool.Query(context.Background(),
|
||||||
|
`SELECT scope, action, changes FROM recommendation_tuning_audit ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query audit: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []struct {
|
||||||
|
Scope, Action string
|
||||||
|
Changes []fieldChange
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var scope, action string
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&scope, &action, &raw); err != nil {
|
||||||
|
t.Fatalf("scan audit: %v", err)
|
||||||
|
}
|
||||||
|
var changes []fieldChange
|
||||||
|
if err := json.Unmarshal(raw, &changes); err != nil {
|
||||||
|
t.Fatalf("unmarshal audit changes: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, struct {
|
||||||
|
Scope, Action string
|
||||||
|
Changes []fieldChange
|
||||||
|
}{scope, action, changes})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_SeedsShippedDefaults(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if got := s.Weights(ScopeRadio); got != ShippedRadioWeights() {
|
||||||
|
t.Errorf("radio weights = %+v, want shipped defaults", got)
|
||||||
|
}
|
||||||
|
if got := s.Weights(ScopeDailyMix); got != ShippedDailyMixWeights() {
|
||||||
|
t.Errorf("daily_mix weights = %+v, want shipped defaults", got)
|
||||||
|
}
|
||||||
|
if got := s.Taste(); got != ShippedTasteTuning() {
|
||||||
|
t.Errorf("taste tuning = %+v, want shipped defaults", got)
|
||||||
|
}
|
||||||
|
// Seeding must not write audit rows — nothing changed.
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("boot reconcile wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProfile_PersistsAndAudits(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if err := s.UpdateProfile(context.Background(), ScopeRadio,
|
||||||
|
map[string]float64{"taste_weight": 2.5, "skip_penalty": 3.0}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
got := s.Weights(ScopeRadio)
|
||||||
|
if got.TasteWeight != 2.5 || got.SkipPenalty != 3.0 {
|
||||||
|
t.Errorf("weights = %+v, want taste 2.5 / skip 3.0", got)
|
||||||
|
}
|
||||||
|
// The other profile is untouched.
|
||||||
|
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
|
||||||
|
t.Error("daily_mix must be unaffected by a radio update")
|
||||||
|
}
|
||||||
|
// Values survive a fresh boot (persisted, not just cached), and the
|
||||||
|
// reconcile's ON CONFLICT DO NOTHING doesn't clobber tuned rows.
|
||||||
|
s2 := newService(t, pool)
|
||||||
|
if got := s2.Weights(ScopeRadio); got.TasteWeight != 2.5 {
|
||||||
|
t.Errorf("rebooted taste_weight = %v, want 2.5", got.TasteWeight)
|
||||||
|
}
|
||||||
|
rows := auditRows(t, pool)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("audit rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Scope != ScopeRadio || rows[0].Action != "update" || len(rows[0].Changes) != 2 {
|
||||||
|
t.Errorf("audit row = %+v, want radio/update with 2 changes", rows[0])
|
||||||
|
}
|
||||||
|
// Changes are field-sorted: skip_penalty before taste_weight.
|
||||||
|
if rows[0].Changes[0].Field != "skip_penalty" || rows[0].Changes[0].New != 3.0 {
|
||||||
|
t.Errorf("changes[0] = %+v, want skip_penalty → 3.0", rows[0].Changes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProfile_Validation(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
scope string
|
||||||
|
patch map[string]float64
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"unknown scope", "banana", map[string]float64{"taste_weight": 1}, ErrUnknownScope},
|
||||||
|
{"unknown field", ScopeRadio, map[string]float64{"vibes": 1}, ErrUnknownField},
|
||||||
|
{"over bound", ScopeRadio, map[string]float64{"taste_weight": 11}, ErrOutOfRange},
|
||||||
|
{"negative jitter", ScopeRadio, map[string]float64{"jitter_magnitude": -0.1}, ErrOutOfRange},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := s.UpdateProfile(context.Background(), c.scope, c.patch); !errors.Is(err, c.want) {
|
||||||
|
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Weights(ScopeRadio) != ShippedRadioWeights() {
|
||||||
|
t.Error("rejected patches must not partially apply")
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
// Moving neutral above full must reject on the PATCHED result.
|
||||||
|
err := s.UpdateTaste(context.Background(), map[string]float64{"engagement_neutral": 0.95})
|
||||||
|
if !errors.Is(err, ErrOutOfRange) {
|
||||||
|
t.Fatalf("err = %v, want ErrOutOfRange (curve ordering)", err)
|
||||||
|
}
|
||||||
|
// A coherent multi-point move is fine.
|
||||||
|
if err := s.UpdateTaste(context.Background(), map[string]float64{
|
||||||
|
"engagement_neutral": 0.40, "engagement_full": 0.95, "half_life_days": 30,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateTaste: %v", err)
|
||||||
|
}
|
||||||
|
cfg := s.TasteConfig()
|
||||||
|
if cfg.HalfLifeDays != 30 || cfg.Engagement.NeutralCompletion != 0.40 {
|
||||||
|
t.Errorf("taste config = %+v, want tuned values", cfg)
|
||||||
|
}
|
||||||
|
// WindowDays scales with the half-life at the shipped ratio.
|
||||||
|
if cfg.WindowDays != 30*3.6 {
|
||||||
|
t.Errorf("WindowDays = %v, want %v", cfg.WindowDays, 30*3.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
// Reset with nothing changed is a no-op (no audit row).
|
||||||
|
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
|
||||||
|
t.Fatalf("no-op reset: %v", err)
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Fatalf("no-op reset wrote audit rows")
|
||||||
|
}
|
||||||
|
if err := s.UpdateProfile(context.Background(), ScopeDailyMix,
|
||||||
|
map[string]float64{"similarity_weight": 4}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
|
||||||
|
t.Fatalf("Reset: %v", err)
|
||||||
|
}
|
||||||
|
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
|
||||||
|
t.Error("reset must restore shipped defaults")
|
||||||
|
}
|
||||||
|
rows := auditRows(t, pool)
|
||||||
|
if len(rows) != 2 || rows[1].Action != "reset" {
|
||||||
|
t.Fatalf("audit rows = %+v, want update then reset", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
shipped := ShippedRadioWeights()
|
||||||
|
if err := s.UpdateProfile(context.Background(), ScopeRadio,
|
||||||
|
map[string]float64{"taste_weight": shipped.TasteWeight}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||||
@@ -88,6 +89,13 @@ type Server struct {
|
|||||||
// PUT /api/me/timezone and POST /api/auth/register can call
|
// PUT /api/me/timezone and POST /api/auth/register can call
|
||||||
// Refresh synchronously.
|
// Refresh synchronously.
|
||||||
PlaylistScheduler *playlists.Scheduler
|
PlaylistScheduler *playlists.Scheduler
|
||||||
|
// RecSettings is the DB-backed recommendation tuning lab (#1250):
|
||||||
|
// scoring-weight profiles + taste-build knobs. Constructed in
|
||||||
|
// cmd/minstrel/main.go (it pushes daily-mix weights into package
|
||||||
|
// playlists at boot); the API layer reads radio weights per request
|
||||||
|
// and serves the admin tuning endpoints from it. Router() constructs
|
||||||
|
// a fallback when nil (tests).
|
||||||
|
RecSettings *recsettings.Service
|
||||||
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
||||||
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
||||||
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
||||||
@@ -143,7 +151,18 @@ func (s *Server) Router() http.Handler {
|
|||||||
if bus == nil {
|
if bus == nil {
|
||||||
bus = eventbus.New()
|
bus = eventbus.New()
|
||||||
}
|
}
|
||||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
recSettings := s.RecSettings
|
||||||
|
if recSettings == nil {
|
||||||
|
// Test contexts construct Server directly without main.go's
|
||||||
|
// boot wiring; reconcile here so radio + the admin tuning
|
||||||
|
// endpoints work against the same pool.
|
||||||
|
var err error
|
||||||
|
recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Error("server: recsettings boot failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||||
// /api/admin/scan is the only admin route owned by the server package
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TasteTuning = {
|
||||||
|
half_life_days: number;
|
||||||
|
engagement_hard_skip: number;
|
||||||
|
engagement_neutral: number;
|
||||||
|
engagement_full: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
||||||
|
|
||||||
|
export type TuningSnapshot = {
|
||||||
|
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||||
|
taste: TasteTuning;
|
||||||
|
shipped: {
|
||||||
|
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||||
|
taste: TasteTuning;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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`, {});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||||
|
{ href: '/admin/tuning', label: 'Tuning' },
|
||||||
{ href: '/admin/users', label: 'Users' }
|
{ href: '/admin/users', label: 'Users' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders all seven tabs in order', () => {
|
test('renders all eight tabs in order', () => {
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
render(AdminTabs);
|
render(AdminTabs);
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
@@ -63,6 +63,7 @@ describe('AdminTabs', () => {
|
|||||||
'Quarantine',
|
'Quarantine',
|
||||||
'Playback errors',
|
'Playback errors',
|
||||||
'Diagnostics',
|
'Diagnostics',
|
||||||
|
'Tuning',
|
||||||
'Users'
|
'Users'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pageTitle } from '$lib/branding';
|
||||||
|
import {
|
||||||
|
getTuning,
|
||||||
|
patchTuning,
|
||||||
|
resetTuning,
|
||||||
|
type TuningScope,
|
||||||
|
type TuningSnapshot,
|
||||||
|
type WeightProfile,
|
||||||
|
type TasteTuning
|
||||||
|
} from '$lib/api/tuning';
|
||||||
|
import { errMessage } from '$lib/api/errors';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
// The defaults-discovery lab (#1250): DB-backed scoring-weight
|
||||||
|
// profiles + taste-build knobs with live effect. This card exists to
|
||||||
|
// FIND good values — found-good values get baked into shipped
|
||||||
|
// defaults, so end users and other operators never need it.
|
||||||
|
|
||||||
|
const weightFields: { key: keyof WeightProfile; label: string; hint: string }[] = [
|
||||||
|
{ key: 'base_weight', label: 'Base weight', hint: 'Floor score every candidate starts from.' },
|
||||||
|
{ key: 'like_boost', label: 'Like boost', hint: 'Added when the track is liked.' },
|
||||||
|
{ key: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played recently (0–30d ramp).' },
|
||||||
|
{ key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts skips/plays ratio.' },
|
||||||
|
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' },
|
||||||
|
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' },
|
||||||
|
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' },
|
||||||
|
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [
|
||||||
|
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." },
|
||||||
|
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads −1.' },
|
||||||
|
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
|
||||||
|
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||||
|
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
|
||||||
|
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let snapshot = $state<TuningSnapshot | null>(null);
|
||||||
|
let loadFailed = $state(false);
|
||||||
|
// Editable copies, string-typed for the inputs; parsed on save.
|
||||||
|
let form = $state<Record<string, Record<string, string>>>({});
|
||||||
|
let saving = $state<TuningScope | null>(null);
|
||||||
|
|
||||||
|
function fillForm(snap: TuningSnapshot) {
|
||||||
|
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
|
||||||
|
for (const p of ['radio', 'daily_mix'] as const) {
|
||||||
|
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
|
||||||
|
}
|
||||||
|
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
|
||||||
|
form = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
getTuning()
|
||||||
|
.then((snap) => {
|
||||||
|
fillForm(snap);
|
||||||
|
snapshot = snap;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loadFailed = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A knob deviates when its CURRENT SAVED value differs from shipped;
|
||||||
|
// the dot marks where this install has drifted from defaults.
|
||||||
|
function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean {
|
||||||
|
if (!snapshot) return false;
|
||||||
|
if (scope === 'taste') {
|
||||||
|
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
snapshot.profiles[scope][key as keyof WeightProfile] !==
|
||||||
|
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(scope: TuningScope, key: string): number {
|
||||||
|
if (!snapshot) return 0;
|
||||||
|
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
|
||||||
|
return snapshot.profiles[scope][key as keyof WeightProfile];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(scope: TuningScope) {
|
||||||
|
if (!snapshot) return;
|
||||||
|
const values: Record<string, number> = {};
|
||||||
|
for (const [key, raw] of Object.entries(form[scope] ?? {})) {
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
pushToast(`${key} is not a number.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v !== currentValue(scope, key)) values[key] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
pushToast('Nothing changed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = scope;
|
||||||
|
try {
|
||||||
|
snapshot = await patchTuning(scope, values);
|
||||||
|
fillForm(snapshot);
|
||||||
|
pushToast('Saved — takes effect on the next scoring pass.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Save failed: ${errMessage(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
saving = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset(scope: TuningScope) {
|
||||||
|
saving = scope;
|
||||||
|
try {
|
||||||
|
snapshot = await resetTuning(scope);
|
||||||
|
fillForm(snapshot);
|
||||||
|
pushToast('Reset to shipped defaults.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Reset failed: ${errMessage(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
saving = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{pageTitle('Tuning')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6 p-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold">Recommendation tuning</h1>
|
||||||
|
<p class="mt-1 max-w-3xl text-sm text-text-secondary">
|
||||||
|
The defaults-discovery lab. Changes apply live — radio on the next request, daily mixes on
|
||||||
|
the next rebuild — and every change is recorded so the metrics page can tie outcome shifts
|
||||||
|
to knob turns. Found-good values get baked into shipped defaults; a dot marks knobs that
|
||||||
|
currently deviate from them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadFailed}
|
||||||
|
<p class="text-sm text-text-secondary">Couldn't load tuning settings.</p>
|
||||||
|
{:else if !snapshot}
|
||||||
|
<p class="text-sm text-text-secondary">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
|
{#each profileScopes as p (p.scope)}
|
||||||
|
<div class="space-y-3 rounded border border-border bg-surface p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">{p.label}</h2>
|
||||||
|
<p class="text-xs text-text-secondary">{p.blurb}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each weightFields as f (f.key)}
|
||||||
|
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
|
||||||
|
<label class="text-sm" for="{p.scope}-{f.key}" title={f.hint}>
|
||||||
|
{f.label}
|
||||||
|
{#if deviates(p.scope, f.key)}
|
||||||
|
<span
|
||||||
|
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
|
||||||
|
title="Deviates from the shipped default ({snapshot.shipped.profiles[p.scope][f.key]})"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{p.scope}-{f.key}"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
bind:value={form[p.scope][f.key]}
|
||||||
|
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving !== null}
|
||||||
|
onclick={() => save(p.scope)}
|
||||||
|
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save {p.label.toLowerCase()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving !== null}
|
||||||
|
onclick={() => reset(p.scope)}
|
||||||
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Taste profile build</h2>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
How plays become the learned taste profile: the influence half-life and the
|
||||||
|
completion→engagement curve (must stay ordered: hard-skip < neutral < full).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each tasteFields as f (f.key)}
|
||||||
|
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
|
||||||
|
<label class="text-sm" for="taste-{f.key}" title={f.hint}>
|
||||||
|
{f.label}
|
||||||
|
{#if deviates('taste', f.key)}
|
||||||
|
<span
|
||||||
|
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
|
||||||
|
title="Deviates from the shipped default ({snapshot.shipped.taste[f.key]})"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="taste-{f.key}"
|
||||||
|
type="number"
|
||||||
|
step={f.key === 'half_life_days' ? '1' : '0.05'}
|
||||||
|
bind:value={form.taste[f.key]}
|
||||||
|
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving !== null}
|
||||||
|
onclick={() => save('taste')}
|
||||||
|
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save taste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving !== null}
|
||||||
|
onclick={() => reset('taste')}
|
||||||
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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