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>
203 lines
6.4 KiB
Go
203 lines
6.4 KiB
Go
// Admin recommendation-trends endpoint: weekly per-surface outcome
|
|
// series with tuning-audit markers — the verify half of the tune→verify
|
|
// loop the tuning card 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
|
|
}
|