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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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