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
+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)