// Admin recommendation-trends endpoint (#1251): weekly per-surface // outcome series with tuning-audit markers — the verify half of the // tune→verify loop the tuning lab (#1250) opens. Aggregated across // all users because the knobs are global; rows carry rates only. package api import ( "encoding/json" "net/http" "sort" "strconv" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) const ( trendsDefaultWeeks = 12 trendsMaxWeeks = 52 // trendsAuditFetchCap bounds the audit fetch; markers older than // the window are dropped in Go. Far above any real knob-turn count // inside a year. trendsAuditFetchCap = 500 ) // trendPoint is one week of one surface family's outcomes. type trendPoint struct { WeekStart string `json:"week_start"` // ISO date (Monday) Plays int64 `json:"plays"` Skips int64 `json:"skips"` SkipRate float64 `json:"skip_rate"` AvgCompletion float64 `json:"avg_completion"` // TasteHitRate is the share of plays whose track's artist carries a // positive weight in the player's current taste profile — a drifted // but retroactive read on whether the surface feeds taste-fitting // tracks. TasteHitRate float64 `json:"taste_hit_rate"` // completionN carries the completion sample count through same-week // merges so avg_completion stays sample-weighted; not serialized. completionN int64 } // trendSeries is one surface family's weekly series. type trendSeries struct { Key string `json:"key"` Label string `json:"label"` Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline Plays int64 `json:"plays"` // window total, for sorting/volume-gating Points []trendPoint `json:"points"` } // trendMarker is one tuning-audit event annotated on the timeline. type trendMarker struct { ChangedAt string `json:"changed_at"` Scope string `json:"scope"` Action string `json:"action"` Changes json.RawMessage `json:"changes"` } type trendsResp struct { Weeks int `json:"weeks"` Series []trendSeries `json:"series"` Markers []trendMarker `json:"markers"` } // handleGetRecommendationTrends implements // GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52). func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) { weeks := trendsDefaultWeeks if v := r.URL.Query().Get("weeks"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 { writeErr(w, apierror.BadRequest("bad_request", "invalid weeks")) return } if n > trendsMaxWeeks { n = trendsMaxWeeks } weeks = n } q := dbq.New(h.pool) rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks)) if err != nil { h.logger.Error("api: recommendation trends", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap) if err != nil { h.logger.Error("api: recommendation trends audit", "err", err) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits)) } // buildTrendsResponse folds weekly rows into per-family series and // windows the audit markers. Split from the handler for pure-unit // testability. func buildTrendsResponse( weeks int, now time.Time, rows []dbq.RecommendationWeeklyTrendsRow, audits []dbq.RecommendationTuningAudit, ) trendsResp { type accum struct { fam recFamily plays int64 points []trendPoint } families := map[string]*accum{} for _, row := range rows { fam := recFamily{key: "manual", label: "Manual library plays"} if row.Source != nil && *row.Source != "" { fam = bucketRecSource(*row.Source) } acc, ok := families[fam.key] if !ok { acc = &accum{fam: fam} families[fam.key] = acc } acc.plays += row.Plays p := trendPoint{ WeekStart: row.WeekStart.Time.Format("2006-01-02"), Plays: row.Plays, Skips: row.Skips, AvgCompletion: row.AvgCompletion, completionN: row.CompletionN, } if row.Plays > 0 { p.SkipRate = float64(row.Skips) / float64(row.Plays) p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays) } // Same family can arrive as several raw sources (radio:); // merge same-week points play-weighted. if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart { acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p) } else { acc.points = append(acc.points, p) } } resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}} for _, acc := range families { resp.Series = append(resp.Series, trendSeries{ Key: acc.fam.key, Label: acc.fam.label, Intent: acc.fam.intent, Plays: acc.plays, Points: acc.points, }) } sort.Slice(resp.Series, func(i, j int) bool { if resp.Series[i].Plays != resp.Series[j].Plays { return resp.Series[i].Plays > resp.Series[j].Plays } return resp.Series[i].Key < resp.Series[j].Key }) cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour) for _, a := range audits { if a.ChangedAt.Time.Before(cutoff) { continue } resp.Markers = append(resp.Markers, trendMarker{ ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339), Scope: a.Scope, Action: a.Action, Changes: json.RawMessage(a.Changes), }) } // ListTuningAudit returns newest-first; the timeline reads better // oldest-first. sort.Slice(resp.Markers, func(i, j int) bool { return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt }) return resp } // mergeTrendPoints combines two same-week points of one family: // counts add, skip/taste rates re-derive from the merged counts, and // avg_completion is weighted by each side's completion sample count. func mergeTrendPoints(a, b trendPoint) trendPoint { out := trendPoint{ WeekStart: a.WeekStart, Plays: a.Plays + b.Plays, Skips: a.Skips + b.Skips, completionN: a.completionN + b.completionN, } if out.Plays > 0 { out.SkipRate = float64(out.Skips) / float64(out.Plays) out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) / float64(out.Plays) } if out.completionN > 0 { out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) / float64(out.completionN) } return out }