From 284271c190d497c6eb4360a183f52e4ffd0f8fc3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 20:55:25 -0400 Subject: [PATCH] feat(api): radio handler reads current session vector + threads ContextWeight Wire contextual scoring end-to-end: add ContextWeight to RecommendationConfig (struct + Default()), fetch the user's current session vector in handleRadio via loadCurrentSessionVector (cold-start returns Seed sentinel), and pass it as the 6th arg to LoadCandidates so ContextualMatchScore flows into Shuffle. --- internal/api/auth_test.go | 2 +- internal/api/radio.go | 37 +++++++++++++++++++++++++++++++++++-- internal/config/config.go | 2 ++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b6f2d5e2..851e52b7 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -52,7 +52,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) recCfg := config.RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, + SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} diff --git a/internal/api/radio.go b/internal/api/radio.go index 6d84994f..dd02b4c1 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -1,13 +1,16 @@ package api import ( + "encoding/json" "errors" + "log/slog" "net/http" "strconv" "strings" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" @@ -22,7 +25,9 @@ type RadioResponse struct { // handleRadio implements GET /api/radio?seed_track=&limit=. // // Returns the seed at index 0, followed by up to limit-1 weighted-shuffle -// picks from the user's library, scored by recommendation.Score. +// picks from the user's library, scored by recommendation.Score. The +// scoring formula folds in contextual_match_score using the user's current +// session vector (read from the most recent open play_event). func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok { @@ -74,7 +79,10 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) + + currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) + + candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec) if err != nil { h.logger.Error("api: radio: load candidates", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") @@ -86,6 +94,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { RecencyWeight: h.recCfg.RecencyWeight, SkipPenalty: h.recCfg.SkipPenalty, JitterMagnitude: h.recCfg.JitterMagnitude, + ContextWeight: h.recCfg.ContextWeight, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) @@ -108,3 +117,27 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) } + +// loadCurrentSessionVector returns the user's most recent active session +// vector, or a Seed=true sentinel if none exists / the column is NULL / +// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore +// to 0 so the contextual term contributes nothing in cold-start cases. +func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector { + raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID) + if err != nil { + // pgx.ErrNoRows is the common path: no active session yet. + if !errors.Is(err, pgx.ErrNoRows) { + logger.Warn("api: radio: load current session vector", "err", err) + } + return recommendation.SessionVector{Seed: true} + } + if len(raw) == 0 { + return recommendation.SessionVector{Seed: true} + } + var v recommendation.SessionVector + if jerr := json.Unmarshal(raw, &v); jerr != nil { + logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr) + return recommendation.SessionVector{Seed: true} + } + return v +} diff --git a/internal/config/config.go b/internal/config/config.go index 2a6a2a39..059aa0a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,6 +72,7 @@ type RecommendationConfig struct { RecencyWeight float64 `yaml:"recency_weight"` SkipPenalty float64 `yaml:"skip_penalty"` JitterMagnitude float64 `yaml:"jitter_magnitude"` + ContextWeight float64 `yaml:"context_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` @@ -93,6 +94,7 @@ func Default() Config { RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, + ContextWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,