feat(api): radio uses similarity pool with exclude param + M3 fallback

Wire LoadCandidatesFromSimilarity as the primary candidate loader with
an ?exclude= query param for comma-separated UUID filtering; fall back
to LoadCandidates on error. Thread SimilarityWeight into ScoringWeights
and update testHandlers recCfg accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 08:47:31 -04:00
parent b8e3019654
commit 8647f9ebe0
3 changed files with 153 additions and 11 deletions
+2 -1
View File
@@ -52,7 +52,8 @@ 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, ContextWeight: 2.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
+47 -10
View File
@@ -82,19 +82,33 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec)
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.Error("api: radio: load candidates", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
return
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, http.StatusInternalServerError, "server_error", "candidate load failed")
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,
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,
}
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
@@ -141,3 +155,26 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
}
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
}
+104
View File
@@ -131,6 +131,110 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) {
}
}
func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000)
control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000)
if _, err := pool.Exec(context.Background(),
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`,
seed.ID, target.ID); err != nil {
t.Fatalf("insert sim: %v", err)
}
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
targetIdx, controlIdx := -1, -1
for i, tr := range resp.Tracks {
if tr.ID == uuidToString(target.ID) {
targetIdx = i
}
if tr.ID == uuidToString(control.ID) {
controlIdx = i
}
}
if targetIdx < 0 || controlIdx < 0 {
t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx)
}
if targetIdx >= controlIdx {
t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx)
}
}
func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000)
for i := 3; i <= 6; i++ {
seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
}
q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID)
w := callRadio(h, user, q)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
for _, tr := range resp.Tracks {
if tr.ID == uuidToString(excluded.ID) {
t.Error("excluded track present in response")
}
}
}
func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)
q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID)
w := callRadio(h, user, q)
if w.Code != http.StatusOK {
t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
for _, tr := range resp.Tracks {
if tr.ID == uuidToString(other.ID) {
t.Error("other track should still be excluded after dropping malformed entry")
}
}
}
func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) {
// Defensive: even when the similarity pool returns no candidates,
// the seed track must still be the first track in the response.
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
artist := seedArtist(t, pool, "X")
album := seedAlbum(t, pool, artist.ID, "X", 1990)
seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var resp RadioResponse
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) {
t.Errorf("seed not at index 0: %v", resp.Tracks)
}
}
func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)