fix vibe engine audit findings: pg.Pool, plan replan, dead exclusions, legacy engine removal

Backend:
- app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205)
- v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206)
- session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor)
- db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232)
- app.ts: drop test enqueue-job endpoint (#234)

Frontend:
- AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-17 13:22:06 +04:00
parent 9eb25311c8
commit c41316ee99
10 changed files with 465 additions and 922 deletions
+15 -28
View File
@@ -1,6 +1,6 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { Client as PgClient } from 'pg';
import { Pool } from 'pg';
import { createClient as createRedisClient } from 'redis';
import { DbService } from './services/db.service.js';
import { JobService } from './services/job.service.js';
@@ -8,7 +8,6 @@ import { SearchService } from './services/search.service.js';
import libraryRoutes from './routes/library.routes.js';
import searchRoutes from './routes/search.routes.js';
import adminRoutes from './routes/admin.routes.js';
import vibeRoutes from './routes/vibe.routes.js';
import historyRoutes from './routes/history.routes.js';
import streamRoutes from './routes/stream.routes.js';
import quarantineRoutes from './routes/quarantine.routes.js';
@@ -33,10 +32,14 @@ export async function buildApp(config: AppConfig) {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173', 'http://localhost:5174'],
});
const pgClient = new PgClient({
// A Pool (not a single Client) so concurrent requests get independent
// connections. Critical for correctness: recordPlay/recordSkip run
// BEGIN...COMMIT transactions, and other requests' queries must not be
// able to land inside another request's open transaction on a shared
// connection (see db.service.ts withTransaction()).
const pgPool = new Pool({
connectionString: process.env.DATABASE_URL,
});
await pgClient.connect();
const redisClient = createRedisClient({
url: process.env.REDIS_URL,
@@ -50,7 +53,7 @@ export async function buildApp(config: AppConfig) {
protocol: 'http',
apiKey: config.searchApiKey,
});
const dbService = new DbService(pgClient, searchService);
const dbService = new DbService(pgPool, searchService);
// Apply the idempotent schema on boot so tables added after the initial DB
// volume was created (e.g. play_history, feedback) exist. The init-time
@@ -118,7 +121,7 @@ export async function buildApp(config: AppConfig) {
};
try {
await pgClient.query('SELECT 1');
await pgPool.query('SELECT 1');
status.postgres = 'ok';
} catch (err) {
status.postgres = 'error';
@@ -136,7 +139,7 @@ export async function buildApp(config: AppConfig) {
}
const isHealthy = status.postgres === 'ok' && status.redis === 'ok';
if (isHealthy) {
return reply.code(200).send(status);
} else {
@@ -148,7 +151,6 @@ export async function buildApp(config: AppConfig) {
fastify.register(libraryRoutes, { prefix: '/api', dbService });
fastify.register(searchRoutes, { prefix: '/api', dbService });
fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService });
fastify.register(vibeRoutes, { prefix: '/api/vibe', dbService });
fastify.register(historyRoutes, { prefix: '/api', dbService });
fastify.register(streamRoutes, { prefix: '/api', dbService });
fastify.register(quarantineRoutes, { prefix: '/api', dbService });
@@ -159,24 +161,9 @@ export async function buildApp(config: AppConfig) {
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
fastify.register(discoveryRoutes, { prefix: '/api', dbService });
fastify.post('/api/test/enqueue-job', async (request, reply) => {
const { jobType, trackId, payload } = request.body as any;
try {
if (jobType === 'metadataRefresh') {
await jobService.enqueueMetadataRefresh(trackId, payload.type);
} else if (jobType === 'audioAnalysis') {
await jobService.enqueueAudioAnalysis(trackId, payload.features);
} else if (jobType === 'cleanup') {
await jobService.enqueueCleanup(payload.reason, payload.targetFiles);
} else {
return reply.code(400).send({ error: 'Unknown job type' });
}
await reply.send({ message: 'Job enqueued' });
} catch (error) {
request.log.error(error);
await reply.status(500).send({ error: 'Internal server error' });
}
});
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
// nothing in the deployed app or its tests called it, and deployment never
// sets NODE_ENV so an env gate would've stayed live in prod anyway.
// Register hooks to close connections on shutdown
fastify.addHook('onClose', async () => {
@@ -188,7 +175,7 @@ export async function buildApp(config: AppConfig) {
fastify.log.error(err);
}
try {
await pgClient.end();
await pgPool.end();
} catch (err) {
fastify.log.error(err);
}
@@ -204,5 +191,5 @@ export async function buildApp(config: AppConfig) {
}
});
return { fastify, pgClient, redisClient };
return { fastify, pgPool, redisClient };
}
+2 -2
View File
@@ -74,7 +74,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
// Replan if running low
if (active.plan.length < 5) {
const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined);
active.plan.push(...refill);
active.plan = refill;
}
await setActivePlan(userId, active);
@@ -111,7 +111,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
if (active) {
const playedTrackIds = [trackId];
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined);
active.plan.push(...refill);
active.plan = refill;
await setActivePlan(userId, active);
}
-76
View File
@@ -1,76 +0,0 @@
import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
export default async function vibeRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
const { dbService } = options;
// Start a new vibe session
fastify.post('/start', async (request, reply) => {
const { seedTrackId } = request.body as { seedTrackId: string };
if (!seedTrackId) {
return reply.code(400).send({ error: 'seedTrackId is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const batchId = await dbService.createVibeSession(userId, seedTrackId);
return reply.send({ batchId });
});
// Get the next chunk of tracks for the active session
fastify.get('/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const activeSession = await dbService.getActiveVibeSession(userId);
if (!activeSession) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
const tracks = await dbService.getNextVibeChunk(activeSession.batchId);
// Update the session timestamp to keep it alive
await dbService.updateVibeSession(activeSession.batchId);
return tracks;
});
// Start/return a chunk seeded by a genre (id or name) — no active session required.
fastify.get('/from-genre', async (request, reply) => {
const { genre } = request.query as { genre?: string };
if (!genre) {
return reply.code(400).send({ error: 'genre query param is required' });
}
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const tracks = await dbService.getVibeChunkFromGenre(genre, userId);
return tracks;
});
// Current ACTIVE batch metadata for the user, or 404.
fastify.get('/current', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const session = await dbService.getCurrentVibeSession(userId);
if (!session) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
return reply.send(session);
});
// Heartbeat: keep the active batch alive by bumping last_interaction_at.
fastify.post('/heartbeat', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const updated = await dbService.heartbeatVibeSession(userId);
if (!updated) {
return reply.code(404).send({ error: 'No active vibe session found' });
}
return reply.send({ status: 'ok' });
});
// End the vibe session
fastify.post('/end', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const activeSession = await dbService.getActiveVibeSession(userId);
if (activeSession) {
await dbService.endVibeSession(activeSession.batchId);
}
return reply.send({ status: 'session_ended' });
});
}
File diff suppressed because it is too large Load Diff
+88 -63
View File
@@ -178,7 +178,7 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance));
const unfamiliarRes = await db.pgClient.query(
`SELECT DISTINCT cf.object_id AS artist_id
`SELECT cf.object_id AS artist_id, MAX(cf.fused_value) AS edge_strength
FROM claim_fusion cf
WHERE cf.subject_type = 'artist'
AND cf.subject_id = ANY($1::uuid[])
@@ -191,16 +191,19 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
AND lb.entity_id = cf.object_id
AND lb.profile IN ('longterm', 'obsession')
)
GROUP BY cf.object_id
LIMIT 30`,
[trustedIds, ctx.userId]
);
const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id);
const unfamiliarRows = unfamiliarRes.rows as { artist_id: string; edge_strength: number }[];
const unfamiliarArtistIds = unfamiliarRows.map(r => r.artist_id);
const edgeStrengthMap = new Map(unfamiliarRows.map(r => [r.artist_id, r.edge_strength]));
if (unfamiliarArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
`SELECT id, artist_id FROM (
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
@@ -208,25 +211,29 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
AND cf.object_id = ANY($1::uuid[])
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub
ORDER BY RANDOM()
LIMIT $3`,
[unfamiliarArtistIds, ctx.recentExclusions, maxCandidates]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'discovery',
explanation: [{
subjectType: 'artist',
subjectId: unfamiliarArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.4,
}],
relevance: 0.4,
}));
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
const relevance = edgeStrengthMap.get(row.artist_id) ?? 0.4;
return {
trackId: row.id,
generatorId: 'discovery',
explanation: [{
subjectType: 'artist',
subjectId: row.artist_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: relevance,
}],
relevance,
};
});
}
// ---------------------------------------------------------------------------
@@ -253,6 +260,11 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
const albumIds = albumRows.map(a => a.album_id);
const albumArtistMap = new Map(albumRows.map(a => [a.album_id, a.artist_id]));
const obsessionValueMap = new Map(
ctx.beliefs
.filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
.map(b => [b.entity_id, b.value])
);
const trackRes = await db.pgClient.query(
`SELECT sub.id, sub.album_id
@@ -269,19 +281,23 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
[albumIds, ctx.recentExclusions]
);
return (trackRes.rows as { id: string; album_id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'deep-dive',
explanation: [{
subjectType: 'artist',
subjectId: albumArtistMap.get(row.album_id) ?? 'unknown',
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.7,
}],
relevance: 0.7,
}));
return (trackRes.rows as { id: string; album_id: string }[]).map(row => {
const artistId = albumArtistMap.get(row.album_id) ?? 'unknown';
const relevance = obsessionValueMap.get(artistId) ?? 0.7;
return {
trackId: row.id,
generatorId: 'deep-dive',
explanation: [{
subjectType: 'artist',
subjectId: artistId,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: relevance,
}],
relevance,
};
});
}
// ---------------------------------------------------------------------------
@@ -306,10 +322,11 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
if (staleArtists.length === 0) return [];
const staleArtistIds = staleArtists.map(a => a.artist_id);
const affinityMap = new Map(staleArtists.map(a => [a.artist_id, a.affinity]));
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
`SELECT id, artist_id FROM (
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
@@ -318,25 +335,29 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub
ORDER BY RANDOM()
LIMIT 20`,
[staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'revival',
explanation: [{
subjectType: 'artist',
subjectId: staleArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.6,
}],
relevance: 0.6,
}));
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
const relevance = affinityMap.get(row.artist_id) ?? 0.6;
return {
trackId: row.id,
generatorId: 'revival',
explanation: [{
subjectType: 'artist',
subjectId: row.artist_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: relevance,
}],
relevance,
};
});
}
// ---------------------------------------------------------------------------
@@ -455,15 +476,15 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
order: 'DESC',
});
const targetArtistIds = contextualBeliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.2)
.map(b => b.entity_id);
const targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2);
const targetArtistIds = targetBeliefs.map(b => b.entity_id);
const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value]));
if (targetArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
`SELECT id, artist_id FROM (
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
@@ -472,25 +493,29 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub
ORDER BY RANDOM()
LIMIT 15`,
[targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'contextual',
explanation: [{
subjectType: 'artist',
subjectId: targetArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.5,
}],
relevance: 0.5,
}));
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
const relevance = targetValueMap.get(row.artist_id) ?? 0.5;
return {
trackId: row.id,
generatorId: 'contextual',
explanation: [{
subjectType: 'artist',
subjectId: row.artist_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: relevance,
}],
relevance,
};
});
}
// ---------------------------------------------------------------------------
+131 -46
View File
@@ -28,6 +28,11 @@ export interface DiversityBudget {
spent: number;
}
export interface RepetitionState {
recentTrackIds: Set<string>;
recentArtistIds: Set<string>;
}
const W_ENJOY = 1.0;
const W_FATIGUE = 0.4;
const W_DIVERSITY = 0.3;
@@ -152,7 +157,7 @@ export class SessionDirector {
// D.2 — Fatigue model
// ---------------------------------------------------------------
async computeFatigue(userId: string): Promise<FatigueState> {
// Track fatigue: last 7 days, decay half-life 30d (2592000 seconds)
// Track fatigue: last 7 days, decay time constant 30d (e-folding; half-life ≈ 20.8d)
const TRACK_DECAY_SEC = 30 * 24 * 3600;
const trackRes = await this.db.pgClient.query(
`SELECT ph.track_id,
@@ -167,7 +172,8 @@ export class SessionDirector {
track.set(row.track_id, row.fatigue);
}
// Artist fatigue: last 24h, decay half-life 8h (28800 seconds)
// Artist fatigue: last 24h, decay time constant 8h (28800s) — this is an e-folding
// time (EXP(-t/tau)), not a half-life; the actual half-life is tau*ln(2) ≈ 5.5h
const ARTIST_DECAY_SEC = 8 * 3600;
const artistRes = await this.db.pgClient.query(
`SELECT ta.artist_id,
@@ -183,7 +189,7 @@ export class SessionDirector {
artist.set(row.artist_id, row.fatigue);
}
// Genre fatigue: last 24h, decay half-life 8h
// Genre fatigue: last 24h, decay time constant 8h (e-folding, not half-life; half-life ≈ 5.5h)
const genreRes = await this.db.pgClient.query(
`SELECT tg.genre_id,
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
@@ -198,7 +204,7 @@ export class SessionDirector {
genre.set(row.genre_id, row.fatigue);
}
// Language fatigue: last 2h, decay half-life 1h (3600 seconds)
// Language fatigue: last 2h, decay time constant 1h (3600s, e-folding; half-life ≈ 0.7h)
const LANG_DECAY_SEC = 3600;
const langRes = await this.db.pgClient.query(
`SELECT tl.language,
@@ -427,14 +433,25 @@ export class SessionDirector {
// ---------------------------------------------------------------
// D.5 — Entropy, anti-loop
// ---------------------------------------------------------------
computeEntropy(candidates: Candidate[]): number {
// NOTE: despite the name, this computes the Herfindahl-Hirschman Index (artist
// concentration, 0 = maximally diverse, 1 = single artist) — not entropy.
// `artistIdOf` should resolve the candidate's actual attributed artist; without it
// this falls back to guessing from the first artist-typed explanation edge, which
// for some generators (discovery/revival/contextual) isn't the real artist —
// pass a resolver when a real artist map is available (see rankCandidates).
computeEntropy(candidates: Candidate[], artistIdOf?: (c: Candidate) => string): number {
if (candidates.length === 0) return 0;
const artistCounts = new Map<string, number>();
for (const c of candidates) {
const mainEdge = c.explanation.find(
e => e.subjectType === 'artist' || e.objectType === 'artist'
);
const key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
let key: string;
if (artistIdOf) {
key = artistIdOf(c);
} else {
const mainEdge = c.explanation.find(
e => e.subjectType === 'artist' || e.objectType === 'artist'
);
key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
}
artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1);
}
const n = candidates.length;
@@ -599,6 +616,61 @@ export class SessionDirector {
return false;
}
// Batched version of checkRepetition for ranking a whole candidate pool:
// loads repetition_rules once, then one query for recently-played
// tracks/artists within the max window, and checks membership in JS
// instead of 2-3 sequential queries per candidate.
async buildRepetitionState(userId: string): Promise<RepetitionState> {
const rulesRes = await this.db.pgClient.query(
'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1',
[userId]
);
const ruleMap = new Map<string, number>();
for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) {
ruleMap.set(row.dimension, row.min_distance);
}
const trackMin = ruleMap.get('track') ?? 120;
const artistMin = ruleMap.get('artist') ?? 20;
const recentTrackIds = new Set<string>();
const recentArtistIds = new Set<string>();
const maxMin = Math.max(trackMin, artistMin);
if (maxMin > 0) {
const res = await this.db.pgClient.query(
`SELECT ph.track_id, ta.artist_id, ph.played_at
FROM play_history ph
LEFT JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
WHERE ph.user_id = $1 AND ph.completed = true
AND ph.played_at > NOW() - ($2 || ' minutes')::interval`,
[userId, String(maxMin)]
);
const now = Date.now();
for (const row of res.rows as { track_id: string; artist_id: string | null; played_at: Date }[]) {
const ageMin = (now - new Date(row.played_at).getTime()) / 60000;
if (trackMin > 0 && ageMin <= trackMin) recentTrackIds.add(row.track_id);
if (artistMin > 0 && row.artist_id && ageMin <= artistMin) recentArtistIds.add(row.artist_id);
}
}
return { recentTrackIds, recentArtistIds };
}
// Shared track_id -> main artist_id lookup, used by rankCandidates and replan.
private async loadArtistMap(trackIds: string[]): Promise<Map<string, string>> {
const artistMap = new Map<string, string>();
if (trackIds.length === 0) return artistMap;
const artRes = await this.db.pgClient.query(
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
FROM track_artists_v2 ta
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
[trackIds]
);
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
artistMap.set(row.track_id, row.artist_id);
}
return artistMap;
}
// ---------------------------------------------------------------
// D.8 — Multi-objective ranking
// ---------------------------------------------------------------
@@ -607,23 +679,12 @@ export class SessionDirector {
fatigue: FatigueState,
budgets: DiversityBudget[],
state: GeneratorContext['state'],
repetitionCheck: (trackId: string, artistId: string) => Promise<boolean>
repetitionState: RepetitionState
): Promise<Candidate[]> {
if (candidates.length === 0) return [];
const trackIds = [...new Set(candidates.map(c => c.trackId))];
const artistMap = new Map<string, string>();
if (trackIds.length > 0) {
const artRes = await this.db.pgClient.query(
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
FROM track_artists_v2 ta
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
[trackIds]
);
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
artistMap.set(row.track_id, row.artist_id);
}
}
const artistMap = await this.loadArtistMap(trackIds);
const genreMap = new Map<string, string>();
if (trackIds.length > 0) {
@@ -639,10 +700,17 @@ export class SessionDirector {
}
}
const artistBudget = budgets.find(b => b.dimension === 'artist');
const currentEntropy = this.computeEntropy(candidates);
const currentEntropy = this.computeEntropy(candidates, c => artistMap.get(c.trackId) ?? 'unknown');
const targetEntropy = 0.55;
// Per-candidate artist share within this batch, for a real per-candidate
// entropy contribution instead of the batch-wide constant.
const artistBatchCounts = new Map<string, number>();
for (const c of candidates) {
const aid = artistMap.get(c.trackId) ?? '';
artistBatchCounts.set(aid, (artistBatchCounts.get(aid) ?? 0) + 1);
}
const scored: { candidate: Candidate; score: number }[] = [];
for (const c of candidates) {
const artistId = artistMap.get(c.trackId) ?? '';
@@ -653,10 +721,14 @@ export class SessionDirector {
const genreFatigue = fatigue.genre.get(genreId) ?? 0;
const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3;
const artistSpendRatio = artistBudget ? artistBudget.spent : 0;
const diversityBonus = 1 - artistSpendRatio;
const entropyBonus = 1 - Math.abs(currentEntropy - targetEntropy);
const wouldRepeat = await repetitionCheck(c.trackId, artistId);
// diversityBonus: this artist's own fatigue-weighted share — varies per candidate.
const diversityBonus = 1 - artistFatigue;
// entropyBonus: reward candidates whose artist is underrepresented in this batch.
const artistShare = (artistBatchCounts.get(artistId) ?? 0) / candidates.length;
const entropyBonus = 1 - artistShare;
const wouldRepeat =
repetitionState.recentTrackIds.has(c.trackId) ||
(!!artistId && repetitionState.recentArtistIds.has(artistId));
let score = W_ENJOY * c.relevance
- W_FATIGUE * avgFatigue
@@ -687,6 +759,27 @@ export class SessionDirector {
return scored.map(s => s.candidate);
}
// session_state is otherwise only written once at /v2/vibe/start — persist the
// freshly-computed state vector here so it evolves across the session instead of
// buildState always reading back the boot defaults.
async persistState(sessionId: string, userId: string, state: GeneratorContext['state']): Promise<void> {
await this.db.pgClient.query(
`UPDATE session_state
SET state_vector = $3::jsonb, last_interaction = NOW()
WHERE session_id = $1 AND user_id = $2`,
[
sessionId,
userId,
JSON.stringify({
energy: state.energy,
lastArtistIds: state.lastArtistIds,
lastGenreIds: state.lastGenreIds,
noveltyHunger: state.noveltyHunger,
}),
]
);
}
// ---------------------------------------------------------------
// D.9 — Plan + replan loop
// ---------------------------------------------------------------
@@ -725,6 +818,7 @@ export class SessionDirector {
}));
const state = await this.buildState(userId, sessionId);
await this.persistState(sessionId, userId, state);
const fatigue = await this.computeFatigue(userId);
const budgets = await this.getBudgets(userId);
@@ -737,7 +831,7 @@ export class SessionDirector {
seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null;
}
const recentExclusions: string[] = [];
const recentExclusions: string[] = recentPlays.map(p => p.trackId);
const toleranceMap: Record<string, number> = {};
const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
for (const b of discoveryBeliefs) {
@@ -764,28 +858,17 @@ export class SessionDirector {
return [];
}
const repetitionCheckFn = (tid: string, aid: string) =>
this.checkRepetition(tid, aid, userId);
const repetitionState = await this.buildRepetitionState(userId);
const ranked = await this.rankCandidates(
allCandidates, fatigue, budgets, state, repetitionCheckFn
allCandidates, fatigue, budgets, state, repetitionState
);
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
let forcedExperimental = false;
if (loopDim && ranked.length > 0) {
const expCtx: GeneratorContext = {
...ctx,
recentExclusions: ctx.recentExclusions.slice(0, Math.min(ctx.recentExclusions.length, 50)),
};
const extraCandidates: Candidate[] = [];
for (const gen of ALL_GENERATORS) {
const result = await gen(this.db, expCtx);
extraCandidates.push(...result);
}
const expRanked = await this.rankCandidates(
extraCandidates, fatigue, budgets, state, repetitionCheckFn
);
const injected = expRanked.filter(
// Anti-loop candidates are already present in `ranked` — just pull them to the
// front instead of re-running all generators and re-ranking from scratch.
const injected = ranked.filter(
c => c.generatorId === 'experimental' || c.generatorId === 'discovery'
);
ranked.unshift(...injected);
@@ -855,6 +938,7 @@ export class SessionDirector {
const fatigue = await this.computeFatigue(userId);
const budgets = await this.getBudgets(userId);
const state = await this.buildState(userId, sessionId);
await this.persistState(sessionId, userId, state);
// Fetch recent plays for anti-loop
const recentPlaysRes = await this.db.pgClient.query(
@@ -892,7 +976,8 @@ export class SessionDirector {
return this.buildPlan(userId, sessionId, seedTrackId);
}
const entropy = this.computeEntropy(currentPlan);
const planArtistMap = await this.loadArtistMap([...new Set(currentPlan.map(c => c.trackId))]);
const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown');
if (Math.abs(entropy - 0.55) > 0.2) {
return this.buildPlan(userId, sessionId, seedTrackId);
}
@@ -95,7 +95,8 @@ describe('SessionDirector', () => {
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false);
const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
});
});
+25 -23
View File
@@ -1,9 +1,18 @@
import { useEffect, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { trackService } from '../services/trackService';
import { vibeService } from '../services/vibeService';
import type { Track } from '../types';
// Track ids whose next natural feedback transition should be skipped because
// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for
// them explicitly. Consumed once, then cleared.
const suppressedFeedbackIds = new Set<string>();
export function suppressAutoFeedback(trackId: string): void {
suppressedFeedbackIds.add(trackId);
}
// Threshold (seconds) above which a store position change is treated as a user
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
// effect loop from fighting itself.
@@ -39,9 +48,6 @@ export const AudioEngine = () => {
const endedNaturallyRef = useRef(false);
// Track whether the current track has crossed the completion threshold.
const crossedThresholdRef = useRef(false);
// Track whether we've already recorded a completed play for the current track
// (to avoid double-recording when both threshold crossed AND ended fires).
const recordedCompletedRef = useRef(false);
// --- DOM -> store: media events -----------------------------------------
useEffect(() => {
@@ -73,16 +79,10 @@ export const AudioEngine = () => {
if (store().isPlaying) store().pause();
};
const onEnded = () => {
const trackId = loadedIdRef.current;
if (trackId && !recordedCompletedRef.current) {
endedNaturallyRef.current = true;
recordedCompletedRef.current = true;
try {
void vibeService.feedback(trackId, 'completed').catch(() => {});
} catch {
/* best-effort */
}
}
// Just flag it — applyTrack (below) is the single place that sends
// feedback, on the resulting track-change, so completion is recorded
// exactly once per track.
endedNaturallyRef.current = true;
store().next();
};
@@ -114,21 +114,23 @@ export const AudioEngine = () => {
// If it crossed the threshold OR ended naturally, record as completed.
const prevId = loadedIdRef.current;
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
if (prevId) {
try {
if (completed) {
recordedCompletedRef.current = true;
void vibeService.feedback(prevId, 'completed').catch(() => {});
} else {
void vibeService.feedback(prevId, 'skipped').catch(() => {});
// Only vibe sessions want this feedback — plain library browsing
// shouldn't write skip/completed evidence for tracks merely sampled.
const inVibeSession = !!useVibeStore.getState().activeSessionId;
if (prevId && inVibeSession) {
if (suppressedFeedbackIds.delete(prevId)) {
// Caller already recorded explicit feedback (e.g. dislike) for
// this track — don't also record the implicit transition.
} else {
try {
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {});
} catch {
/* best-effort */
}
} catch {
/* best-effort */
}
}
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
recordedCompletedRef.current = false;
loadedIdRef.current = id;
if (!id) {
+20 -7
View File
@@ -9,6 +9,8 @@ import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Track } from '../types';
import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore';
const INITIAL_BATCH_SIZE = 5;
const PREFETCH_THRESHOLD = 3;
@@ -19,7 +21,7 @@ function bestEffort(p: Promise<unknown>): void {
}
export default function Vibe() {
const { currentTrack, queue, setQueue, playTrack, next: playNext } = usePlaybackStore();
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const {
activeSessionId,
buffer,
@@ -117,23 +119,34 @@ export default function Vibe() {
if (idx > 0) {
setBuffer(buffer.slice(idx));
}
}, [activeSessionId, currentTrack]);
}, [activeSessionId, currentTrack, buffer, setBuffer]);
const handleKeep = useCallback(() => {
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
toast.success(`Kept "${currentTrack.title}"`);
}
}, [currentTrack]);
const handleDislike = useCallback(() => {
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
// AudioEngine would otherwise also record a 'skipped' on the track
// change caused by playNext() below — suppress that duplicate.
suppressAutoFeedback(currentTrack.id);
}
playNext();
}, [currentTrack, playNext]);
const handleEnd = useCallback(() => {
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
pause();
setQueue([]);
setCurrentTrack(null);
reset();
setEmpty(false);
setError(null);
}, [reset]);
}, [reset, pause, setQueue, setCurrentTrack]);
const upcoming = currentTrack
? (() => {
@@ -208,12 +221,12 @@ export default function Vibe() {
<div className="space-y-2">
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{libraryTracks.map((track) => (
{libraryTracks.map((track, index) => (
<li key={track.id}>
<TrackRow
track={track}
queue={libraryTracks}
index={libraryTracks.findIndex((t) => t.id === track.id)}
index={index}
showActions={false}
/>
</li>
+19 -10
View File
@@ -12,6 +12,8 @@ interface PlaybackState {
volume: number;
shuffle: boolean;
repeat: RepeatMode;
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
shufflePlayed: Set<string>;
setQueue: (queue: Track[]) => void;
playTrack: (track: Track) => void;
@@ -36,8 +38,9 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
volume: 1,
shuffle: false,
repeat: 'none',
shufflePlayed: new Set<string>(),
setQueue: (queue) => set({ queue }),
setQueue: (queue) => set({ queue, shufflePlayed: new Set() }),
playTrack: (track) =>
set({
@@ -45,13 +48,14 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
isPlaying: true,
position: 0,
duration: track.duration ?? 0,
shufflePlayed: new Set(),
}),
play: () => set({ isPlaying: true }),
pause: () => set({ isPlaying: false }),
next: () => {
const { queue, currentTrack, shuffle, repeat } = get();
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
if (queue.length === 0) return;
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
@@ -63,17 +67,22 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
}
if (shuffle) {
// Shuffle: pick a random track from the remaining queue (excluding current)
const remaining = queue.filter((t) => t.id !== currentTrack?.id);
// Shuffle: pick a random track not yet played this lap (excludes current),
// so repeat-all doesn't bounce between the same few tracks.
const played = new Set(shufflePlayed);
if (currentTrack) played.add(currentTrack.id);
let remaining = queue.filter((t) => !played.has(t.id));
if (remaining.length === 0) {
if (repeat === 'all') {
const pick = queue[Math.floor(Math.random() * queue.length)];
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
}
return;
if (repeat !== 'all') return;
// Lap complete — start a fresh one.
played.clear();
if (currentTrack) played.add(currentTrack.id);
remaining = queue.filter((t) => t.id !== currentTrack?.id);
if (remaining.length === 0) return;
}
const pick = remaining[Math.floor(Math.random() * remaining.length)];
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
played.add(pick.id);
set({ currentTrack: pick, shufflePlayed: played, position: 0, duration: pick.duration ?? 0, isPlaying: true });
return;
}