feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
The recommendation metrics table was observable but not actionable: raw source strings (album:<uuid> one-offs) drowned the stable surfaces, and manual plays were excluded so skip rates had no control group. - SQL: include NULL-source rows (the baseline) and carry completion_n so family merges can weight avg_completion correctly. - Handler buckets raw sources into stable families (radio:<uuid> → Radio, album:/artist: → direct plays, etc.) grouped by surface intent: go-to / discovery / direct — each band judged against its job, since discovery mixes are expected to skip hotter. Families under 20 plays are flagged low-confidence, not hidden. - Settings card renders the baseline row and per-surface deltas in percentage points vs baseline (worse-than-baseline deltas in danger color), intent hint copy per group, low-data rows dimmed. - Pure-unit test for the bucketing/merge; DB test updated to the new contract (baseline included, radio:<uuid> collapse). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
@@ -11,28 +13,135 @@ import (
|
||||
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
|
||||
)
|
||||
|
||||
// recommendationMetric is one recommendation surface's outcomes.
|
||||
type recommendationMetric struct {
|
||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
||||
Plays int64 `json:"plays"` // plays launched from this surface
|
||||
// Surface intents (milestone #127): 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Sources []recommendationMetric `json:"sources"`
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
||||
// see which recommendation surfaces are landing and tune the taste weights.
|
||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
||||
// (no source) are excluded.
|
||||
// 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 {
|
||||
@@ -51,28 +160,59 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
out := recommendationMetricsResp{
|
||||
WindowDays: days,
|
||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||
}
|
||||
|
||||
// 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{}
|
||||
for _, row := range rows {
|
||||
source := ""
|
||||
if row.Source != nil {
|
||||
source = *row.Source
|
||||
if row.Source == nil || *row.Source == "" {
|
||||
baseline.add(row)
|
||||
continue
|
||||
}
|
||||
var skipRate float64
|
||||
if row.Plays > 0 {
|
||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
||||
fam := bucketRecSource(*row.Source)
|
||||
acc, exists := families[fam.key]
|
||||
if !exists {
|
||||
acc = &familyAccum{fam: fam}
|
||||
families[fam.key] = acc
|
||||
}
|
||||
out.Sources = append(out.Sources, recommendationMetric{
|
||||
Source: source,
|
||||
Plays: row.Plays,
|
||||
Skips: row.Skips,
|
||||
SkipRate: skipRate,
|
||||
AvgCompletion: row.AvgCompletion,
|
||||
})
|
||||
acc.add(row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
|
||||
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 {
|
||||
group.Surfaces = append(group.Surfaces, acc.metric())
|
||||
}
|
||||
}
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user