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 } // 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)) } // 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 { 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) } 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). // 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 }