65dd132b3d
Milestone #160 Opt 3 (temporal half). A new additive scoring term that boosts a candidate when its artist's play history concentrates in the CURRENT daypart × weekday-type cell, in the user's local timezone. - Migration 0046: recommendation_weight_profiles.context_time_weight (per-profile scoring weight, DEFAULT 1.0). - Query ListArtistContextPlayCountsForUser: per-artist completed-play counts split by the current cell (daypart night[22,5)/morning[5,12)/ afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via started_at AT TIME ZONE users.timezone; 365-day window, skips excluded. - internal/recommendation/context.go: LoadContextAffinity computes each artist's shrunk cell-share minus the user's baseline share, clamped to [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown artists → 0 (cold-start neutral). - Score() gains context_affinity_score · ContextTimeWeight; both candidate loaders set it per candidate. - Tuning lab: ContextTimeWeight threaded through recsettings + admin API + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both profiles (uniform start, re-bakeable). Device-class axis deferred to #1551 (needs a client_id → device-class mapping that doesn't exist yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.6 KiB
Go
159 lines
5.6 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"`
|
|
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"`
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|