diff --git a/backend/src/routes/v2.routes.ts b/backend/src/routes/v2.routes.ts index 7fbb04d..cef9bb2 100644 --- a/backend/src/routes/v2.routes.ts +++ b/backend/src/routes/v2.routes.ts @@ -13,8 +13,10 @@ interface ActivePlan { servedTrackIds?: string[]; /** Explicit feedback targets (skip, dislike, completion, promotion). */ excludedTrackIds?: string[]; - /** Main artists served or explicitly rejected in this session. */ - excludedArtistIds?: string[]; + /** Main artists already heard in this Vibe; they get a mild rank penalty. */ + servedArtistIds?: string[]; + /** Artists explicitly skipped/disliked; they receive the stronger penalty. */ + downrankedArtistIds?: string[]; } const PLAN_TTL_SEC = 2 * 3600; @@ -26,9 +28,9 @@ function planKey(userId: string): string { return `v2:plan:${userId}`; } -function appendUniqueTrackId(ids: string[] | undefined, trackId: string): string[] { +function appendUniqueId(ids: string[] | undefined, id: string): string[] { const next = ids ? [...ids] : []; - if (!next.includes(trackId)) next.push(trackId); + if (!next.includes(id)) next.push(id); return next.length > MAX_SESSION_EXCLUSIONS ? next.slice(next.length - MAX_SESSION_EXCLUSIONS) : next; @@ -41,8 +43,11 @@ function sessionExclusions(active: ActivePlan): string[] { ])]; } -function sessionArtistExclusions(active: ActivePlan): string[] { - return [...new Set(active.excludedArtistIds ?? [])]; +function sessionArtistPenalties(active: ActivePlan): Map { + const penalties = new Map(); + for (const artistId of active.servedArtistIds ?? []) penalties.set(artistId, 0.2); + for (const artistId of active.downrankedArtistIds ?? []) penalties.set(artistId, 0.75); + return penalties; } export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) { @@ -148,7 +153,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe } const next = active.plan.shift()!; - active.servedTrackIds = appendUniqueTrackId(active.servedTrackIds, next.trackId); + active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId); const artistResult = await dbService.pgClient.query<{ artist_id: string }>( `SELECT artist_id FROM track_artists_v2 WHERE track_id = $1 AND role = 'main' @@ -156,7 +161,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe [next.trackId] ); if (artistResult.rows[0]?.artist_id) { - active.excludedArtistIds = appendUniqueTrackId(active.excludedArtistIds, artistResult.rows[0].artist_id); + active.servedArtistIds = appendUniqueId(active.servedArtistIds, artistResult.rows[0].artist_id); } // Enrich with track details const track = await dbService.getTrackById(next.trackId); @@ -169,7 +174,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe active.plan, [next.trackId], active.seedTrackId ?? undefined, - { excludedTrackIds: sessionExclusions(active), excludedArtistIds: sessionArtistExclusions(active) } + { excludedTrackIds: sessionExclusions(active), artistPenalties: sessionArtistPenalties(active) } ); active.plan = refill; } @@ -233,15 +238,17 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe if (!sessionId || active.sessionId !== sessionId) return 0; // Feedback may race /next or arrive after a client-side prefetch. In all // cases its track becomes ineligible for the rest of this session. - active.excludedTrackIds = appendUniqueTrackId(active.excludedTrackIds, trackId); - const artistResult = await dbService.pgClient.query<{ artist_id: string }>( - `SELECT artist_id FROM track_artists_v2 - WHERE track_id = $1 AND role = 'main' - ORDER BY confidence DESC NULLS LAST LIMIT 1`, - [trackId] - ); - if (artistResult.rows[0]?.artist_id) { - active.excludedArtistIds = appendUniqueTrackId(active.excludedArtistIds, artistResult.rows[0].artist_id); + active.excludedTrackIds = appendUniqueId(active.excludedTrackIds, trackId); + if (action === 'skipped' || action === 'disliked') { + const artistResult = await dbService.pgClient.query<{ artist_id: string }>( + `SELECT artist_id FROM track_artists_v2 + WHERE track_id = $1 AND role = 'main' + ORDER BY confidence DESC NULLS LAST LIMIT 1`, + [trackId] + ); + if (artistResult.rows[0]?.artist_id) { + active.downrankedArtistIds = appendUniqueId(active.downrankedArtistIds, artistResult.rows[0].artist_id); + } } const sessionTrackIds = sessionExclusions(active); const refill = await director.replan( @@ -250,7 +257,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe active.plan, [trackId], active.seedTrackId ?? undefined, - { excludedTrackIds: sessionTrackIds, excludedArtistIds: sessionArtistExclusions(active) } + { excludedTrackIds: sessionTrackIds, artistPenalties: sessionArtistPenalties(active) } ); active.plan = refill; await setActivePlan(userId, active); diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 8c06585..40fec53 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -37,8 +37,12 @@ export interface RepetitionState { export interface PlanBuildOptions { /** Tracks already exposed during this Vibe session; they are ineligible. */ excludedTrackIds?: Iterable; - /** Main artists already served or rejected in this Vibe session. */ - excludedArtistIds?: Iterable; + /** + * Session-local artist penalties. These deliberately affect ranking rather + * than eligibility: a Vibe may return to an artist after giving other + * artists a fair chance, but skips/dislikes push that artist much lower. + */ + artistPenalties?: Iterable; } const W_ENJOY = 1.0; @@ -46,6 +50,7 @@ const W_FATIGUE = 0.4; const W_DIVERSITY = 0.3; const W_ENTROPY = 0.2; const W_REPETITION = 0.5; +const W_SESSION_ARTIST_PENALTY = 0.5; const PLAN_SIZE = 20; /** @@ -727,7 +732,8 @@ export class SessionDirector { fatigue: FatigueState, budgets: DiversityBudget[], state: GeneratorContext['state'], - repetitionState: RepetitionState + repetitionState: RepetitionState, + artistPenalties: ReadonlyMap = new Map() ): Promise { if (candidates.length === 0) return []; @@ -783,6 +789,11 @@ export class SessionDirector { + W_DIVERSITY * diversityBonus + W_ENTROPY * entropyBonus; + // Session history should diversify the queue, not make an artist + // permanently ineligible. A skipped/disliked artist receives the larger + // penalty supplied by the route; a merely served artist gets a small one. + score -= W_SESSION_ARTIST_PENALTY * (artistPenalties.get(artistId) ?? 0); + if (wouldRepeat) { score *= 0.1; } @@ -901,7 +912,7 @@ export class SessionDirector { ]); if (seedTrackId) recentExclusionSet.add(seedTrackId); const recentExclusions = [...recentExclusionSet]; - const excludedArtistIds = new Set(options.excludedArtistIds ?? []); + const artistPenalties = new Map(options.artistPenalties ?? []); const toleranceMap: Record = {}; const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery'); for (const b of discoveryBeliefs) { @@ -938,15 +949,14 @@ export class SessionDirector { const eligibleCandidates = allCandidates.filter(candidate => !recentExclusionSet.has(candidate.trackId) && !repetitionState.recentTrackIds.has(candidate.trackId) && - !repetitionState.recentArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '') && - !excludedArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '') + !repetitionState.recentArtistIds.has(candidateArtistMap.get(candidate.trackId) ?? '') ); if (eligibleCandidates.length === 0) { return []; } const ranked = await this.rankCandidates( - eligibleCandidates, fatigue, budgets, state, repetitionState + eligibleCandidates, fatigue, budgets, state, repetitionState, artistPenalties ); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); @@ -1072,7 +1082,7 @@ export class SessionDirector { if (loopDim) { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), - excludedArtistIds: options.excludedArtistIds, + artistPenalties: options.artistPenalties, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } @@ -1082,7 +1092,7 @@ export class SessionDirector { if (Math.abs(entropy - 0.55) > 0.2) { const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), - excludedArtistIds: options.excludedArtistIds, + artistPenalties: options.artistPenalties, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } @@ -1096,7 +1106,7 @@ export class SessionDirector { // queued. Keep the valid tail and append only fresh candidates. const rebuilt = await this.buildPlan(userId, sessionId, seedTrackId, { excludedTrackIds: new Set([...excludedTrackIds, ...remainingSlots.map(c => c.trackId)]), - excludedArtistIds: options.excludedArtistIds, + artistPenalties: options.artistPenalties, }); return mergeUniquePlan(remainingSlots, rebuilt, excludedTrackIds, PLAN_SIZE); } diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 02611c2..c6c417b 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -167,6 +167,28 @@ describe('SessionDirector', () => { const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState); expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance); }); + + it('downranks a skipped artist without making it ineligible', async () => { + const db = makeMockDb(); + (db.pgClient.query as any) + .mockResolvedValueOnce({ rows: [{ track_id: 't1', artist_id: 'a1' }, { track_id: 't2', artist_id: 'a2' }] }) + .mockResolvedValueOnce({ rows: [] }); + const director = new SessionDirector(db); + const candidates = [candidate('t1'), candidate('t2')]; + const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 }; + const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null }; + + const ranked = await director.rankCandidates( + candidates, + fatigue, + [], + state, + { recentTrackIds: new Set(), recentArtistIds: new Set() }, + new Map([['a1', 0.75]]) + ); + + expect(ranked.map(item => item.trackId)).toEqual(['t2', 't1']); + }); }); describe('buildState', () => {