feat(taste): device-class context conditioning — #1551
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>
This commit is contained in:
@@ -65,7 +65,16 @@ func (w *Writer) RecordPlayStarted(
|
||||
clientID string,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
|
||||
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", "", at)
|
||||
}
|
||||
|
||||
// strPtrOrNil maps an empty string to a NULL text column (nullable pointer),
|
||||
// mirroring the clientID/source nil-on-empty pattern used above.
|
||||
func strPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// systemPlaylistSources are the play_events.source values that count
|
||||
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
|
||||
func (w *Writer) RecordPlayStartedWithSource(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
clientID, source, deviceClass string,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
var out StartedResult
|
||||
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
DeviceClass: strPtrOrNil(deviceClass),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
|
||||
func (w *Writer) RecordOfflinePlay(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
clientID, source, deviceClass string,
|
||||
at time.Time,
|
||||
durationPlayedMs int32,
|
||||
) error {
|
||||
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
DeviceClass: strPtrOrNil(deviceClass),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user