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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user