9ad4343c76
The verify half of the tune→verify loop (#1251), on the same admin Tuning page as the knobs: - RecommendationWeeklyTrends: weekly per-source outcomes aggregated across all users (the knobs are global, so judging a turn needs global outcomes — rows carry rates only, no track/user identity), with a taste-hit count per bucket: plays whose track's artist has a positive weight in the player's current taste profile. That's the "cheap recompute" reading — retroactive over the whole window, at the cost of profile drift. - GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52): per-family weekly series (skip rate, sample-weighted completion, taste-hit rate) plus the tuning-audit markers inside the window. - Web: sparkline table under the tuning cards — skip rate per week on a shared axis with dashed ticks at knob turns, latest-week columns, window taste-hit rate, low-volume rows dimmed as anecdote, and a plain-text list of the window's tuning changes. Also fixes the revive unused-parameter lint on the tuning GET handler that failed CI run 1903 on the previous commit. 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, _ *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))
|
|
}
|
|
}
|