aff346c731
The profile built in phase 1 now changes what gets surfaced. Adds a TasteMatch term to the weighted-shuffle score so candidates are re-ranked by their fit to the user's learned taste (positive draws toward it; negative reflects passive avoidance; 0 at cold start). - recommendation/score.go: ScoringInputs.TasteMatchScore ([-1,+1]) + ScoringWeights.TasteWeight + the term in Score. - recommendation/taste.go: LoadTasteProfile reads the taste_profile_* tables; TasteProfile.Match blends the candidate's artist weight (0.7) and avg genre-tag weight (0.3), each tanh-squashed by a fixed scale so one outlier artist can't compress the rest. Unknown artist/tags and empty profiles → 0 (neutral). - candidates.go: both candidate loaders set TasteMatchScore per candidate, so every Score caller (system playlists incl. You-might-like, radio) becomes taste-aware automatically. - weights: systemMixWeights.TasteWeight = 1.5 (daily mixes are the primary taste surface); config.RecommendationConfig gains taste_weight (default 1.0, lighter — radio is seed-directed) wired into the radio handler. - tests: pure (Match curve incl. saturation/clamp/empty-neutral, Score term add+subtract) + DB round-trip (seed taste rows → Match positive). All green vs real Postgres; existing playlist/radio tests unaffected (empty profile → zero taste effect). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
5.7 KiB
Go
181 lines
5.7 KiB
Go
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/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
)
|
|
|
|
// RadioResponse is the body of GET /api/radio.
|
|
type RadioResponse struct {
|
|
Tracks []TrackRef `json:"tracks"`
|
|
}
|
|
|
|
// 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. 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 := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(r.URL.Query().Get("seed_track"))
|
|
if raw == "" {
|
|
writeErr(w, apierror.BadRequest("bad_request", "seed_track is required"))
|
|
return
|
|
}
|
|
seedID, ok := parseUUID(raw)
|
|
if !ok {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid seed_track id"))
|
|
return
|
|
}
|
|
limit := h.recCfg.RadioSize
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid limit"))
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
if limit > h.recCfg.RadioSizeMax {
|
|
limit = h.recCfg.RadioSizeMax
|
|
}
|
|
q := dbq.New(h.pool)
|
|
track, err := q.GetTrackByID(r.Context(), seedID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, apierror.NotFound("seed_track"))
|
|
return
|
|
}
|
|
h.logger.Error("api: get radio seed track failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
album, err := q.GetAlbumByID(r.Context(), track.AlbumID)
|
|
if err != nil {
|
|
h.logger.Error("api: get radio seed album failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
artist, err := q.GetArtistByID(r.Context(), track.ArtistID)
|
|
if err != nil {
|
|
h.logger.Error("api: get radio seed artist failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
|
return
|
|
}
|
|
|
|
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
|
|
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
|
limits := recommendation.DefaultCandidateSourceLimits()
|
|
candidates, err := recommendation.LoadCandidatesFromSimilarity(
|
|
r.Context(), q, user.ID, seedID,
|
|
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
|
|
)
|
|
if err != nil {
|
|
h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err)
|
|
candidates, err = recommendation.LoadCandidates(
|
|
r.Context(), q, user.ID, seedID,
|
|
h.recCfg.RecentlyPlayedHours, currentVec,
|
|
)
|
|
if err != nil {
|
|
h.logger.Error("api: radio: load candidates fallback failed", "err", err)
|
|
writeErr(w, apierror.InternalMsg("candidate load failed", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
weights := recommendation.ScoringWeights{
|
|
BaseWeight: h.recCfg.BaseWeight,
|
|
LikeBoost: h.recCfg.LikeBoost,
|
|
RecencyWeight: h.recCfg.RecencyWeight,
|
|
SkipPenalty: h.recCfg.SkipPenalty,
|
|
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
ContextWeight: h.recCfg.ContextWeight,
|
|
SimilarityWeight: h.recCfg.SimilarityWeight,
|
|
TasteWeight: h.recCfg.TasteWeight,
|
|
}
|
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
|
|
|
out := make([]TrackRef, 0, len(picks)+1)
|
|
out = append(out, trackRefFrom(track, album.Title, artist.Name))
|
|
for _, p := range picks {
|
|
al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID)
|
|
if err != nil {
|
|
h.logger.Error("api: radio: resolve album", "err", err)
|
|
writeErr(w, apierror.InternalMsg("resolve failed", err))
|
|
return
|
|
}
|
|
ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID)
|
|
if err != nil {
|
|
h.logger.Error("api: radio: resolve artist", "err", err)
|
|
writeErr(w, apierror.InternalMsg("resolve failed", err))
|
|
return
|
|
}
|
|
out = append(out, trackRefFrom(p.Track, al.Title, ar.Name))
|
|
}
|
|
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
|
|
}
|
|
|
|
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
|
// `exclude` query string, silently dropping malformed entries. Returns
|
|
// nil for empty or all-malformed input.
|
|
func parseExcludeParam(raw string) []pgtype.UUID {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]pgtype.UUID, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
id, ok := parseUUID(p)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|