From a7d126787f2e24993c1426095ac9e0947415b13d Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 5 Aug 2026 23:53:22 +0400 Subject: [PATCH] feat(vibe): weigh local calendar context in session planning The director had no idea what hour or season a session started in, so a 22:00 weeknight and a Sunday morning drew from the same pool. The client now sends localHour, weekday, month and an optional timeZone; the route validates and bounds all four, the coordinator threads them through, and the generators use them as scoring signals. Also exposes GET /library/stats, which the Home page counts read. Co-Authored-By: Claude Opus 5 --- backend/src/routes/library.routes.ts | 4 ++ .../src/routes/vibe-sessions.routes.test.ts | 20 +++++++ backend/src/routes/vibe-sessions.routes.ts | 22 +++++++- backend/src/services/db.service.test.ts | 14 +++-- backend/src/services/db.service.ts | 55 ++++++++++++++++++- backend/src/services/generators.service.ts | 29 +++++++++- backend/src/services/generators.test.ts | 12 +++- .../src/services/session-director.service.ts | 28 +++++++++- .../vibe-session-coordinator.service.ts | 19 +++++-- 9 files changed, 179 insertions(+), 24 deletions(-) diff --git a/backend/src/routes/library.routes.ts b/backend/src/routes/library.routes.ts index 8d018a7..6d0cf63 100644 --- a/backend/src/routes/library.routes.ts +++ b/backend/src/routes/library.routes.ts @@ -59,6 +59,10 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: { return album; }); + fastify.get('/library/stats', async () => { + return await dbService.getLibraryStats(); + }); + // Genres fastify.get('/genres', async () => { return await dbService.getGenres(); diff --git a/backend/src/routes/vibe-sessions.routes.test.ts b/backend/src/routes/vibe-sessions.routes.test.ts index ba3c0b4..5f9fc91 100644 --- a/backend/src/routes/vibe-sessions.routes.test.ts +++ b/backend/src/routes/vibe-sessions.routes.test.ts @@ -68,6 +68,26 @@ describe('durable Vibe session routes', () => { await app.close(); }); + it('accepts bounded local calendar context and rejects malformed context', async () => { + const { app, coordinator } = await appWithCoordinator(); + const valid = await app.inject({ + method: 'POST', url: '/v2/vibe/sessions', + payload: { context: { localHour: 22, weekday: 5, month: 8, timeZone: 'Europe/Samara' } }, + }); + const invalid = await app.inject({ + method: 'POST', url: '/v2/vibe/sessions', + payload: { context: { localHour: 24, weekday: 5, month: 8 } }, + }); + + expect(valid.statusCode).toBe(201); + expect(coordinator.start).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000', + expect.objectContaining({ context: { localHour: 22, weekday: 5, month: 8, timeZone: 'Europe/Samara' } }), + ); + expect(invalid.statusCode).toBe(400); + await app.close(); + }); + it('rejects server-only event types from the client event ledger', async () => { const { app, coordinator } = await appWithCoordinator(); const result = await app.inject({ diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts index f5bfef7..f876f6c 100644 --- a/backend/src/routes/vibe-sessions.routes.ts +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -6,6 +6,7 @@ import { VibeSessionNotFoundError, VibePlanNotFoundError, } from '../services/vibe-session-coordinator.service.js'; +import { VibeCalendarContext } from '../services/generators.service.js'; const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -40,6 +41,17 @@ function validOccurredAt(value: unknown): value is string { return typeof value === 'string' && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value)); } +function parseCalendarContext(value: unknown): VibeCalendarContext | undefined | null { + if (value === undefined) return undefined; + if (!isObject(value)) return null; + const { localHour, weekday, month, timeZone } = value; + if (typeof localHour !== 'number' || !Number.isInteger(localHour) || localHour < 0 || localHour > 23) return null; + if (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null; + if (typeof month !== 'number' || !Number.isInteger(month) || month < 1 || month > 12) return null; + if (timeZone !== undefined && (typeof timeZone !== 'string' || timeZone.length > 64)) return null; + return { localHour, weekday, month, ...(typeof timeZone === 'string' ? { timeZone } : {}) }; +} + function validationError(reply: Reply, message: string) { return reply.code(400).send({ error: message }); } @@ -50,13 +62,19 @@ function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null { } function parseStart(body: unknown): - | { seedTrackId?: string; resumeSessionId?: string } + | { seedTrackId?: string; resumeSessionId?: string; context?: VibeCalendarContext } | { error: string } { const input = isObject(body) ? body : {}; if (input.resumeSessionId !== undefined && !validUuid(input.resumeSessionId)) return { error: 'resumeSessionId must be a UUID' }; if (input.seedTrackId !== undefined && !validUuid(input.seedTrackId)) return { error: 'seedTrackId must be a UUID' }; if (input.resumeSessionId !== undefined && input.seedTrackId !== undefined) return { error: 'resumeSessionId cannot be combined with seedTrackId' }; - return { seedTrackId: input.seedTrackId as string | undefined, resumeSessionId: input.resumeSessionId as string | undefined }; + const context = parseCalendarContext(input.context); + if (context === null) return { error: 'context must contain valid localHour, weekday, month, and optional timeZone' }; + return { + seedTrackId: input.seedTrackId as string | undefined, + resumeSessionId: input.resumeSessionId as string | undefined, + ...(context ? { context } : {}), + }; } function parseEvent(body: unknown): diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index 174227d..5b08a36 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -30,6 +30,7 @@ describe('DbService v2 methods', () => { .mockResolvedValueOnce({ rows: [] }) // BEGIN .mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill .mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker + .mockResolvedValueOnce({ rows: [{ context: {} }] }) // no calendar context on legacy session .mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity .mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence .mockResolvedValueOnce({ rowCount: 1 }) // discovery belief @@ -51,12 +52,13 @@ describe('DbService v2 methods', () => { expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles'); expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections'); - expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history'); - expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence'); - expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient'); - expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END'); - expect(clientQuery.mock.calls[8][0]).toContain('ELSE $3::real END'); - expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 })); + expect(clientQuery.mock.calls[3][0]).toContain('SELECT context FROM vibe_sessions'); + expect(clientQuery.mock.calls[4][0]).toContain('EXISTS (SELECT 1 FROM play_history'); + expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO evidence'); + expect(clientQuery.mock.calls[9][0]).toContain('exploration_coefficient'); + expect(clientQuery.mock.calls[9][0]).toContain('ELSE goals END'); + expect(clientQuery.mock.calls[9][0]).toContain('ELSE $3::real END'); + expect(clientQuery.mock.calls[10][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 })); expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1); }); diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index d76e3df..46fb3a0 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -10,6 +10,19 @@ import { SearchService } from './search.service.js'; /** Anything with a `.query()` — either the shared Pool or a checked-out client. */ type Queryable = Pool | PoolClient; +function calendarContextKey(context: Record | null | undefined): string | null { + const hour = context?.localHour; + const weekday = context?.weekday; + const month = context?.month; + if (typeof hour !== 'number' || typeof weekday !== 'number' || typeof month !== 'number' + || !Number.isInteger(hour) || !Number.isInteger(weekday) || !Number.isInteger(month) + || hour < 0 || hour > 23 || weekday < 0 || weekday > 6 || month < 1 || month > 12) return null; + const daypart = hour < 6 ? 'night' : hour < 12 ? 'morning' : hour < 18 ? 'day' : 'evening'; + const dayType = weekday === 0 || weekday === 6 ? 'weekend' : 'weekday'; + const season = month === 12 || month <= 2 ? 'winter' : month <= 5 ? 'spring' : month <= 8 ? 'summer' : 'autumn'; + return `calendar:${daypart}:${dayType}:${season}`; +} + type VibePlanVersionRow = Omit & { item_plan_version_id: string | null; ordinal: number | null; @@ -305,6 +318,21 @@ export class DbService { }; } + /** + * Library totals for the Home header. Counts only what library views show, + * so the number on screen matches what a user can actually browse. + */ + async getLibraryStats(): Promise<{ tracks: number; albums: number; artists: number; duration: number }> { + const res = await this.pgClient.query( + `SELECT + (SELECT COUNT(*)::int FROM tracks WHERE state NOT IN ('HIDDEN','DELETED')) AS tracks, + (SELECT COUNT(*)::int FROM albums) AS albums, + (SELECT COUNT(*)::int FROM artists) AS artists, + (SELECT COALESCE(SUM(duration),0)::int FROM tracks WHERE state NOT IN ('HIDDEN','DELETED')) AS duration` + ); + return res.rows[0]; + } + async getGenres(): Promise { const res = await this.pgClient.query( `SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count @@ -1210,6 +1238,7 @@ export class DbService { signal: string; profile: string; weight: number; + dimension?: string; context?: Record; }, client?: Queryable): Promise { const { track_id: trackId, ...event } = evidence; @@ -1227,6 +1256,7 @@ export class DbService { signal: event.signal, profile: event.profile, weight: event.weight * target.factor, + dimension: event.dimension, context: { ...event.context, ...target.context }, }, client); } @@ -1243,6 +1273,7 @@ export class DbService { signal: string; profile: string; weight: number; + dimension?: string; context?: unknown; }, client?: Queryable): Promise { const res = await (client ?? this.pgClient).query( @@ -1265,7 +1296,7 @@ export class DbService { // which feeds 'novelty_tolerance'. Each new evidence row must also // upsert the matching listener_belief (spec §B.4) — otherwise evidence // accumulates but beliefs never materialise. - const dimension = this.beliefDimensionForSignal(evidence.signal); + const dimension = evidence.dimension ?? this.beliefDimensionForSignal(evidence.signal); await this.updateListenerBelief({ user_id: evidence.user_id, profile: evidence.profile, @@ -1514,6 +1545,7 @@ export class DbService { userId: string; policyVersion: string; seedTrackId?: string | null; + context?: Record; profile?: { goals: Record; explorationCoefficient: number; @@ -1561,7 +1593,7 @@ export class DbService { [ params.userId, params.seedTrackId ?? null, - '{}', + JSON.stringify(params.context ? { ...params.context } : {}), params.policyVersion, JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }), params.profile?.explorationCoefficient ?? 0.3, @@ -1635,6 +1667,25 @@ export class DbService { ); if (!marker.rows[0]) return; + const sessionContext = await client.query( + 'SELECT context FROM vibe_sessions WHERE id = $1 AND user_id = $2', + [event.session_id, event.user_id], + ); + const context = (sessionContext.rows[0]?.context ?? {}) as Record; + const contextDimension = calendarContextKey(context); + if (contextDimension) { + const contextWeight = event.type === 'skipped' ? -0.16 : event.type === 'completed' ? 0.08 : 0.04; + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: trackId, + signal: event.type === 'skipped' ? 'skip_quick' : event.type === 'kept' ? 'kept' : 'playback_completed', + profile: 'contextual', + weight: contextWeight, + dimension: contextDimension, + context: { vibe_event_id: event.id, session_id: event.session_id, calendar: context }, + }, client); + } + // This query occurs before a completed event's play_history projection // can be considered. Favourites and prior evidence count as familiarity // too, avoiding a false “new discovery” on a locally known track. diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 6e18b2e..40e2d92 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -15,6 +15,27 @@ export interface ClaimEdge { fusedValue: number; } +export interface VibeCalendarContext { + localHour: number; + weekday: number; + month: number; + timeZone?: string; +} + +/** A bounded key for short-lived, calendar-specific preference beliefs. */ +export function calendarContextKey(context: VibeCalendarContext): string { + const daypart = context.localHour < 6 ? 'night' + : context.localHour < 12 ? 'morning' + : context.localHour < 18 ? 'day' + : 'evening'; + const dayType = context.weekday === 0 || context.weekday === 6 ? 'weekend' : 'weekday'; + const season = context.month === 12 || context.month <= 2 ? 'winter' + : context.month <= 5 ? 'spring' + : context.month <= 8 ? 'summer' + : 'autumn'; + return `calendar:${daypart}:${dayType}:${season}`; +} + export interface Candidate { trackId: string; generatorId: string; @@ -47,7 +68,7 @@ export interface GeneratorContext { energy: number; lastArtistIds: string[]; lastGenreIds: string[]; - context: string | null; + context: VibeCalendarContext | null; noveltyHunger: number; /** Session-local exploration controls; they never overwrite durable taste. */ explorationCoefficient?: number; @@ -487,6 +508,8 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise { if (!ctx.state.context) return []; + const contextKey = calendarContextKey(ctx.state.context); + const contextualBeliefs = await db.getListenerBeliefs({ userId: ctx.userId, profile: 'contextual', @@ -495,7 +518,9 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis order: 'DESC', }); - const targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2); + const targetBeliefs = contextualBeliefs.filter(b => + b.entity_type === 'artist' && b.dimension === contextKey && b.value > 0.2, + ); const targetArtistIds = targetBeliefs.map(b => b.entity_id); const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value])); diff --git a/backend/src/services/generators.test.ts b/backend/src/services/generators.test.ts index 3536d69..2594b8c 100644 --- a/backend/src/services/generators.test.ts +++ b/backend/src/services/generators.test.ts @@ -182,13 +182,19 @@ describe('generators', () => { describe('contextual', () => { it('returns tracks matching context when set', async () => { - const db = makeMockDb(); + const db = makeMockDb({ + getListenerBeliefs: vi.fn().mockResolvedValue([ + { entity_type: 'artist', entity_id: 'artist-1', value: 0.7, profile: 'contextual', dimension: 'calendar:day:weekday:summer' }, + { entity_type: 'artist', entity_id: 'artist-2', value: 0.9, profile: 'contextual', dimension: 'calendar:night:weekend:summer' }, + ]), + }); (db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] }); const ctx = makeCtx({ - state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: 'coding', noveltyHunger: 0.3, sessionAgeMin: 10 }, + state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: { localHour: 12, weekday: 1, month: 6 }, noveltyHunger: 0.3, sessionAgeMin: 10 }, }); const results = await generatorByName.contextual(db, ctx); - expect(results).toBeDefined(); + expect(results).toHaveLength(1); + expect((db.pgClient.query as any).mock.calls[0][1][0]).toEqual(['artist-1']); }); it('returns empty when no context set', async () => { diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index 5900190..8eb3e29 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -1,5 +1,5 @@ import { DbService, ListenerBelief } from './db.service.js'; -import { Candidate, GeneratorContext, Generator, ALL_GENERATORS } from './generators.service.js'; +import { Candidate, GeneratorContext, Generator, ALL_GENERATORS, VibeCalendarContext } from './generators.service.js'; import { AUDIO_PREFERENCE_BUCKETS } from '../db/types.js'; export interface FatigueState { @@ -11,6 +11,25 @@ export interface FatigueState { vocal: number; } +function parseCalendarContext(value: string | null): VibeCalendarContext | null { + if (!value) return null; + try { + const context = JSON.parse(value) as Record; + if (!Number.isInteger(context.localHour) || !Number.isInteger(context.weekday) || !Number.isInteger(context.month) + || (context.localHour as number) < 0 || (context.localHour as number) > 23 + || (context.weekday as number) < 0 || (context.weekday as number) > 6 + || (context.month as number) < 1 || (context.month as number) > 12) return null; + return { + localHour: context.localHour as number, + weekday: context.weekday as number, + month: context.month as number, + ...(typeof context.timeZone === 'string' ? { timeZone: context.timeZone } : {}), + }; + } catch { + return null; + } +} + export interface RecentPlay { trackId: string; artistId: string | null; @@ -754,7 +773,7 @@ export class SessionDirector { energy: (row.state_vector?.energy as number) ?? 0.5, lastArtistIds: (row.state_vector?.lastArtistIds as string[]) ?? [], lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [], - context: row.context, + context: parseCalendarContext(row.context), noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3, explorationCoefficient: (row.state_vector?.explorationCoefficient as number) ?? 0.3, discoveryRadius: (row.state_vector?.discoveryRadius as number) ?? 0.38, @@ -773,7 +792,7 @@ export class SessionDirector { energy: (latest.state_vector?.energy as number) ?? 0.5, lastArtistIds: (latest.state_vector?.lastArtistIds as string[]) ?? [], lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [], - context: latest.context, + context: parseCalendarContext(latest.context), noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3, explorationCoefficient: (latest.state_vector?.explorationCoefficient as number) ?? 0.3, discoveryRadius: (latest.state_vector?.discoveryRadius as number) ?? 0.38, @@ -1091,6 +1110,9 @@ export class SessionDirector { if (goal.type === 'familiar') return 'comfort'; if (goal.type === 'discovery' || goal.type === 'artist_introduction') return 'discovery'; } + // Calendar time is a gentle tie-breaker: it only selects the quiet arc + // when no explicit listening goal has priority over it. + if (state.context && state.context.localHour < 6) return 'late-night'; if (state.energy < 0.3) return 'late-night'; if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery'; if (state.energy > 0.6) return 'energetic'; diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index 72b1ff5..f01bd81 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -1,6 +1,6 @@ import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; import { SessionDirector } from './session-director.service.js'; -import { Candidate } from './generators.service.js'; +import { Candidate, VibeCalendarContext } from './generators.service.js'; /** * This is deliberately a narrow bridge between the durable Vibe ledger and @@ -22,6 +22,7 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number]; export interface StartVibeSessionInput { seedTrackId?: string; resumeSessionId?: string; + context?: VibeCalendarContext; } export interface AppendVibeEventInput { @@ -70,10 +71,15 @@ export class VibeSessionCoordinator { async start(userId: string, input: StartVibeSessionInput): Promise { if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId); - // Vibe has no reliable device/activity/location signal. Start from neutral - // recommendation state and let actual listening behaviour shape the plan. + // Calendar context is a weak boot prior, never a substitute for listening + // feedback or long-term preference. It is intentionally coarse and is + // persisted with the session so a resume remains coherent. + const calendar = input.context; + const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32 + : calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58 + : 0.5; const initialState = { - energy: 0.5, + energy: contextualEnergy, noveltyHunger: 0.3, explorationCoefficient: 0.3, discoveryRadius: 0.38, @@ -83,6 +89,7 @@ export class VibeSessionCoordinator { userId, policyVersion: DEFAULT_VIBE_POLICY_VERSION, seedTrackId: input.seedTrackId ?? null, + context: calendar ? { ...calendar } : undefined, profile: { goals: initialState.sessionGoal, explorationCoefficient: initialState.explorationCoefficient, @@ -95,7 +102,7 @@ export class VibeSessionCoordinator { // session while the durable tables remain the source of truth. await this.db.createSessionState( userId, - undefined, + calendar ? JSON.stringify(calendar) : undefined, { energy: initialState.energy, noveltyHunger: initialState.noveltyHunger, @@ -109,7 +116,7 @@ export class VibeSessionCoordinator { sessionId: session.id, userId, type: 'session_started', - payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION }, + payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) }, }); const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);