diff --git a/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.spec.ts b/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.spec.ts index 4dc2e33e4..b2a8cd584 100644 --- a/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.spec.ts +++ b/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.spec.ts @@ -7,6 +7,7 @@ const applyStoredAutoplayPreference = jest.fn<(queue: unknown, guildId: string) => Promise>() const blendAutoplayTracks = jest.fn<(queue: unknown, track: unknown) => Promise>() +const clearSessionMoodCache = jest.fn<(guildId: string) => void>() const addBreadcrumb = jest.fn() const errorLog = jest.fn() @@ -20,6 +21,9 @@ jest.mock('./autoplayPreference', () => ({ jest.mock('../../../../../utils/music/queueManipulation', () => ({ blendAutoplayTracks: (q: unknown, t: unknown) => blendAutoplayTracks(q, t), })) +jest.mock('../../../../../utils/music/autoplay/replenisher', () => ({ + clearSessionMoodCache: (id: string) => clearSessionMoodCache(id), +})) jest.mock('@lucky/shared/utils/monitoring', () => ({ addBreadcrumb: (...args: unknown[]) => addBreadcrumb(...args), })) @@ -58,6 +62,7 @@ describe('runPostPlayBackgroundOps', () => { }) expect(clearAutoplayPause).toHaveBeenCalledWith(guildId) + expect(clearSessionMoodCache).toHaveBeenCalledWith(guildId) expect(applyStoredAutoplayPreference).toHaveBeenCalledTimes(1) expect(blendAutoplayTracks).toHaveBeenCalledTimes(1) expect(failedOps()).toEqual([]) diff --git a/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.ts b/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.ts index bb6258cdc..ae7b9d5ba 100644 --- a/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.ts +++ b/packages/bot/src/functions/music/commands/play/handlers/postPlayBackgroundOps.ts @@ -5,6 +5,7 @@ import { addBreadcrumb } from '@lucky/shared/utils/monitoring' import { blendAutoplayTracks } from '../../../../../utils/music/queueManipulation' import { applyStoredAutoplayPreference } from './autoplayPreference' import { clearAutoplayPause } from '../../../../../utils/music/autoplay/skipCircuitBreaker' +import { clearSessionMoodCache } from '../../../../../utils/music/autoplay/replenisher' export interface PostPlayBackgroundOpsInput { queue: GuildQueue | null | undefined @@ -75,6 +76,10 @@ export async function runPostPlayBackgroundOps( clearAutoplayPause(guildId), ) + await runIsolated('clearSessionMoodCache', guildId, () => + clearSessionMoodCache(guildId), + ) + if (!hadQueueBeforePlay && queue) { await runIsolated('applyStoredAutoplayPreference', guildId, () => withSingleRetry(() => diff --git a/packages/bot/src/utils/music/autoplay/autoplayContext.ts b/packages/bot/src/utils/music/autoplay/autoplayContext.ts index aaca1ac7d..9dc69c4a2 100644 --- a/packages/bot/src/utils/music/autoplay/autoplayContext.ts +++ b/packages/bot/src/utils/music/autoplay/autoplayContext.ts @@ -29,4 +29,5 @@ export interface AutoplayContext { } replayFrequentTrackIds?: Set replayFrequentArtists?: Set + recentArtistIndices?: Map } diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts index ac3a2e26f..d5425e4c3 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts @@ -6,6 +6,7 @@ import { enrichWithAudioFeatures, calculateGenreFamilyPenalty, getGenreFamilies, + RECENCY_WINDOW_TRACKS, } from './candidateScorer' import type { SessionMood } from './sessionMood' @@ -749,4 +750,74 @@ describe('candidateScorer', () => { expect(result.signals).not.toContain('implicit dislike') }) }) + + describe('recency-decay penalty (diversity decay)', () => { + const candidateArtist = 'Recent Band' + const candidateKey = candidateArtist.toLowerCase() + + function score(recentArtistIndices?: Map) { + return calculateRecommendationScore({ + candidate: createTrack({ author: candidateArtist }), + currentTrack: createTrack({ author: 'Now Playing Artist' }), + recentArtists: new Set(), + recentArtistIndices, + }) + } + + it('penalizes a candidate whose artist played most recently (index 0)', () => { + const baseline = score() + const decayed = score(new Map([[candidateKey, 0]])) + expect(decayed.score).toBeLessThan(baseline.score) + expect(decayed.signals).toContain('recency decay') + }) + + it('applies a smaller penalty the further back the artist appeared', () => { + const recent = score(new Map([[candidateKey, 0]])) + const older = score(new Map([[candidateKey, 8]])) + // Older appearance => smaller decay factor => less-negative penalty + // => higher score than the most-recent case. + expect(older.score).toBeGreaterThan(recent.score) + expect(older.signals).toContain('recency decay') + }) + + it('applies no penalty once the artist is at or beyond the decay window', () => { + const baseline = score() + const atWindow = score(new Map([[candidateKey, RECENCY_WINDOW_TRACKS]])) + expect(atWindow.score).toBe(baseline.score) + expect(atWindow.signals).not.toContain('recency decay') + }) + + it('applies no penalty when the candidate artist is not in the recent map', () => { + const result = score(new Map([['some other artist', 0]])) + expect(result.signals).not.toContain('recency decay') + }) + + it('omits the penalty entirely when no recency map is provided', () => { + const result = score() + expect(result.signals).not.toContain('recency decay') + }) + + it('is bounded so it cannot flip a genre-family veto (-Infinity)', () => { + const result = calculateRecommendationScore({ + candidate: createTrack({ author: candidateArtist }), + currentTrack: createTrack({ author: 'Now Playing Artist' }), + recentArtists: new Set(), + recentArtistIndices: new Map([[candidateKey, 0]]), + autoplayMode: 'similar', + sessionMood: { + dominantLocale: null, + deepDiveArtist: null, + preferLong: false, + preferShort: false, + restless: false, + }, + genreContext: { + candidateTags: ['thrash metal'], + currentTrackTags: ['reggaeton'], + sessionGenreFamilies: new Set(['latin']), + }, + }) + expect(result.score).toBe(-Infinity) + }) + }) }) diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.ts index 3ce0b3c38..81974319e 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.ts @@ -49,6 +49,12 @@ const SPOTIFY_PREFERRED_UNKNOWN_MULTIPLIER = 0.5 // artist replayed >2 times in the last 30 days. Bounded so it cannot flip // genre vetoes (-Infinity) or dominate provenance-aware rankings. const SCORE_REPLAY_COUNT_BOOST = 0.15 +// Recency-decay penalty: applied to candidates whose artist appeared in the +// recent queue, scaled by how recently they appeared. Decays linearly to zero +// over a window of RECENCY_WINDOW_TRACKS. Bounded so it cannot flip genre +// vetoes (-Infinity) or override provenance-aware ordering. +const SCORE_RECENCY_DECAY_MAX = -0.15 +export const RECENCY_WINDOW_TRACKS = 10 // Genre families dense/cohesive enough that a cross-family jump reads as drift. // Used both for the cross-family penalty and the untagged-candidate fail-closed // guard. Kept deliberately narrow (pop/soul are too broad to fail closed on). @@ -200,6 +206,12 @@ export interface ScoringContext { */ replayFrequentTrackIds?: Set replayFrequentArtists?: Set + /** + * Map of artist names (lowercased) to their queue position (0 = most recent) + * in the recent history. Used to calculate recency-decay penalty: artists + * that appeared recently get a penalty that decays to zero over RECENCY_WINDOW_TRACKS. + */ + recentArtistIndices?: Map } export function calculateRecommendationScore(ctx: ScoringContext): { @@ -224,6 +236,7 @@ export function calculateRecommendationScore(ctx: ScoringContext): { genreContext = {}, replayFrequentTrackIds = new Set(), replayFrequentArtists = new Set(), + recentArtistIndices = new Map(), } = ctx const candidateTags = genreContext.candidateTags ?? [] const currentTrackTags = genreContext.currentTrackTags ?? [] @@ -516,6 +529,22 @@ export function calculateRecommendationScore(ctx: ScoringContext): { signals.push('replay frequent') } + // Recency-decay penalty: candidates whose artist appeared in the recent + // queue receive a penalty that decays linearly to zero over a window of + // RECENCY_WINDOW_TRACKS. More recent = larger penalty, older = smaller + // penalty. Bounded and applied additively so it cannot flip hard vetoes + // or override provenance-aware ordering. + const recentIndex = recentArtistIndices.get(candidateArtist) + if (recentIndex !== undefined && recentIndex >= 0) { + // Linear decay: penalty = max * (1 - index/window), clamped to [0, max] + const decayFactor = Math.max(0, 1 - recentIndex / RECENCY_WINDOW_TRACKS) + const penalty = SCORE_RECENCY_DECAY_MAX * decayFactor + if (penalty !== 0) { + score += penalty + signals.push('recency decay') + } + } + // Soft genre-family penalty (formerly inside enrichWithAudioFeatures via // Spotify's getArtistGenres). When both sides have Last.fm tags, score // family overlap: same family → 0, no families known → −0.1, no overlap diff --git a/packages/bot/src/utils/music/autoplay/recommendationBasis.ts b/packages/bot/src/utils/music/autoplay/recommendationBasis.ts index ddffe52b1..1fb27d246 100644 --- a/packages/bot/src/utils/music/autoplay/recommendationBasis.ts +++ b/packages/bot/src/utils/music/autoplay/recommendationBasis.ts @@ -41,6 +41,7 @@ export type RecommendationSignal = | 'discovery boost' | 'energy match' | 'replay frequent' + | 'recency decay' export interface RecommendationBasis { source: RecommendationSource diff --git a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts index 18db352e6..0824b7c8b 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.spec.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.spec.ts @@ -490,6 +490,41 @@ describe('replenishQueue', () => { 'guildid', ) }) + + it('builds recency-decay indices from full history: most-recent-per-artist, dedup, missing-author skip, window-bounded', async () => { + const { collectRecommendationCandidates } = require('./candidateCollector') + collectRecommendationCandidates.mockResolvedValue(new Map()) + + // allTracks = [currentTrack, ...history]; only positions < window (10) + // are mapped, each unique artist keyed to its most-recent position. + const history = [ + createTrack({ author: 'Alpha' }), // allTracks idx 1 + createTrack({ author: 'Beta' }), // idx 2 + createTrack({ author: 'Alpha' }), // idx 3 — dup, keep idx 1 + createTrack({ author: '' }), // idx 4 — missing author, skip + createTrack({ author: 'F5' }), // idx 5 + createTrack({ author: 'F6' }), // idx 6 + createTrack({ author: 'F7' }), // idx 7 + createTrack({ author: 'F8' }), // idx 8 + createTrack({ author: 'F9' }), // idx 9 + createTrack({ author: 'OutOfWindow' }), // idx 10 — beyond window + ] + const queue = createGuildQueue({ + currentTrack: createTrack({ author: 'Cur' }), + history: { tracks: { toArray: () => history } }, + } as Partial) + + await replenishQueue(queue) + + const ctx = collectRecommendationCandidates.mock.calls[0][0] + const indices: Map = ctx.recentArtistIndices + expect(indices.get('cur')).toBe(0) + expect(indices.get('alpha')).toBe(1) // most-recent position wins + expect(indices.get('beta')).toBe(2) + expect(indices.get('f9')).toBe(9) + expect(indices.has('')).toBe(false) // missing author skipped + expect(indices.has('outofwindow')).toBe(false) // window-bounded + }) }) describe('clearSessionMoodCache', () => { diff --git a/packages/bot/src/utils/music/autoplay/replenisher.ts b/packages/bot/src/utils/music/autoplay/replenisher.ts index 83785437d..a284bb55b 100644 --- a/packages/bot/src/utils/music/autoplay/replenisher.ts +++ b/packages/bot/src/utils/music/autoplay/replenisher.ts @@ -24,7 +24,7 @@ import { hasGenreTag, type ArtistTagFetcher, } from './artistTagCache' -import { getGenreFamilies } from './candidateScorer' +import { getGenreFamilies, RECENCY_WINDOW_TRACKS } from './candidateScorer' import { buildExcludedUrls, buildExcludedKeys, @@ -286,6 +286,13 @@ async function _replenishQueue( }, }) const recentArtists = buildRecentArtists(currentTrack, historyTracks) + // Recency-decay needs the real recent queue, not the 3-track seed sample + // (HISTORY_SEED_LIMIT) — otherwise the linear-decay window never fills and + // every recent artist gets a near-max penalty. Feed it the fuller history. + const recentArtistIndices = buildRecentArtistIndices( + currentTrack, + allHistoryTracks, + ) const artistFrequency = buildArtistFrequency(persistentHistory) // Fetch the token FIRST (it may refresh), then audio features — @@ -361,6 +368,7 @@ async function _replenishQueue( genreContext: candidateGenreContext, replayFrequentTrackIds: replayFrequency?.trackIds ?? new Set(), replayFrequentArtists: replayFrequency?.artists ?? new Set(), + recentArtistIndices, } const candidates = await collectRecommendationCandidates( autoplayContext, @@ -613,6 +621,34 @@ function buildRecentArtists( ) } +/** + * Build a map of artist names (lowercased) to their queue position (0 = most recent) + * for recency-decay scoring. Only positions within RECENCY_WINDOW_TRACKS matter — + * the scorer's decay factor clamps to zero past the window — so iteration stops + * there, keeping the map bounded regardless of how much history is fetched upstream. + */ +function buildRecentArtistIndices( + currentTrack: Track, + historyTracks: Track[], +): Map { + const indices = new Map() + const allTracks = [currentTrack, ...historyTracks] + const seenArtists = new Set() + + // Iterate from most recent to oldest, assigning each unique artist its + // most-recent position. Stop at the decay window — later positions score 0. + const limit = Math.min(allTracks.length, RECENCY_WINDOW_TRACKS) + for (let i = 0; i < limit; i++) { + const artist = allTracks[i].author?.toLowerCase() + if (artist && !seenArtists.has(artist)) { + indices.set(artist, i) + seenArtists.add(artist) + } + } + + return indices +} + /** * Look up Last.fm tags for the most recent unique artists in history and * derive the dominant genre families. Returns a non-empty set only when at