1a7515e6ea
Per-source play outcomes so the operator can see whether each recommendation
surface is landing and tune the now-operator-tunable taste weights.
Server:
- query RecommendationSourceMetricsForUser: groups the user's play_events by
source (system-playlist surface), reporting plays / skips / avg completion
over a window; NULL-source (library/radio) plays excluded.
- GET /api/me/recommendation-metrics?days=30 (default 30, capped 365) →
{window_days, sources:[{source, plays, skips, skip_rate, avg_completion}]}.
- handler test: 401 unauth; per-source aggregation + NULL-source exclusion +
skip_rate / avg_completion math.
Web:
- lib/api/metrics.ts: query + friendly source labels.
- settings page gains a "Recommendation metrics" card (table of surface / plays
/ skip rate / avg completion), with loading/error/empty states.
- settings tests mock the new query (manual subscribe-store, hoisting-safe).
Note: You-might-like plays aren't source-tagged (it's a Home row, not a system
playlist), so this covers For-You / Discover / the mixes. Tagging YML plays
would be a client follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
3.6 KiB
Go
109 lines
3.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func newMetricsRouter(h *handlers) chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
|
return r
|
|
}
|
|
|
|
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
|
// skip flag. A nil source inserts NULL (library/radio play).
|
|
func seedSourcedPlay(
|
|
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
|
source *string, completion float64, skipped bool,
|
|
) {
|
|
t.Helper()
|
|
if _, err := h.pool.Exec(context.Background(),
|
|
`INSERT INTO play_events
|
|
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
|
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
|
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
|
t.Fatalf("seed sourced play: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
|
rec := httptest.NewRecorder()
|
|
newMetricsRouter(h).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
}
|
|
h, pool := testHandlers(t)
|
|
user := seedUser(t, pool, "metrics", "pw", false)
|
|
artist := seedArtist(t, pool, "MetricArtist")
|
|
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
|
|
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
|
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
|
|
|
forYou := "for_you"
|
|
discover := "discover"
|
|
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
|
// discover: 1 play.
|
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
|
// library play (NULL source) — must be excluded.
|
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
|
req = withUser(req, user)
|
|
rec := httptest.NewRecorder()
|
|
newMetricsRouter(h).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
|
|
var resp recommendationMetricsResp
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.WindowDays != recMetricsDefaultDays {
|
|
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
|
}
|
|
bySource := map[string]recommendationMetric{}
|
|
for _, m := range resp.Sources {
|
|
bySource[m.Source] = m
|
|
}
|
|
if _, present := bySource[""]; present {
|
|
t.Error("NULL-source (library) plays should be excluded")
|
|
}
|
|
fy, ok := bySource["for_you"]
|
|
if !ok {
|
|
t.Fatal("for_you metrics missing")
|
|
}
|
|
if fy.Plays != 3 || fy.Skips != 1 {
|
|
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
|
|
}
|
|
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
|
|
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
|
|
}
|
|
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
|
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
|
}
|
|
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
|
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
|
}
|
|
}
|