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 }