5749f48b4a
Milestone #160 Opt 3b. Adds device class as a third context axis on top of the #1531 time-of-day/weekday affinity: on the radio path, a candidate is boosted when its artist concentrates in the current (daypart × weekday × device) cell. Client-sent (client_id is opaque; no UA stored), so it's captured going forward and applies to radio only (daily mixes are cron-built with no device → stay device-agnostic). Server: - Migration 0048: play_events.device_class text NULL (no CHECK; normalized in Go — one whitelist entry per new client class, not a migration). - events.go: eventRequest.device_class + normalizeDeviceClass (whitelist → mobile/web/…, else "other", empty → NULL); threaded through both RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent. - ListArtistContextPlayCountsForUser gains a current-device param; the cell FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531 time-only behaviour exactly (used by mixes). SessionVector.DeviceClass carries it; the radio handler derives the current device from the user's latest play (GetLatestPlayDeviceClassForUser) — request-free proxy. - No new tuning knob: device narrows the existing ContextAffinityScore (reuses context_time_weight). Clients: - web: play_started sends device_class 'web'. - android: play_started + offline replay send 'mobile' (EventsWire + PlayOfflinePayload + MutationReplayer + PlayEventsReporter). Test: LoadContextAffinity device-narrowing integration test (mobile vs web artist separation; device-agnostic parity). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
250 lines
7.4 KiB
Go
250 lines
7.4 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
|
)
|
|
|
|
// 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, currentVector.DeviceClass)
|
|
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,
|
|
// Fallback path: mood is scored only in the primary
|
|
// (similarity) loader — loading per-candidate tags over this
|
|
// near-whole-library pool isn't worth it (nil moods → 0).
|
|
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
|
|
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
|
|
// UserCoplay (#1533): tracks by artists co-played across the instance
|
|
// with the seed's artist (source='user_cooccurrence'). Empty on
|
|
// single-user servers, so it contributes nothing there.
|
|
UserCoplay 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,
|
|
UserCoplay: 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),
|
|
Limit_7: int32(limits.UserCoplay),
|
|
})
|
|
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, currentVector.DeviceClass)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
trackIDs := make([]pgtype.UUID, 0, len(rows))
|
|
for _, r := range rows {
|
|
trackIDs = append(trackIDs, r.Track.ID)
|
|
}
|
|
moods, err := loadCandidateMoods(ctx, q, trackIDs)
|
|
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, moods[r.Track.ID]),
|
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
|
},
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
|
|
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
|
|
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
|
|
// are absent from the map (→ no mood signal). Empty input short-circuits.
|
|
func loadCandidateMoods(
|
|
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
|
|
) (map[pgtype.UUID][]string, error) {
|
|
if len(trackIDs) == 0 {
|
|
return map[pgtype.UUID][]string{}, nil
|
|
}
|
|
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tagsByTrack := make(map[pgtype.UUID][]string)
|
|
for _, r := range rows {
|
|
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
|
|
}
|
|
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
|
|
for id, tags := range tagsByTrack {
|
|
if m := mood.Of(tags); len(m) > 0 {
|
|
out[id] = m
|
|
}
|
|
}
|
|
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
|
|
}
|