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
203 lines
6.4 KiB
Go
203 lines
6.4 KiB
Go
// Admin recommendation-trends endpoint (#1251): weekly per-surface
|
|
// outcome series with tuning-audit markers — the verify half of the
|
|
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
|
|
// all users because the knobs are global; rows carry rates only.
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
const (
|
|
trendsDefaultWeeks = 12
|
|
trendsMaxWeeks = 52
|
|
// trendsAuditFetchCap bounds the audit fetch; markers older than
|
|
// the window are dropped in Go. Far above any real knob-turn count
|
|
// inside a year.
|
|
trendsAuditFetchCap = 500
|
|
)
|
|
|
|
// trendPoint is one week of one surface family's outcomes.
|
|
type trendPoint struct {
|
|
WeekStart string `json:"week_start"` // ISO date (Monday)
|
|
Plays int64 `json:"plays"`
|
|
Skips int64 `json:"skips"`
|
|
SkipRate float64 `json:"skip_rate"`
|
|
AvgCompletion float64 `json:"avg_completion"`
|
|
// TasteHitRate is the share of plays whose track's artist carries a
|
|
// positive weight in the player's current taste profile — a drifted
|
|
// but retroactive read on whether the surface feeds taste-fitting
|
|
// tracks.
|
|
TasteHitRate float64 `json:"taste_hit_rate"`
|
|
// completionN carries the completion sample count through same-week
|
|
// merges so avg_completion stays sample-weighted; not serialized.
|
|
completionN int64
|
|
}
|
|
|
|
// trendSeries is one surface family's weekly series.
|
|
type trendSeries struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
|
|
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
|
|
Points []trendPoint `json:"points"`
|
|
}
|
|
|
|
// trendMarker is one tuning-audit event annotated on the timeline.
|
|
type trendMarker struct {
|
|
ChangedAt string `json:"changed_at"`
|
|
Scope string `json:"scope"`
|
|
Action string `json:"action"`
|
|
Changes json.RawMessage `json:"changes"`
|
|
}
|
|
|
|
type trendsResp struct {
|
|
Weeks int `json:"weeks"`
|
|
Series []trendSeries `json:"series"`
|
|
Markers []trendMarker `json:"markers"`
|
|
}
|
|
|
|
// handleGetRecommendationTrends implements
|
|
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
|
|
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
|
|
weeks := trendsDefaultWeeks
|
|
if v := r.URL.Query().Get("weeks"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
|
|
return
|
|
}
|
|
if n > trendsMaxWeeks {
|
|
n = trendsMaxWeeks
|
|
}
|
|
weeks = n
|
|
}
|
|
|
|
q := dbq.New(h.pool)
|
|
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
|
|
if err != nil {
|
|
h.logger.Error("api: recommendation trends", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
|
|
if err != nil {
|
|
h.logger.Error("api: recommendation trends audit", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
|
|
}
|
|
|
|
// buildTrendsResponse folds weekly rows into per-family series and
|
|
// windows the audit markers. Split from the handler for pure-unit
|
|
// testability.
|
|
func buildTrendsResponse(
|
|
weeks int, now time.Time,
|
|
rows []dbq.RecommendationWeeklyTrendsRow,
|
|
audits []dbq.RecommendationTuningAudit,
|
|
) trendsResp {
|
|
type accum struct {
|
|
fam recFamily
|
|
plays int64
|
|
points []trendPoint
|
|
}
|
|
families := map[string]*accum{}
|
|
for _, row := range rows {
|
|
fam := recFamily{key: "manual", label: "Manual library plays"}
|
|
if row.Source != nil && *row.Source != "" {
|
|
fam = bucketRecSource(*row.Source)
|
|
}
|
|
acc, ok := families[fam.key]
|
|
if !ok {
|
|
acc = &accum{fam: fam}
|
|
families[fam.key] = acc
|
|
}
|
|
acc.plays += row.Plays
|
|
p := trendPoint{
|
|
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
|
|
Plays: row.Plays,
|
|
Skips: row.Skips,
|
|
AvgCompletion: row.AvgCompletion,
|
|
completionN: row.CompletionN,
|
|
}
|
|
if row.Plays > 0 {
|
|
p.SkipRate = float64(row.Skips) / float64(row.Plays)
|
|
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
|
|
}
|
|
// Same family can arrive as several raw sources (radio:<uuid>);
|
|
// merge same-week points play-weighted.
|
|
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
|
|
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
|
|
} else {
|
|
acc.points = append(acc.points, p)
|
|
}
|
|
}
|
|
|
|
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
|
|
for _, acc := range families {
|
|
resp.Series = append(resp.Series, trendSeries{
|
|
Key: acc.fam.key,
|
|
Label: acc.fam.label,
|
|
Intent: acc.fam.intent,
|
|
Plays: acc.plays,
|
|
Points: acc.points,
|
|
})
|
|
}
|
|
sort.Slice(resp.Series, func(i, j int) bool {
|
|
if resp.Series[i].Plays != resp.Series[j].Plays {
|
|
return resp.Series[i].Plays > resp.Series[j].Plays
|
|
}
|
|
return resp.Series[i].Key < resp.Series[j].Key
|
|
})
|
|
|
|
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
|
|
for _, a := range audits {
|
|
if a.ChangedAt.Time.Before(cutoff) {
|
|
continue
|
|
}
|
|
resp.Markers = append(resp.Markers, trendMarker{
|
|
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
|
|
Scope: a.Scope,
|
|
Action: a.Action,
|
|
Changes: json.RawMessage(a.Changes),
|
|
})
|
|
}
|
|
// ListTuningAudit returns newest-first; the timeline reads better
|
|
// oldest-first.
|
|
sort.Slice(resp.Markers, func(i, j int) bool {
|
|
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
|
|
})
|
|
return resp
|
|
}
|
|
|
|
// mergeTrendPoints combines two same-week points of one family:
|
|
// counts add, skip/taste rates re-derive from the merged counts, and
|
|
// avg_completion is weighted by each side's completion sample count.
|
|
func mergeTrendPoints(a, b trendPoint) trendPoint {
|
|
out := trendPoint{
|
|
WeekStart: a.WeekStart,
|
|
Plays: a.Plays + b.Plays,
|
|
Skips: a.Skips + b.Skips,
|
|
completionN: a.completionN + b.completionN,
|
|
}
|
|
if out.Plays > 0 {
|
|
out.SkipRate = float64(out.Skips) / float64(out.Plays)
|
|
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
|
|
float64(out.Plays)
|
|
}
|
|
if out.completionN > 0 {
|
|
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
|
|
float64(out.completionN)
|
|
}
|
|
return out
|
|
}
|