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