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