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 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-05 23:53:22 +04:00
parent 8f33744f8c
commit a7d126787f
9 changed files with 179 additions and 24 deletions
+4
View File
@@ -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();
@@ -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({
+20 -2
View File
@@ -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):
+8 -6
View File
@@ -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);
});
+53 -2
View File
@@ -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<string, unknown> | 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<VibePlan, 'items'> & {
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<Genre[]> {
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<string, unknown>;
}, client?: Queryable): Promise<string> {
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<string> {
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<string, unknown>;
profile?: {
goals: Record<string, unknown>;
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<string, unknown>;
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.
+27 -2
View File
@@ -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<Candidate[]> {
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]));
+9 -3
View File
@@ -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 () => {
@@ -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<string, unknown>;
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';
@@ -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<VibeSessionResponse> {
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);