feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore
Adds a 6th argument (currentVector SessionVector) to LoadCandidates, bulk-fetches the user's active contextual_likes in one query, and sets ContextualMatchScore on each candidate's ScoringInputs via max-similarity over that candidate's like vectors. Adds helper + 6 integration tests covering no-likes, single match, multi-like max, soft-delete filtering, seed-like filtering, and seed-current short-circuit. Adds contextual_likes to the TRUNCATE list in testPool to prevent cross-test leakage. internal/api/radio.go is intentionally left broken (Task 6 fixes it). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package recommendation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -9,11 +10,17 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||||
|
// the existing track+stats query with a one-shot bulk fetch of the user's
|
||||||
|
// active contextual_likes, mapping each candidate to its max similarity
|
||||||
|
// against currentVector. Pass currentVector with Seed=true to short-circuit
|
||||||
|
// the contextual term to 0 (cold-start path).
|
||||||
func LoadCandidates(
|
func LoadCandidates(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
q *dbq.Queries,
|
q *dbq.Queries,
|
||||||
userID, seedID pgtype.UUID,
|
userID, seedID pgtype.UUID,
|
||||||
recentlyPlayedHours int,
|
recentlyPlayedHours int,
|
||||||
|
currentVector SessionVector,
|
||||||
) ([]Candidate, error) {
|
) ([]Candidate, error) {
|
||||||
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -23,6 +30,12 @@ func LoadCandidates(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
likes, err := loadContextualLikesByTrack(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -30,15 +43,41 @@ func LoadCandidates(
|
|||||||
t := r.LastPlayedAt.Time
|
t := r.LastPlayedAt.Time
|
||||||
lpt = &t
|
lpt = &t
|
||||||
}
|
}
|
||||||
|
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
|
||||||
out = append(out, Candidate{
|
out = append(out, Candidate{
|
||||||
Track: r.Track,
|
Track: r.Track,
|
||||||
Inputs: ScoringInputs{
|
Inputs: ScoringInputs{
|
||||||
IsGeneralLiked: r.IsLiked,
|
IsGeneralLiked: r.IsLiked,
|
||||||
LastPlayedAt: lpt,
|
LastPlayedAt: lpt,
|
||||||
PlayCount: int(r.PlayCount),
|
PlayCount: int(r.PlayCount),
|
||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
|
ContextualMatchScore: ctxScore,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||||
|
// one query and groups them by track_id. Rows whose session_vector fails
|
||||||
|
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||||
|
// bad row); the SQL query already filters NULL vectors.
|
||||||
|
func loadContextualLikesByTrack(
|
||||||
|
ctx context.Context,
|
||||||
|
q *dbq.Queries,
|
||||||
|
userID pgtype.UUID,
|
||||||
|
) (map[pgtype.UUID][]SessionVector, error) {
|
||||||
|
rows, err := q.ListActiveContextualLikesForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[pgtype.UUID][]SessionVector, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
var v SessionVector
|
||||||
|
if err := json.Unmarshal(r.SessionVector, &v); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[r.TrackID] = append(out[r.TrackID], v)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package recommendation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -33,7 +34,7 @@ func testPool(t *testing.T) *pgxpool.Pool {
|
|||||||
}
|
}
|
||||||
t.Cleanup(pool.Close)
|
t.Cleanup(pool.Close)
|
||||||
if _, err := pool.Exec(context.Background(),
|
if _, err := pool.Exec(context.Background(),
|
||||||
"TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
"TRUNCATE contextual_likes, general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||||
t.Fatalf("truncate: %v", err)
|
t.Fatalf("truncate: %v", err)
|
||||||
}
|
}
|
||||||
return pool
|
return pool
|
||||||
@@ -77,7 +78,7 @@ func newFixture(t *testing.T, n int) fixture {
|
|||||||
|
|
||||||
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
|
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
|
||||||
f := newFixture(t, 5)
|
f := newFixture(t, 5)
|
||||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
|
|||||||
ClientID: nil,
|
ClientID: nil,
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) {
|
|||||||
DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
|
DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -180,7 +181,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
||||||
f := newFixture(t, 2)
|
f := newFixture(t, 2)
|
||||||
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
|
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +204,7 @@ func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
|
|||||||
// Alice likes tracks[1]; Bob shouldn't see it.
|
// Alice likes tracks[1]; Bob shouldn't see it.
|
||||||
_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
|
_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
|
||||||
|
|
||||||
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1)
|
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1, SessionVector{Seed: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -221,3 +222,134 @@ func trackTitles(cs []Candidate) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// helperInsertContextualLike inserts a contextual_like row with the given
|
||||||
|
// session_vector marshaled to JSON. Bypasses playevents.CaptureContextualLikeIfPlaying
|
||||||
|
// because we want full control over the stored vector for these unit tests.
|
||||||
|
func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(vec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
|
||||||
|
f.user, trackID, raw); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) {
|
||||||
|
f := newFixture(t, 5)
|
||||||
|
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
for _, c := range got {
|
||||||
|
if c.Inputs.ContextualMatchScore != 0 {
|
||||||
|
t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) {
|
||||||
|
f := newFixture(t, 3)
|
||||||
|
target := f.tracks[1]
|
||||||
|
likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
helperInsertContextualLike(t, f, target.ID, likeVec)
|
||||||
|
|
||||||
|
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, c := range got {
|
||||||
|
if c.Track.ID == target.ID {
|
||||||
|
found = true
|
||||||
|
if c.Inputs.ContextualMatchScore < 0.99 {
|
||||||
|
t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if c.Inputs.ContextualMatchScore != 0 {
|
||||||
|
t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("target track missing from candidate list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) {
|
||||||
|
f := newFixture(t, 3)
|
||||||
|
target := f.tracks[1]
|
||||||
|
helperInsertContextualLike(t, f, target.ID, SessionVector{
|
||||||
|
Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1},
|
||||||
|
})
|
||||||
|
helperInsertContextualLike(t, f, target.ID, SessionVector{
|
||||||
|
Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
|
||||||
|
})
|
||||||
|
helperInsertContextualLike(t, f, target.ID, SessionVector{
|
||||||
|
Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
|
||||||
|
for _, c := range got {
|
||||||
|
if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 {
|
||||||
|
t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) {
|
||||||
|
f := newFixture(t, 3)
|
||||||
|
target := f.tracks[1]
|
||||||
|
likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
helperInsertContextualLike(t, f, target.ID, likeVec)
|
||||||
|
if _, err := f.pool.Exec(context.Background(),
|
||||||
|
`UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil {
|
||||||
|
t.Fatalf("soft-delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
|
||||||
|
for _, c := range got {
|
||||||
|
if c.Inputs.ContextualMatchScore != 0 {
|
||||||
|
t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) {
|
||||||
|
f := newFixture(t, 3)
|
||||||
|
target := f.tracks[1]
|
||||||
|
helperInsertContextualLike(t, f, target.ID, SessionVector{
|
||||||
|
Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
|
||||||
|
for _, c := range got {
|
||||||
|
if c.Inputs.ContextualMatchScore != 0 {
|
||||||
|
t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) {
|
||||||
|
f := newFixture(t, 3)
|
||||||
|
target := f.tracks[1]
|
||||||
|
likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
helperInsertContextualLike(t, f, target.ID, likeVec)
|
||||||
|
|
||||||
|
currentSeed := SessionVector{Seed: true}
|
||||||
|
got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed)
|
||||||
|
for _, c := range got {
|
||||||
|
if c.Inputs.ContextualMatchScore != 0 {
|
||||||
|
t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user