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:, radio:) 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 }