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.
This commit is contained in:
2026-04-27 20:55:25 -04:00
parent 541698a8b1
commit 284271c190
3 changed files with 38 additions and 3 deletions
+1 -1
View File
@@ -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 }}
+35 -2
View File
@@ -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=<uuid>&limit=<int>.
//
// 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
}
+2
View File
@@ -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,