5b79aa1047
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>
84 lines
2.2 KiB
Go
84 lines
2.2 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"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(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID, seedID pgtype.UUID,
|
|
recentlyPlayedHours int,
|
|
currentVector SessionVector,
|
|
) ([]Candidate, error) {
|
|
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
|
UserID: userID,
|
|
ID: seedID,
|
|
Column3: float64(recentlyPlayedHours),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
likes, err := loadContextualLikesByTrack(ctx, q, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]Candidate, 0, len(rows))
|
|
for _, r := range rows {
|
|
var lpt *time.Time
|
|
if r.LastPlayedAt.Valid {
|
|
t := r.LastPlayedAt.Time
|
|
lpt = &t
|
|
}
|
|
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
|
|
out = append(out, Candidate{
|
|
Track: r.Track,
|
|
Inputs: ScoringInputs{
|
|
IsGeneralLiked: r.IsLiked,
|
|
LastPlayedAt: lpt,
|
|
PlayCount: int(r.PlayCount),
|
|
SkipCount: int(r.SkipCount),
|
|
ContextualMatchScore: ctxScore,
|
|
},
|
|
})
|
|
}
|
|
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
|
|
}
|