feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and out of the systemMixWeights hard-code (daily_mix profile) into DB-backed settings with live effect (#1250) — the defaults-discovery lab per decision #1247: the operator turns knobs to find good values, which then get baked back into shipped defaults; end users and other operators should never need the card. - Migration 0040: recommendation_weight_profiles (radio / daily_mix, 8 weight columns), taste_tuning singleton (engagement half-life + completion-curve points), recommendation_tuning_audit (one row per change with a {field, old, new} diff — the trend view's markers, #1251). - internal/recsettings: boot reconcile seeds shipped defaults without clobbering tuned rows (coverart SettingsService pattern), validates patches (bounds, curve ordering), writes audit rows, and pushes daily_mix weights + taste config into package playlists. No-op patches write no audit row. - playlists gains SetSystemMixWeights / SetTasteConfig swap points under a RWMutex — no signature threading through the producers; the scheduler's taste rebuild reads the pushed config. - Radio reads its weight profile from the service per request; the 8 weight fields leave config.RecommendationConfig (YAML keeps only RecentlyPlayedHours / RadioSize / RadioSizeMax). - Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning, echoing current + shipped values. - Web: new admin Tuning tab — two weight profiles side by side, taste card, per-scope save (changed fields only) + reset, deviation dots against shipped defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -0,0 +1,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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user