// 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)) } }