feat(taste): era/decade taste facet — #1530
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s

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:
2026-07-14 09:01:00 -04:00
parent 40056d2e9a
commit 40384cc05e
20 changed files with 376 additions and 65 deletions
+11 -2
View File
@@ -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