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:
@@ -11,6 +11,24 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
|
||||
SELECT device_class
|
||||
FROM play_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The device_class of the user's most recent play (#1551), used as the
|
||||
// "current device" for radio context conditioning. NULL when the latest play
|
||||
// predates device capture or came from a client that didn't send one.
|
||||
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
|
||||
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
|
||||
var device_class *string
|
||||
err := row.Scan(&device_class)
|
||||
return device_class, err
|
||||
}
|
||||
|
||||
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
|
||||
WITH tz AS (
|
||||
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||||
@@ -30,6 +48,7 @@ now_cell AS (
|
||||
),
|
||||
plays AS (
|
||||
SELECT t.artist_id,
|
||||
pe.device_class,
|
||||
CASE
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||||
@@ -50,11 +69,17 @@ SELECT p.artist_id,
|
||||
count(*) FILTER (
|
||||
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||||
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||||
AND ($2::text = '' OR p.device_class = $2::text)
|
||||
) AS cell_plays
|
||||
FROM plays p
|
||||
GROUP BY p.artist_id
|
||||
`
|
||||
|
||||
type ListArtistContextPlayCountsForUserParams struct {
|
||||
ID pgtype.UUID
|
||||
Column2 string
|
||||
}
|
||||
|
||||
type ListArtistContextPlayCountsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
TotalPlays int64
|
||||
@@ -62,14 +87,16 @@ type ListArtistContextPlayCountsForUserRow struct {
|
||||
}
|
||||
|
||||
// Per-artist completed-play counts split by whether each play falls in the
|
||||
// CURRENT daypart × weekday-type cell, in the user's local timezone (#1531).
|
||||
// Feeds the context-affinity scoring term: an artist whose plays concentrate
|
||||
// in the current cell (vs the user's overall baseline, computed Go-side from
|
||||
// these rows) gets boosted right now. Skips excluded; a 365-day window bounds
|
||||
// cost. Daypart buckets: night [22,5) morning [5,12) afternoon [12,17)
|
||||
// evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, id pgtype.UUID) ([]ListArtistContextPlayCountsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, id)
|
||||
// CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||||
// daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||||
// $2 (the current device) is ”, the device dimension is ignored (identical to
|
||||
// the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||||
// when $2 is set (radio), a play only counts in the cell if its device_class
|
||||
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||||
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||||
// afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user