feat(taste): era/decade taste facet — #1530
Milestone #160 Opt 2 (era half). A third taste facet alongside artists + genre tags: signed weights over decade buckets ("1990s") derived from albums.release_date, rebuilt daily and scored into the taste match. - Migration 0045: taste_profile_eras table (mirrors taste_profile_tags) + taste_tuning.era_scale column (DEFAULT 0.5). - Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors EnrichedTagScale), accumulate folds each play/like's decade at base*EraScale, persist atomic-replaces the era rows. - Scorer (internal/recommendation): TasteProfile gains an era term (own tanh scale + additive 0.15 share so it never weakens the existing artist/tag signal when a track is undated); candidate queries return album release_date; decadeOf mirrors the builder helper. - Tuning lab: era_scale threaded through recsettings + admin API + web card (auto-renders the new row) + Go/web tests. Mood facet deferred to #1534 (partial enrichment coverage + needs candidate-side enriched-tag loading). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -554,6 +554,13 @@ type TasteProfileArtist struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileEra struct {
|
||||
UserID pgtype.UUID
|
||||
Era string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileTag struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
@@ -569,6 +576,7 @@ type TasteTuning struct {
|
||||
EngagementFull float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -585,8 +585,10 @@ SELECT
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
@@ -620,6 +622,7 @@ type LoadRadioCandidatesRow struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// Returns all tracks except the seed and any played by the user within
|
||||
@@ -660,6 +663,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -775,6 +779,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
SELECT track_id, sim_score FROM lb_similar
|
||||
@@ -785,6 +790,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(started_at) AS last_played_at,
|
||||
@@ -796,7 +802,8 @@ LEFT JOIN LATERAL (
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||
al.release_date
|
||||
`
|
||||
|
||||
type LoadRadioCandidatesV2Params struct {
|
||||
@@ -818,6 +825,7 @@ type LoadRadioCandidatesV2Row struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
@@ -874,6 +882,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
&i.SimilarityScore,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale FROM taste_tuning WHERE singleton = true
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale FROM taste_tuning WHERE singleton = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
@@ -24,6 +24,7 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -122,9 +123,10 @@ UPDATE taste_tuning
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale
|
||||
`
|
||||
|
||||
type UpdateTasteTuningParams struct {
|
||||
@@ -133,6 +135,7 @@ type UpdateTasteTuningParams struct {
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||
@@ -142,6 +145,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
arg.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
)
|
||||
var i TasteTuning
|
||||
err := row.Scan(
|
||||
@@ -152,6 +156,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -214,8 +219,8 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full, enriched_tag_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (singleton) DO NOTHING
|
||||
`
|
||||
|
||||
@@ -225,6 +230,7 @@ type UpsertTasteTuningDefaultsParams struct {
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||
@@ -234,6 +240,7 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
|
||||
arg.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
|
||||
DELETE FROM taste_profile_eras WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
@@ -45,6 +54,22 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
|
||||
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileEraParams struct {
|
||||
UserID pgtype.UUID
|
||||
Era string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -87,21 +112,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
||||
}
|
||||
|
||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// (track_id, artist_id, genre) for each track the user has explicitly
|
||||
// liked. Feeds the track-like bonus into the liked track's artist and
|
||||
// tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
// (track_id, artist_id, genre, release_date) for each track the user has
|
||||
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
@@ -111,7 +138,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListLikedTrackTasteInputsForUserRow
|
||||
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -128,6 +160,7 @@ SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
@@ -135,6 +168,7 @@ SELECT
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -149,11 +183,12 @@ type ListPlayEngagementInputsForUserParams struct {
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
}
|
||||
|
||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||
@@ -162,7 +197,8 @@ type ListPlayEngagementInputsForUserRow struct {
|
||||
// One row per play in the decay-relevant window. completion is the play's
|
||||
// completion ratio (precomputed column when present, else duration_played /
|
||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
// is split into tags in Go. Quarantined tracks are excluded.
|
||||
// is split into tags in Go; release_date derives the decade for the era
|
||||
// facet (#1530). Quarantined tracks are excluded.
|
||||
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
@@ -176,6 +212,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
&i.Completion,
|
||||
&i.AgeDays,
|
||||
); err != nil {
|
||||
@@ -228,6 +265,45 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
|
||||
SELECT era, weight
|
||||
FROM taste_profile_eras
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileErasForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileErasForUserRow struct {
|
||||
Era string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileErasForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileErasForUserRow
|
||||
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
|
||||
Reference in New Issue
Block a user