Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.6 KiB
Go
184 lines
6.6 KiB
Go
// Admin recommendation-tuning endpoints: the lab for finding good
|
|
// defaults. 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).
|
|
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"`
|
|
ContextTimeWeight float64 `json:"context_time_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,
|
|
ContextTimeWeight: w.ContextTimeWeight,
|
|
}
|
|
}
|
|
|
|
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"`
|
|
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
|
EraScale float64 `json:"era_scale"`
|
|
MoodScale float64 `json:"mood_scale"`
|
|
}
|
|
|
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|
return tasteTuningResp{
|
|
HalfLifeDays: t.HalfLifeDays,
|
|
EngagementHardSkip: t.EngagementHardSkip,
|
|
EngagementNeutral: t.EngagementNeutral,
|
|
EngagementFull: t.EngagementFull,
|
|
EnrichedTagScale: t.EnrichedTagScale,
|
|
EraScale: t.EraScale,
|
|
MoodScale: t.MoodScale,
|
|
}
|
|
}
|
|
|
|
// discoverTuningResp is the Discover scope on the wire.
|
|
type discoverTuningResp struct {
|
|
TagOverlapWeight float64 `json:"tag_overlap_weight"`
|
|
SnoozeDays float64 `json:"snooze_days"`
|
|
}
|
|
|
|
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
|
|
return discoverTuningResp{
|
|
TagOverlapWeight: d.TagOverlapWeight,
|
|
SnoozeDays: d.SnoozeDays,
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
Discover discoverTuningResp `json:"discover"`
|
|
Shipped struct {
|
|
Profiles map[string]weightsResp `json:"profiles"`
|
|
Taste tasteTuningResp `json:"taste"`
|
|
Discover discoverTuningResp `json:"discover"`
|
|
} `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)),
|
|
recsettings.ScopeSongsLike: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeSongsLike)),
|
|
}
|
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
|
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
|
out.Shipped.Profiles = map[string]weightsResp{
|
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
|
recsettings.ScopeSongsLike: weightsRespFrom(recsettings.ShippedSongsLikeWeights()),
|
|
}
|
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
|
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
|
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
|
|
switch scope {
|
|
case recsettings.ScopeTaste:
|
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
|
case recsettings.ScopeDiscover:
|
|
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
|
|
default:
|
|
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))
|
|
}
|
|
}
|