Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268) #116
@@ -1022,20 +1022,66 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||||
WITH seeds AS (
|
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
|
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
|
--
|
||||||
|
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
|
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
|
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
|
-- from stops contributing instead of accumulating forever.
|
||||||
|
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
-- (new account, or before the first daily recompute).
|
||||||
|
--
|
||||||
|
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
--
|
||||||
|
-- Candidates already in the library, or already requested and not terminal,
|
||||||
|
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||||
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
|
|||||||
@@ -258,27 +258,66 @@ ORDER BY started_at DESC
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: SuggestArtistsForUser :many
|
-- name: SuggestArtistsForUser :many
|
||||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
--
|
||||||
-- artist_similarity_unmatched and filters out candidates already in
|
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
-- from stops contributing instead of accumulating forever.
|
||||||
WITH seeds AS (
|
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
-- (new account, or before the first daily recompute).
|
||||||
|
--
|
||||||
|
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
--
|
||||||
|
-- Candidates already in the library, or already requested and not terminal,
|
||||||
|
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||||
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
// suggestions.go is the per-user artist-suggestion service behind the
|
||||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
// Discover request surface. Seeds from the taste profile (falling back to
|
||||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
// likes + completed plays for a user who has none yet), projects those seeds
|
||||||
// resolved to artist names.
|
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||||
|
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||||
|
//
|
||||||
|
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||||
|
// without bound and counted skips as engagement, so a few heavily-played
|
||||||
|
// artists monopolized every slot and the surface entrenched harder the more
|
||||||
|
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||||
|
// see internal/db/queries/recommendation.sql for the tiering.
|
||||||
package recommendation
|
package recommendation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -32,8 +39,12 @@ type SeedContribution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
// capped at 50 (default 12 when out of range).
|
||||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
//
|
||||||
|
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
|
||||||
|
// seed path only — the likes + completed-plays fallback used while the user
|
||||||
|
// has no taste-profile rows yet. Once the profile is populated it seeds
|
||||||
|
// instead, carrying its own decay, so this knob stops applying.
|
||||||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 12
|
limit = 12
|
||||||
|
|||||||
@@ -330,3 +330,145 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
|||||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||||
|
|
||||||
|
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, artistID, weight,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("set taste weight: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
|
||||||
|
userID, startedAt,
|
||||||
|
).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, $4, true)`,
|
||||||
|
userID, trackID, sessionID, startedAt,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert skipped play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||||
|
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||||
|
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seed := seedArtist(t, pool, "Profile Seed", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "out-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
||||||
|
}
|
||||||
|
if out[0].Score <= 0 {
|
||||||
|
t.Errorf("score = %v, want > 0", out[0].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
||||||
|
// the user played but that the taste engine did not keep does NOT seed. That is
|
||||||
|
// the point of the rewrite: the taste engine decides what counts as affinity.
|
||||||
|
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
kept := seedArtist(t, pool, "Kept By Taste", "")
|
||||||
|
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
||||||
|
|
||||||
|
// `dropped` has real completed plays but no profile row.
|
||||||
|
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
||||||
|
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "kept-cand" {
|
||||||
|
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
||||||
|
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
||||||
|
// tier 2 and the assertion would pass for the wrong reason.
|
||||||
|
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
positive := seedArtist(t, pool, "Still Liked", "")
|
||||||
|
abandoned := seedArtist(t, pool, "Abandoned", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
||||||
|
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == "neg-cand" {
|
||||||
|
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
||||||
|
t.Errorf("out = %+v, want only pos-cand", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||||
|
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||||
|
// neighbours at the user (#2367 mechanism 3).
|
||||||
|
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
skipped := seedArtist(t, pool, "Only Skipped", "")
|
||||||
|
|
||||||
|
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||||
|
}
|
||||||
|
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user