65dd132b3d
Milestone #160 Opt 3 (temporal half). A new additive scoring term that boosts a candidate when its artist's play history concentrates in the CURRENT daypart × weekday-type cell, in the user's local timezone. - Migration 0046: recommendation_weight_profiles.context_time_weight (per-profile scoring weight, DEFAULT 1.0). - Query ListArtistContextPlayCountsForUser: per-artist completed-play counts split by the current cell (daypart night[22,5)/morning[5,12)/ afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via started_at AT TIME ZONE users.timezone; 365-day window, skips excluded. - internal/recommendation/context.go: LoadContextAffinity computes each artist's shrunk cell-share minus the user's baseline share, clamped to [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown artists → 0 (cold-start neutral). - Score() gains context_affinity_score · ContextTimeWeight; both candidate loaders set it per candidate. - Tuning lab: ContextTimeWeight threaded through recsettings + admin API + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both profiles (uniform start, re-bakeable). Device-class axis deferred to #1551 (needs a client_id → device-class mapping that doesn't exist yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
5.7 KiB
Go
203 lines
5.7 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
|
|
}
|
|
|
|
affinity, err := LoadContextAffinity(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, r.ReleaseDate),
|
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
|
},
|
|
})
|
|
}
|
|
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
|
|
}
|
|
|
|
affinity, err := LoadContextAffinity(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, r.ReleaseDate),
|
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
|
},
|
|
})
|
|
}
|
|
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
|
|
}
|