6c26ba807e
2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by the user's top positively-weighted taste-profile artists ($10 K, weight > 0, deterministic weight-DESC,id order so it doesn't reintroduce same-day nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores the fit. Empty for cold-start users (no profile). - CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via systemForYouSourceLimits. - You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively- engaged artists, so flooding its pool with top-taste (mostly already-played) artists would just feed the read-time dedup. - Test: positive-weight artist's track enters via the arm; negative-weight one is excluded (weight > 0). Existing pool tests unaffected (no profile seeded). Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch already inject taste broadly (top-played seed ≈ top-taste artist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
5.4 KiB
Go
191 lines
5.4 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
|
|
}
|
|
|
|
profile, err := LoadTasteProfile(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,
|
|
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
|
},
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CandidateSourceLimits controls per-source K values for the M4c
|
|
// similarity-driven pool. Defaults via DefaultCandidateSourceLimits().
|
|
type CandidateSourceLimits struct {
|
|
LBSimilar int
|
|
SimilarArtist int
|
|
TagOverlap int
|
|
LikesOverlap int
|
|
RandomFill int
|
|
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
|
|
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
|
// users have an empty profile, so it contributes nothing anyway).
|
|
TasteOverlap int
|
|
}
|
|
|
|
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
|
func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|
return CandidateSourceLimits{
|
|
LBSimilar: 30,
|
|
SimilarArtist: 30,
|
|
TagOverlap: 20,
|
|
LikesOverlap: 20,
|
|
RandomFill: 30,
|
|
TasteOverlap: 20,
|
|
}
|
|
}
|
|
|
|
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
|
|
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
|
|
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
|
|
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
|
|
//
|
|
// Caller (radio handler) falls back to LoadCandidates on error.
|
|
func LoadCandidatesFromSimilarity(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID, seedID pgtype.UUID,
|
|
recentlyPlayedHours int,
|
|
currentVector SessionVector,
|
|
exclude []pgtype.UUID,
|
|
limits CandidateSourceLimits,
|
|
) ([]Candidate, error) {
|
|
if exclude == nil {
|
|
exclude = []pgtype.UUID{}
|
|
}
|
|
rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{
|
|
UserID: userID,
|
|
ID: seedID,
|
|
Column3: int64(recentlyPlayedHours),
|
|
Column4: exclude,
|
|
Limit: int32(limits.LBSimilar),
|
|
Limit_2: int32(limits.SimilarArtist),
|
|
Limit_3: int32(limits.TagOverlap),
|
|
Limit_4: int32(limits.LikesOverlap),
|
|
Limit_5: int32(limits.RandomFill),
|
|
Limit_6: int32(limits.TasteOverlap),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
likes, err := loadContextualLikesByTrack(ctx, q, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
profile, err := LoadTasteProfile(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
|
|
}
|
|
// sqlc returns SimilarityScore as interface{} (couldn't infer the
|
|
// type through max(...) over a UNION). Type-assert; default to 0
|
|
// on the (impossible-but-defensive) case where it's nil/wrong type.
|
|
var simScore float64
|
|
if v, ok := r.SimilarityScore.(float64); ok {
|
|
simScore = v
|
|
}
|
|
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,
|
|
SimilarityScore: simScore,
|
|
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
|
},
|
|
})
|
|
}
|
|
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
|
|
}
|