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>
368 lines
13 KiB
Go
368 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
const (
|
|
recMetricsDefaultDays = 30
|
|
recMetricsMaxDays = 365
|
|
|
|
// recMetricsLowVolume marks a family as low-confidence rather than
|
|
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
|
// signal, but silently dropping the row would misread as "surface
|
|
// unused". The web renders low-confidence rows dimmed.
|
|
recMetricsLowVolume = 20
|
|
)
|
|
|
|
// Surface intents: each family is judged against its
|
|
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
|
// skip rates than the go-to surfaces.
|
|
const (
|
|
intentGoTo = "go_to"
|
|
intentDiscovery = "discovery"
|
|
intentDirect = "direct"
|
|
)
|
|
|
|
// surfaceMetric is one bucketed surface family's outcomes.
|
|
type surfaceMetric struct {
|
|
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
|
Label string `json:"label"` // display label
|
|
Plays int64 `json:"plays"` // plays launched from this family
|
|
Skips int64 `json:"skips"` // of those, marked skipped
|
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
|
// SkipDelta / CompletionDelta are this row's difference from the manual
|
|
// baseline WITH its margin of error. nil on the baseline row
|
|
// itself, and whenever the samples are too thin for a margin to mean
|
|
// anything. Computed server-side so both clients read the same arithmetic
|
|
// instead of each re-deriving it. `low_confidence` is a display
|
|
// threshold, not a decision one.
|
|
SkipDelta *metricDelta `json:"skip_delta,omitempty"`
|
|
CompletionDelta *metricDelta `json:"completion_delta,omitempty"`
|
|
// Breakdown splits the family into the pick-kind populations its
|
|
// builder stamped: For You's taste/fresh,
|
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
|
// that predate attribution. Present only when the family has at
|
|
// least one attributed play; the parent row remains the sum of its
|
|
// breakdown.
|
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
|
}
|
|
|
|
// surfaceGroup is one intent band of surface families.
|
|
type surfaceGroup struct {
|
|
Intent string `json:"intent"` // go_to | discovery | direct
|
|
Label string `json:"label"`
|
|
Surfaces []surfaceMetric `json:"surfaces"`
|
|
}
|
|
|
|
type recommendationMetricsResp struct {
|
|
WindowDays int `json:"window_days"`
|
|
// Baseline is the control group: plays the user picked manually
|
|
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
|
// when the window holds no manual plays.
|
|
Baseline *surfaceMetric `json:"baseline"`
|
|
Groups []surfaceGroup `json:"groups"`
|
|
}
|
|
|
|
// recFamily is the bucketing target for a raw play_events.source value.
|
|
type recFamily struct {
|
|
key string
|
|
label string
|
|
intent string
|
|
}
|
|
|
|
// bucketRecSource maps a raw client-stamped source string to its stable
|
|
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
|
// their family so the table stays readable at any library size.
|
|
func bucketRecSource(src string) recFamily {
|
|
switch {
|
|
case src == "for_you":
|
|
return recFamily{"for_you", "For You", intentGoTo}
|
|
case src == "songs_like_artist":
|
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
|
return recFamily{"radio", "Radio", intentGoTo}
|
|
case src == "discover":
|
|
return recFamily{"discover", "Discover", intentDiscovery}
|
|
case src == "deep_cuts":
|
|
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
|
case src == "rediscover":
|
|
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
|
case src == "new_for_you":
|
|
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
|
case src == "on_this_day":
|
|
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
|
case src == "first_listens":
|
|
return recFamily{"first_listens", "First listens", intentDiscovery}
|
|
case strings.HasPrefix(src, "album:"):
|
|
return recFamily{"direct_album", "Album plays", intentDirect}
|
|
case strings.HasPrefix(src, "artist:"):
|
|
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
|
case strings.HasPrefix(src, "offline:"):
|
|
return recFamily{"offline", "Offline pools", intentDirect}
|
|
case strings.HasPrefix(src, "home:"):
|
|
return recFamily{"home", "Home sections", intentDirect}
|
|
case src == "history":
|
|
return recFamily{"history", "History", intentDirect}
|
|
default:
|
|
return recFamily{"other", "Other", intentDirect}
|
|
}
|
|
}
|
|
|
|
// familyAccum merges raw source rows into one family, carrying the
|
|
// completion sample count so the merged average stays play-weighted.
|
|
type familyAccum struct {
|
|
fam recFamily
|
|
plays int64
|
|
skips int64
|
|
completionN int64
|
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
|
// reduces to a single weighted division at the end.
|
|
completionSum float64
|
|
// completionSqSum is the sum of squared completion ratios, which is what
|
|
// makes the variance mergeable across raw source rows. Standard
|
|
// deviations cannot be combined; sums of squares add exactly.
|
|
completionSqSum float64
|
|
}
|
|
|
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
|
a.plays += row.Plays
|
|
a.skips += row.Skips
|
|
a.completionN += row.CompletionN
|
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
|
a.completionSqSum += row.CompletionSqsum
|
|
}
|
|
|
|
func (a *familyAccum) metric() surfaceMetric {
|
|
m := surfaceMetric{
|
|
Key: a.fam.key,
|
|
Label: a.fam.label,
|
|
Plays: a.plays,
|
|
Skips: a.skips,
|
|
LowConfidence: a.plays < recMetricsLowVolume,
|
|
}
|
|
if a.plays > 0 {
|
|
m.SkipRate = float64(a.skips) / float64(a.plays)
|
|
}
|
|
if a.completionN > 0 {
|
|
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// completionVariance is the sample variance of this family's completion ratios.
|
|
func (a *familyAccum) completionVariance() float64 {
|
|
return sampleVariance(a.completionSum, a.completionSqSum, a.completionN)
|
|
}
|
|
|
|
// applyDeltas attaches baseline-relative deltas + margins to a metric.
|
|
// Split out so every row — parent surfaces and breakdown rows alike — goes
|
|
// through the identical arithmetic; a breakdown arm is exactly where the old
|
|
// card was most misleading, because those are the thinnest samples on screen.
|
|
func applyDeltas(m *surfaceMetric, acc *familyAccum, baseline *familyAccum) {
|
|
if baseline == nil || baseline.plays == 0 {
|
|
return
|
|
}
|
|
m.SkipDelta = proportionDelta(
|
|
m.SkipRate, acc.plays,
|
|
float64(baseline.skips)/float64(baseline.plays), baseline.plays,
|
|
)
|
|
baseMean := 0.0
|
|
if baseline.completionN > 0 {
|
|
baseMean = baseline.completionSum / float64(baseline.completionN)
|
|
}
|
|
m.CompletionDelta = meanDelta(
|
|
m.AvgCompletion, acc.completionVariance(), acc.completionN,
|
|
baseMean, baseline.completionVariance(), baseline.completionN,
|
|
)
|
|
}
|
|
|
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
|
// manual-plays baseline so the numbers are judgeable, not just observable.
|
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
|
caller, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
days, ok := parseMetricsDays(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
|
|
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
|
|
if err != nil {
|
|
h.logger.Error("api: recommendation metrics", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
|
}
|
|
|
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
|
// values (mirrors the CHECK in migration 0041). Every system mix that
|
|
// stamps provenance gets its breakdown from this one map — adding a
|
|
// stamping mix needs no metrics change.
|
|
var pickKindLabels = map[string]string{
|
|
"taste": "Taste picks",
|
|
"fresh": "Fresh picks",
|
|
"dormant": "Dormant artists",
|
|
"taste_unheard": "Taste-matched",
|
|
"cross_user": "Liked by others",
|
|
"random": "Random unheard",
|
|
"tier1": "Tier 1 (exact)",
|
|
"tier2": "Tier 2 (relaxed)",
|
|
"tier3": "Tier 3 (stretched)",
|
|
}
|
|
|
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
|
// whose track had rotated out of the snapshot at ingestion) renders
|
|
// last, kept visible so the parent row's sums stay transparent instead
|
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
|
// vocabulary, so iterating the list is exhaustive.
|
|
var pickKindOrder = []string{
|
|
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
|
|
"tier1", "tier2", "tier3", "",
|
|
}
|
|
|
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
|
if kind == "" {
|
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
|
}
|
|
label, ok := pickKindLabels[kind]
|
|
if !ok {
|
|
label = kind
|
|
}
|
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
|
}
|
|
|
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
|
// Breakdown rows. Attached only when at least one attributed play
|
|
// exists — an all-unattributed breakdown would just repeat the parent
|
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
|
func pickKindBreakdown(picks map[string]*familyAccum, baseline *familyAccum) []surfaceMetric {
|
|
attributed := int64(0)
|
|
for kind, acc := range picks {
|
|
if kind != "" {
|
|
attributed += acc.plays
|
|
}
|
|
}
|
|
if attributed == 0 {
|
|
return nil
|
|
}
|
|
out := make([]surfaceMetric, 0, len(picks))
|
|
for _, kind := range pickKindOrder {
|
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
|
m := acc.metric()
|
|
applyDeltas(&m, acc, baseline)
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
|
// baseline-anchored response shape. Split from the handler for pure-unit
|
|
// testability.
|
|
func bucketMetricsResponse(
|
|
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
|
) recommendationMetricsResp {
|
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
|
families := map[string]*familyAccum{}
|
|
picks := map[string]map[string]*familyAccum{}
|
|
for _, row := range rows {
|
|
if row.Source == nil || *row.Source == "" {
|
|
baseline.add(row)
|
|
continue
|
|
}
|
|
fam := bucketRecSource(*row.Source)
|
|
acc, exists := families[fam.key]
|
|
if !exists {
|
|
acc = &familyAccum{fam: fam}
|
|
families[fam.key] = acc
|
|
}
|
|
acc.add(row)
|
|
// Accumulate the pick-kind population unconditionally; families
|
|
// that never stamp end up all-unattributed and get no breakdown.
|
|
kind := ""
|
|
if row.PickKind != nil {
|
|
kind = *row.PickKind
|
|
}
|
|
byKind, ok := picks[fam.key]
|
|
if !ok {
|
|
byKind = map[string]*familyAccum{}
|
|
picks[fam.key] = byKind
|
|
}
|
|
pick, ok := byKind[kind]
|
|
if !ok {
|
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
|
byKind[kind] = pick
|
|
}
|
|
pick.add(row)
|
|
}
|
|
|
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
|
if baseline.plays > 0 {
|
|
m := baseline.metric()
|
|
resp.Baseline = &m
|
|
}
|
|
for _, g := range []struct{ intent, label string }{
|
|
{intentGoTo, "Go-to surfaces"},
|
|
{intentDiscovery, "Discovery mixes"},
|
|
{intentDirect, "Direct plays"},
|
|
} {
|
|
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
|
for _, acc := range families {
|
|
if acc.fam.intent == g.intent {
|
|
m := acc.metric()
|
|
applyDeltas(&m, acc, baseline)
|
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key], baseline)
|
|
group.Surfaces = append(group.Surfaces, m)
|
|
}
|
|
}
|
|
if len(group.Surfaces) == 0 {
|
|
continue
|
|
}
|
|
sort.Slice(group.Surfaces, func(i, j int) bool {
|
|
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
|
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
|
}
|
|
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
|
})
|
|
resp.Groups = append(resp.Groups, group)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
|
// Writes a 400 and returns ok=false on a malformed value.
|
|
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
|
|
v := r.URL.Query().Get("days")
|
|
if v == "" {
|
|
return recMetricsDefaultDays, true
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
|
|
return 0, false
|
|
}
|
|
if n > recMetricsMaxDays {
|
|
n = recMetricsMaxDays
|
|
}
|
|
return n, true
|
|
}
|