fb4431207d
Milestone #127 step 2 (#1249). For You deliberately blends two populations — a head of top-scored taste picks and a tail sampled from deeper ranking (the freshness injection) — but the metrics judged it as one blob, so its skip rate couldn't distinguish "the taste engine is missing" from "the freshness tax is too high". That number decides the exploration share before we tune it. - Migration 0038: nullable pick_kind ('taste'|'fresh') on both playlist_tracks (stamped at snapshot build) and play_events (frozen at play-ingestion — the snapshot rebuilds daily, so attribution cannot be reconstructed at read time). - Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool fallback is all taste (top-N-by-score IS the taste mechanism). Other variants persist NULL. - Ingestion: for_you plays (live + offline replay) look the track up in the user's current snapshot; not found → unattributed, never guessed. - Metrics: For You's row gains a breakdown (taste / fresh / earlier unattributed plays), parent row stays the sum; web card renders the sub-rows indented with the same baseline deltas + low-data dimming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
291 lines
9.5 KiB
Go
291 lines
9.5 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 sub-populations. Only For You
|
|
// carries one (#1249): taste picks vs fresh picks (the deliberate
|
|
// freshness injection), plus earlier plays that predate attribution.
|
|
// 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))
|
|
}
|
|
|
|
// forYouPickFamilies defines the For You breakdown rows (#1249), keyed
|
|
// by play_events.pick_kind. "" (NULL pick_kind) = plays recorded before
|
|
// attribution shipped, or whose track had already rotated out of the
|
|
// snapshot at ingestion — kept visible so the parent row's sums stay
|
|
// transparent instead of silently shrinking.
|
|
var forYouPickFamilies = map[string]recFamily{
|
|
"taste": {"for_you_taste", "Taste picks", intentGoTo},
|
|
"fresh": {"for_you_fresh", "Fresh picks", intentGoTo},
|
|
"": {"for_you_unattributed", "Earlier plays", intentGoTo},
|
|
}
|
|
|
|
// forYouBreakdown folds pick-kind accums into the parent metric's
|
|
// Breakdown. Attached only when at least one attributed (taste/fresh)
|
|
// play exists — an all-unattributed breakdown would just repeat the
|
|
// parent row.
|
|
func forYouBreakdown(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 []string{"taste", "fresh", ""} {
|
|
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{}
|
|
forYouPicks := 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)
|
|
if fam.key == "for_you" {
|
|
kind := ""
|
|
if row.PickKind != nil {
|
|
kind = *row.PickKind
|
|
}
|
|
pick, ok := forYouPicks[kind]
|
|
if !ok {
|
|
pick = &familyAccum{fam: forYouPickFamilies[kind]}
|
|
forYouPicks[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()
|
|
if acc.fam.key == "for_you" {
|
|
m.Breakdown = forYouBreakdown(forYouPicks)
|
|
}
|
|
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
|
|
}
|