5faa57634b
The #1249 mechanism (stamp WHY a track is in the snapshot at build time, freeze it onto the play at ingestion, break it down in metrics) generalizes from a For You one-off to the standard for every system mix (#1270): - Migration 0039 widens both pick_kind CHECKs (drop + re-add in the same change) to taste/fresh + Discover's dormant/cross_user/random + tier1-3 for the rule-#131 eligibility ladders. - GetForYouPickKindForTrack becomes GetSystemPickKindForTrack (user, variant, track); ingestion stamps any systemPlaylistSources play from its own variant's live snapshot, live + offline paths. - Discover stamps its candidate bucket on discoverTrack before the interleave, making the 40/30/30 allocation measurable; dedup keeps the taking bucket's stamp. - Metrics replace the for_you special-case with one pick-kind vocabulary — any family with attributed plays gets a breakdown, future stamping mixes need no metrics change. - Web: breakdown sub-rows are now toggled per surface (collapsed by default) so eight stamping mixes don't swamp the card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
324 lines
11 KiB
Go
324 lines
11 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 (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
|
|
// Breakdown splits the family into the pick-kind populations its
|
|
// builder stamped (#1249, generalized #1270): 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
|
|
}
|
|
|
|
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.
|
|
// 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 0039). 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",
|
|
"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", "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) []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 {
|
|
out = append(out, acc.metric())
|
|
}
|
|
}
|
|
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()
|
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
|
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
|
|
}
|