feat(vibe): adapt sessions to context and exploration
This commit is contained in:
@@ -43,3 +43,19 @@ describe('Vibe durable session migration', () => {
|
||||
expect(migration!.sql).toContain('idx_vibe_events_user_occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vibe context memory migration', () => {
|
||||
it('adds bounded session exploration state and exactly-once projection storage', () => {
|
||||
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_context_memory_exploration');
|
||||
expect(migration?.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_session_profiles');
|
||||
expect(migration?.sql).toContain('exploration_coefficient');
|
||||
expect(migration?.sql).toContain('vibe_session_feedback_projections');
|
||||
});
|
||||
|
||||
it('backfills profiles for durable sessions created before context memory', () => {
|
||||
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_session_profile_backfill');
|
||||
expect(migration?.sql).toContain('INSERT INTO vibe_session_profiles');
|
||||
expect(migration?.sql).toContain('SELECT id, user_id FROM vibe_sessions');
|
||||
expect(migration?.sql).toContain('ON CONFLICT (session_id) DO NOTHING');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -720,4 +720,43 @@ export const MIGRATIONS: Migration[] = [
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Session-specific exploration, goals, and deliberately lossy session
|
||||
// fingerprints are derived from the immutable Vibe ledger. Keeping them
|
||||
// separate from listener_beliefs prevents a transient session from
|
||||
// rewriting permanent taste.
|
||||
id: '20260802_vibe_context_memory_exploration',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
||||
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
||||
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
||||
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
||||
discovery_radius REAL NOT NULL DEFAULT 0.38
|
||||
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
||||
ON vibe_session_profiles (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// The profile table was introduced after durable sessions. Backfill every
|
||||
// pre-existing session before feedback can claim its exactly-once marker;
|
||||
// newly created sessions receive their context-derived initial goals in
|
||||
// createVibeSession's transaction.
|
||||
id: '20260802_vibe_session_profile_backfill',
|
||||
sql: `
|
||||
INSERT INTO vibe_session_profiles (session_id, user_id)
|
||||
SELECT id, user_id FROM vibe_sessions
|
||||
ON CONFLICT (session_id) DO NOTHING;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -543,6 +543,23 @@ CREATE TABLE IF NOT EXISTS vibe_sessions (
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
|
||||
ON vibe_sessions (user_id, last_event_at DESC);
|
||||
|
||||
-- Compact, derived memory for the session director. Fingerprints are a
|
||||
-- deliberately lossy description of session shape (not a track list) and are
|
||||
-- used only as a soft freshness signal against recent sessions.
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
||||
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
||||
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
||||
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
||||
discovery_radius REAL NOT NULL DEFAULT 0.38
|
||||
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
||||
ON vibe_session_profiles (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_event_id UUID,
|
||||
@@ -570,6 +587,14 @@ CREATE TABLE IF NOT EXISTS vibe_event_projections (
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Separate from listener-belief projection because exploration is session
|
||||
-- state. It lets a failed post-event replan safely retry the exact same
|
||||
-- adaptation without counting the feedback twice.
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -258,3 +258,16 @@ export interface VibePlanItem {
|
||||
export interface VibePlan extends VibePlanVersion {
|
||||
items: VibePlanItem[];
|
||||
}
|
||||
|
||||
/** Derived session-director memory. The ledger remains authoritative; this
|
||||
* compact row makes fingerprints, bounded goals, and exploration state cheap
|
||||
* to read while planning. */
|
||||
export interface VibeSessionProfile {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
fingerprint: Record<string, unknown>;
|
||||
goals: Record<string, unknown>;
|
||||
exploration_coefficient: number;
|
||||
discovery_radius: number;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
VibeSessionNotFoundError,
|
||||
VibePlanNotFoundError,
|
||||
} from '../services/vibe-session-coordinator.service.js';
|
||||
import { isValidVibeContext } from '../services/vibe-context.service.js';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -56,6 +57,9 @@ export default async function vibeSessionsRoutes(
|
||||
if (body.context !== undefined && !isObject(body.context)) {
|
||||
return reply.code(400).send({ error: 'context must be an object' });
|
||||
}
|
||||
if (isObject(body.context) && !isValidVibeContext(body.context)) {
|
||||
return reply.code(400).send({ error: 'context contains an invalid structured Vibe value' });
|
||||
}
|
||||
if (body.intent !== undefined && typeof body.intent !== 'string') {
|
||||
return reply.code(400).send({ error: 'intent must be a string' });
|
||||
}
|
||||
@@ -125,6 +129,11 @@ export default async function vibeSessionsRoutes(
|
||||
if (body.payload !== undefined && !isObject(body.payload)) {
|
||||
return reply.code(400).send({ error: 'payload must be an object' });
|
||||
}
|
||||
if (body.type === 'context_changed' && isObject(body.payload)
|
||||
&& body.payload.context !== undefined
|
||||
&& (!isObject(body.payload.context) || !isValidVibeContext(body.payload.context))) {
|
||||
return reply.code(400).send({ error: 'context_changed payload.context must be structured Vibe context' });
|
||||
}
|
||||
try {
|
||||
return reply.send(await coordinator.appendEvent(userId, sessionId, {
|
||||
eventId: body.eventId as string | undefined,
|
||||
|
||||
@@ -19,6 +19,46 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType
|
||||
|
||||
describe('DbService v2 methods', () => {
|
||||
describe('durable Vibe sessions', () => {
|
||||
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
|
||||
type: 'completed', occurred_at: new Date(), client_event_id: null,
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
} as any;
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
|
||||
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
|
||||
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
|
||||
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
|
||||
.mockResolvedValueOnce({ rows: [] }) // no audio targets
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
exploration_coefficient: 0.36, discovery_radius: 0.434,
|
||||
goals: { type: 'familiar', target: 1, progress: 1 },
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // session_state projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // marker conflict
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
|
||||
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
|
||||
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[9][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);
|
||||
});
|
||||
|
||||
it('creates, reads, and ends sessions scoped to their user', async () => {
|
||||
const { service, poolQuery, clientQuery } = makeTransactionalService();
|
||||
const session = {
|
||||
@@ -44,12 +84,59 @@ describe('DbService v2 methods', () => {
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']);
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
|
||||
'user-1', null, expect.any(String), 'v2.1',
|
||||
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]));
|
||||
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual(expect.objectContaining({
|
||||
activity: 'focus',
|
||||
}));
|
||||
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
|
||||
});
|
||||
|
||||
it('normalizes context at the persistence boundary for direct callers', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-02T19:00:00.000Z'));
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
try {
|
||||
await service.createVibeSession({
|
||||
userId: 'user-1',
|
||||
policyVersion: 'v2.1',
|
||||
context: {
|
||||
timeZone: 'UTC',
|
||||
activity: 'walking',
|
||||
device: 'phone',
|
||||
exactCoordinates: '53.1959,50.1002',
|
||||
browserTelemetry: { batteryPercent: 4, ipAddress: '192.0.2.1' },
|
||||
localHour: 3,
|
||||
weekday: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const insertParameters = clientQuery.mock.calls[3][1];
|
||||
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
|
||||
expect(JSON.parse(insertParameters[2])).toEqual({
|
||||
timeZone: 'UTC', localHour: 19, weekday: 0, dayKind: 'weekend',
|
||||
activity: 'walking', device: 'phone',
|
||||
});
|
||||
expect(insertParameters.slice(3)).toEqual([
|
||||
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces an owned active session and writes its terminal event before starting another', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
|
||||
@@ -102,6 +189,63 @@ describe('DbService v2 methods', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes and projects an inserted context change atomically with its ledger event', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { context: { activity: 'walking', localHour: 12 } },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [event] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }) // session context projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // legacy session-state projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', type: 'context_changed',
|
||||
payload: {
|
||||
context: { activity: 'walking', exactCoordinates: '53.2,50.1' },
|
||||
rawBrowserTelemetry: { battery: 4 },
|
||||
},
|
||||
});
|
||||
|
||||
const values = clientQuery.mock.calls[2][1];
|
||||
const storedPayload = JSON.parse(values[8]);
|
||||
expect(storedPayload).toEqual({ context: expect.objectContaining({ activity: 'walking' }) });
|
||||
expect(storedPayload.context).not.toHaveProperty('exactCoordinates');
|
||||
expect(storedPayload).not.toHaveProperty('rawBrowserTelemetry');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('SET context = $3::jsonb');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("jsonb_build_object('context'");
|
||||
});
|
||||
|
||||
it('does not apply a retry body to an existing context-change event', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const canonicalEvent = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { context: { activity: 'focus', localHour: 12 } },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'context_changed',
|
||||
payload: { context: { activity: 'workout', exactCoordinates: '53.2,50.1' } },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ event: canonicalEvent, inserted: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
|
||||
expect.stringContaining('SET context = $3::jsonb'),
|
||||
);
|
||||
});
|
||||
|
||||
it('projects material feedback once with the durable event transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { SearchService } from './search.service.js';
|
||||
import { normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js';
|
||||
|
||||
/** Anything with a `.query()` — either the shared Pool or a checked-out client. */
|
||||
type Queryable = Pool | PoolClient;
|
||||
@@ -51,6 +52,7 @@ import type {
|
||||
RecordedVibeEvent,
|
||||
VibePlan,
|
||||
VibePlanItem,
|
||||
VibeSessionProfile,
|
||||
} from '../db/types.js';
|
||||
import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js';
|
||||
export * from '../db/types.js';
|
||||
@@ -1514,7 +1516,17 @@ export class DbService {
|
||||
policyVersion: string;
|
||||
seedTrackId?: string | null;
|
||||
context?: Record<string, unknown>;
|
||||
profile?: {
|
||||
goals: Record<string, unknown>;
|
||||
explorationCoefficient: number;
|
||||
discoveryRadius: number;
|
||||
};
|
||||
}): Promise<VibeSession> {
|
||||
// DbService is also used directly by workers and migrations. Keep the
|
||||
// durable storage boundary canonical even when callers bypass the HTTP
|
||||
// coordinator, so opaque or precise client telemetry can never become
|
||||
// session context.
|
||||
const canonicalContext = normalizeVibeContext(params.context ?? {});
|
||||
return this.withTransaction(async (client) => {
|
||||
// Serialize starts for one listener even when there is no active row to
|
||||
// lock yet. The row lock below then safely replaces any prior session.
|
||||
@@ -1542,14 +1554,25 @@ export class DbService {
|
||||
}
|
||||
}
|
||||
const res = await client.query(
|
||||
`INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version)
|
||||
VALUES ($1, 'active', $2, $3::jsonb, $4)
|
||||
RETURNING *`,
|
||||
`WITH created AS (
|
||||
INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version)
|
||||
VALUES ($1, 'active', $2, $3::jsonb, $4)
|
||||
RETURNING *
|
||||
), profile AS (
|
||||
INSERT INTO vibe_session_profiles
|
||||
(session_id, user_id, goals, exploration_coefficient, discovery_radius)
|
||||
SELECT id, user_id, $5::jsonb, $6::real, $7::real FROM created
|
||||
ON CONFLICT (session_id) DO NOTHING
|
||||
)
|
||||
SELECT * FROM created`,
|
||||
[
|
||||
params.userId,
|
||||
params.seedTrackId ?? null,
|
||||
JSON.stringify(params.context ?? {}),
|
||||
JSON.stringify(canonicalContext),
|
||||
params.policyVersion,
|
||||
JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }),
|
||||
params.profile?.explorationCoefficient ?? 0.3,
|
||||
params.profile?.discoveryRadius ?? 0.38,
|
||||
],
|
||||
);
|
||||
return res.rows[0] as VibeSession;
|
||||
@@ -1565,6 +1588,141 @@ export class DbService {
|
||||
return (res.rows[0] as VibeSession) ?? null;
|
||||
}
|
||||
|
||||
/** Recent session shapes, excluding the active session. The fingerprint is
|
||||
* intentionally aggregate-only and is used as a soft planning penalty. */
|
||||
async getRecentVibeSessionFingerprints(userId: string, sessionId: string, limit = 8): Promise<Record<string, unknown>[]> {
|
||||
const res = await this.pgClient.query(
|
||||
`SELECT p.fingerprint
|
||||
FROM vibe_session_profiles p
|
||||
JOIN vibe_sessions s ON s.id = p.session_id
|
||||
WHERE p.user_id = $1 AND p.session_id <> $2::uuid
|
||||
AND s.status IN ('ended', 'expired', 'replaced')
|
||||
AND p.fingerprint <> '{}'::jsonb
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT $3`,
|
||||
[userId, sessionId, limit],
|
||||
);
|
||||
return res.rows.map((row: { fingerprint: Record<string, unknown> }) => row.fingerprint ?? {});
|
||||
}
|
||||
|
||||
async getVibeSessionProfile(sessionId: string, userId: string): Promise<VibeSessionProfile | null> {
|
||||
const res = await this.pgClient.query(
|
||||
`SELECT p.* FROM vibe_session_profiles p
|
||||
JOIN vibe_sessions s ON s.id = p.session_id
|
||||
WHERE p.session_id = $1 AND s.user_id = $2`,
|
||||
[sessionId, userId],
|
||||
);
|
||||
return (res.rows[0] as VibeSessionProfile | undefined) ?? null;
|
||||
}
|
||||
|
||||
/** Replace only the coarse, sanitised context attached to an active session.
|
||||
* The immutable context_changed event remains the audit trail. */
|
||||
async updateVibeSessionContext(sessionId: string, userId: string, context: Record<string, unknown>): Promise<void> {
|
||||
const canonicalContext = normalizeVibeContext(context);
|
||||
await this.withTransaction(async client => {
|
||||
const updated = await client.query(
|
||||
`UPDATE vibe_sessions SET context = $3::jsonb, last_event_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'active'
|
||||
RETURNING id`,
|
||||
[sessionId, userId, JSON.stringify(canonicalContext)],
|
||||
);
|
||||
if (!updated.rows[0]) throw new Error('Vibe session was not found or is not owned by this user');
|
||||
await client.query(
|
||||
`UPDATE session_state
|
||||
SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'),
|
||||
state_vector = state_vector || jsonb_build_object('context', $3::jsonb),
|
||||
last_interaction = NOW()
|
||||
WHERE session_id = $1 AND user_id = $2`,
|
||||
[sessionId, userId, JSON.stringify(canonicalContext)],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Project unknown-track feedback into the session exploration controls.
|
||||
* This deliberately runs behind its own projection marker: the immutable
|
||||
* event has already committed, so retries after a transient failure are
|
||||
* safe and converge on one evidence row and one coefficient adjustment.
|
||||
*/
|
||||
async projectVibeSessionFeedback(event: VibeEvent): Promise<void> {
|
||||
if (!event.track_id || !['completed', 'skipped', 'kept'].includes(event.type)) return;
|
||||
const trackId = event.track_id;
|
||||
await this.withTransaction(async client => {
|
||||
// Old active/resumable sessions predate vibe_session_profiles. Create a
|
||||
// neutral profile before claiming the exactly-once marker: otherwise the
|
||||
// marker could permanently consume a feedback event without adapting its
|
||||
// session. Existing goals are deliberately never overwritten here.
|
||||
await client.query(
|
||||
`INSERT INTO vibe_session_profiles (session_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (session_id) DO NOTHING`,
|
||||
[event.session_id, event.user_id],
|
||||
);
|
||||
const marker = await client.query(
|
||||
`INSERT INTO vibe_session_feedback_projections (event_id)
|
||||
VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`,
|
||||
[event.id],
|
||||
);
|
||||
if (!marker.rows[0]) return;
|
||||
|
||||
// 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.
|
||||
const familiarity = await client.query(
|
||||
`SELECT (
|
||||
EXISTS (SELECT 1 FROM play_history WHERE user_id = $1 AND track_id = $2 AND completed = true AND played_at < $3::timestamptz)
|
||||
OR EXISTS (SELECT 1 FROM favorites WHERE user_id = $1 AND track_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM evidence WHERE user_id = $1 AND entity_type = 'track' AND entity_id = $2 AND created_at < $3::timestamptz)
|
||||
) AS familiar`,
|
||||
[event.user_id, trackId, event.occurred_at],
|
||||
);
|
||||
const familiar = Boolean(familiarity.rows[0]?.familiar);
|
||||
|
||||
const delta = event.type === 'skipped' ? -0.08 : event.type === 'completed' ? 0.06 : 0.03;
|
||||
const signal = event.type === 'skipped' ? 'skip_quick' : 'play_of_never_seen';
|
||||
const weight = event.type === 'skipped' ? -0.05 : delta;
|
||||
if (!familiar) {
|
||||
await this.recordTrackEvidence({
|
||||
user_id: event.user_id,
|
||||
track_id: trackId,
|
||||
signal,
|
||||
profile: event.type === 'skipped' ? 'negative' : 'discovery',
|
||||
weight,
|
||||
context: { vibe_event_id: event.id, session_id: event.session_id, unfamiliar: true },
|
||||
}, client);
|
||||
}
|
||||
|
||||
const profile = await client.query(
|
||||
`UPDATE vibe_session_profiles
|
||||
SET exploration_coefficient = GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)),
|
||||
discovery_radius = GREATEST(0.15, LEAST(0.9, 0.2 + (GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)) * 0.65))),
|
||||
goals = CASE
|
||||
WHEN goals->>'type' = 'familiar' AND $4 AND $5
|
||||
THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1))))
|
||||
WHEN goals->>'type' IN ('discovery', 'surprise', 'artist_introduction') AND NOT $4 AND $3 > 0
|
||||
THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1))))
|
||||
ELSE goals END,
|
||||
updated_at = NOW()
|
||||
WHERE session_id = $1 AND user_id = $2
|
||||
RETURNING exploration_coefficient, discovery_radius, goals`,
|
||||
[event.session_id, event.user_id, delta, familiar, event.type === 'completed' || event.type === 'kept'],
|
||||
);
|
||||
const row = profile.rows[0] as Pick<VibeSessionProfile, 'exploration_coefficient' | 'discovery_radius' | 'goals'> | undefined;
|
||||
if (row) {
|
||||
await client.query(
|
||||
`UPDATE session_state
|
||||
SET state_vector = state_vector || jsonb_build_object(
|
||||
'explorationCoefficient', $3::real,
|
||||
'discoveryRadius', $4::real,
|
||||
'sessionGoal', $5::jsonb
|
||||
), last_interaction = NOW()
|
||||
WHERE session_id = $1 AND user_id = $2`,
|
||||
[event.session_id, event.user_id, row.exploration_coefficient, row.discovery_radius, JSON.stringify(row.goals)],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End (or expire/replace) a session without changing its original end time
|
||||
* when a client retries the same request.
|
||||
@@ -1667,6 +1825,10 @@ export class DbService {
|
||||
durationMs?: number | null;
|
||||
payload?: Record<string, unknown>;
|
||||
}): Promise<RecordedVibeEvent> {
|
||||
// This service is also called by jobs and tests which bypass the HTTP
|
||||
// route. Preserve the context privacy boundary at the final point before
|
||||
// an immutable ledger write.
|
||||
const payload = normalizeVibeEventPayload(params.type, params.payload);
|
||||
return this.withTransaction(async (client) => {
|
||||
// A session-row lock serializes both event writes and terminal state
|
||||
// transitions. In particular, it avoids the READ COMMITTED CTE snapshot
|
||||
@@ -1718,7 +1880,7 @@ export class DbService {
|
||||
occurredAt,
|
||||
params.positionMs ?? null,
|
||||
params.durationMs ?? null,
|
||||
JSON.stringify(params.payload ?? {}),
|
||||
JSON.stringify(payload ?? {}),
|
||||
]
|
||||
);
|
||||
const event = insertRes.rows[0] as VibeEvent | undefined;
|
||||
@@ -1727,6 +1889,7 @@ export class DbService {
|
||||
}
|
||||
|
||||
await this.projectVibeFeedback(event, client);
|
||||
await this.projectVibeContextChanged(event, client);
|
||||
|
||||
await client.query(
|
||||
`UPDATE vibe_sessions
|
||||
@@ -1738,6 +1901,30 @@ export class DbService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply the context projection in the same transaction as its *inserted*
|
||||
* ledger event. A client-event retry returns before this method, so its body
|
||||
* can never overwrite session state with a different context. */
|
||||
private async projectVibeContextChanged(event: VibeEvent, client: PoolClient): Promise<void> {
|
||||
if (event.type !== 'context_changed') return;
|
||||
const context = event.payload?.context;
|
||||
if (!context || typeof context !== 'object' || Array.isArray(context)) return;
|
||||
const canonicalContext = context as Record<string, unknown>;
|
||||
await client.query(
|
||||
`UPDATE vibe_sessions
|
||||
SET context = $3::jsonb
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'active'`,
|
||||
[event.session_id, event.user_id, JSON.stringify(canonicalContext)],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE session_state
|
||||
SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'),
|
||||
state_vector = state_vector || jsonb_build_object('context', $3::jsonb),
|
||||
last_interaction = NOW()
|
||||
WHERE session_id = $1 AND user_id = $2`,
|
||||
[event.session_id, event.user_id, JSON.stringify(canonicalContext)],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize Vibe feedback into the listener inputs used by the incumbent
|
||||
* director. The projection marker and every write share the event's
|
||||
@@ -1899,6 +2086,32 @@ export class DbService {
|
||||
feedbackEventId: params.objectiveSnapshot.feedbackEventId ?? null,
|
||||
})],
|
||||
);
|
||||
// Store a compact session shape rather than a replayable queue. It is
|
||||
// overwritten on each revision so a recently adapted session represents
|
||||
// its current direction when tomorrow's session asks for freshness.
|
||||
await client.query(
|
||||
`WITH selected AS (
|
||||
SELECT i.track_id, i.candidate_source
|
||||
FROM vibe_plan_items i WHERE i.plan_version_id = $1
|
||||
), artists AS (
|
||||
SELECT DISTINCT ta.artist_id::text AS value FROM selected s
|
||||
JOIN track_artists_v2 ta ON ta.track_id = s.track_id AND ta.role = 'main'
|
||||
), genres AS (
|
||||
SELECT DISTINCT tg.genre_id::text AS value FROM selected s
|
||||
JOIN track_genre tg ON tg.track_id = s.track_id
|
||||
), sources AS (
|
||||
SELECT DISTINCT candidate_source AS value FROM selected
|
||||
)
|
||||
UPDATE vibe_session_profiles
|
||||
SET fingerprint = jsonb_build_object(
|
||||
'artists', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM artists), '[]'::jsonb),
|
||||
'genres', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM genres), '[]'::jsonb),
|
||||
'sources', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM sources), '[]'::jsonb),
|
||||
'context', (SELECT context FROM vibe_sessions WHERE id = $2)
|
||||
), updated_at = NOW()
|
||||
WHERE session_id = $2 AND user_id = $3`,
|
||||
[planVersion.id, params.sessionId, params.userId],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`,
|
||||
[params.sessionId],
|
||||
|
||||
@@ -49,6 +49,10 @@ export interface GeneratorContext {
|
||||
lastGenreIds: string[];
|
||||
context: string | null;
|
||||
noveltyHunger: number;
|
||||
/** Session-local exploration controls; they never overwrite durable taste. */
|
||||
explorationCoefficient?: number;
|
||||
discoveryRadius?: number;
|
||||
sessionGoal?: { type: string; target: number; progress: number };
|
||||
sessionAgeMin: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,6 +91,33 @@ export interface PlanBuildOptions {
|
||||
retainedPlan?: Candidate[];
|
||||
}
|
||||
|
||||
export interface SessionFingerprint {
|
||||
artists?: string[];
|
||||
genres?: string[];
|
||||
sources?: string[];
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A compact Jaccard penalty. It is deliberately capped: last night's shape
|
||||
* should make an alternative more attractive, not make good music ineligible. */
|
||||
export function sessionSimilarityPenalty(
|
||||
metadata: CandidateConstraintMetadata | undefined,
|
||||
generatorId: string,
|
||||
fingerprints: SessionFingerprint[],
|
||||
): number {
|
||||
if (!metadata || fingerprints.length === 0) return 0;
|
||||
let strongest = 0;
|
||||
for (const fingerprint of fingerprints) {
|
||||
let matches = 0;
|
||||
let known = 0;
|
||||
if (metadata.artistId && fingerprint.artists?.length) { known++; if (fingerprint.artists.includes(metadata.artistId)) matches++; }
|
||||
if (metadata.genreId && fingerprint.genres?.length) { known++; if (fingerprint.genres.includes(metadata.genreId)) matches++; }
|
||||
if (fingerprint.sources?.length) { known++; if (fingerprint.sources.includes(generatorId)) matches++; }
|
||||
if (known > 0) strongest = Math.max(strongest, matches / known);
|
||||
}
|
||||
return Math.min(0.18, strongest * 0.18);
|
||||
}
|
||||
|
||||
export interface ArcRange {
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -729,6 +756,9 @@ export class SessionDirector {
|
||||
lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [],
|
||||
context: 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,
|
||||
sessionGoal: row.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined,
|
||||
sessionAgeMin: row.started_at
|
||||
? (Date.now() - new Date(row.started_at).getTime()) / 60000
|
||||
: 0,
|
||||
@@ -745,6 +775,9 @@ export class SessionDirector {
|
||||
lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [],
|
||||
context: 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,
|
||||
sessionGoal: latest.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined,
|
||||
sessionAgeMin: latest.started_at
|
||||
? (Date.now() - new Date(latest.started_at).getTime()) / 60000
|
||||
: 0,
|
||||
@@ -782,6 +815,9 @@ export class SessionDirector {
|
||||
if (preferredEnergy !== null && (energyBelief.rows[0]?.value ?? 0) > 0) {
|
||||
energy = energy * 0.65 + preferredEnergy * 0.35;
|
||||
}
|
||||
// Activity/device/time supply a boot prior only. Once playback exists its
|
||||
// contribution stays small so context never masquerades as feedback.
|
||||
if (savedState) energy = energy * 0.85 + savedState.energy * 0.15;
|
||||
|
||||
// Read novelty hunger from discovery profile
|
||||
const noveltyRes = await this.db.pgClient.query(
|
||||
@@ -790,7 +826,12 @@ export class SessionDirector {
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3;
|
||||
const durableNovelty = (noveltyRes.rows[0]?.value as number) ?? 0.3;
|
||||
const explorationCoefficient = Math.max(0, Math.min(1, savedState?.explorationCoefficient ?? 0.3));
|
||||
const discoveryRadius = Math.max(0.15, Math.min(0.9, savedState?.discoveryRadius ?? 0.38));
|
||||
// A contextual/session feedback signal is only a minority of the input;
|
||||
// long-term discovery belief still stabilises the stream across resumes.
|
||||
const noveltyHunger = durableNovelty * 0.65 + explorationCoefficient * 0.35;
|
||||
|
||||
// Last distinct artist IDs from recent completed plays.
|
||||
// Use a subquery to order first, then DISTINCT — avoids PG's rule that
|
||||
@@ -832,6 +873,9 @@ export class SessionDirector {
|
||||
lastGenreIds,
|
||||
context: savedState?.context ?? null,
|
||||
noveltyHunger,
|
||||
explorationCoefficient,
|
||||
discoveryRadius,
|
||||
sessionGoal: savedState?.sessionGoal,
|
||||
sessionAgeMin: age,
|
||||
};
|
||||
}
|
||||
@@ -1042,6 +1086,11 @@ export class SessionDirector {
|
||||
// D.4 — Arc selection
|
||||
// ---------------------------------------------------------------
|
||||
pickArc(state: GeneratorContext['state']): string {
|
||||
const goal = state.sessionGoal;
|
||||
if (goal && goal.progress < goal.target) {
|
||||
if (goal.type === 'familiar') return 'comfort';
|
||||
if (goal.type === 'discovery' || goal.type === 'artist_introduction') return 'discovery';
|
||||
}
|
||||
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';
|
||||
@@ -1505,6 +1554,7 @@ export class SessionDirector {
|
||||
state: GeneratorContext['state'],
|
||||
repetitionState: RepetitionState,
|
||||
userId = '',
|
||||
recentSessionFingerprints: SessionFingerprint[] = [],
|
||||
): Promise<Candidate[]> {
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
@@ -1551,11 +1601,16 @@ export class SessionDirector {
|
||||
const wouldRepeat =
|
||||
repetitionState.recentTrackIds.has(c.trackId) ||
|
||||
(!!artistId && repetitionState.recentArtistIds.has(artistId));
|
||||
const novelty = candidateNovelty(c, item);
|
||||
const explorationFit = 1 - Math.abs(novelty - (state.discoveryRadius ?? 0.38));
|
||||
const sessionSimilarity = sessionSimilarityPenalty(item, c.generatorId, recentSessionFingerprints);
|
||||
|
||||
let score = W_ENJOY * c.relevance
|
||||
- W_FATIGUE * avgFatigue
|
||||
+ W_DIVERSITY * diversityBonus
|
||||
+ W_ENTROPY * entropyBonus;
|
||||
+ W_ENTROPY * entropyBonus
|
||||
+ 0.08 * explorationFit
|
||||
- sessionSimilarity;
|
||||
|
||||
if (wouldRepeat) {
|
||||
score *= 0.1;
|
||||
@@ -1597,6 +1652,9 @@ export class SessionDirector {
|
||||
lastArtistIds: state.lastArtistIds,
|
||||
lastGenreIds: state.lastGenreIds,
|
||||
noveltyHunger: state.noveltyHunger,
|
||||
explorationCoefficient: state.explorationCoefficient ?? 0.3,
|
||||
discoveryRadius: state.discoveryRadius ?? 0.38,
|
||||
sessionGoal: state.sessionGoal ?? { type: 'discovery', target: 1, progress: 0 },
|
||||
}),
|
||||
]
|
||||
);
|
||||
@@ -1659,6 +1717,10 @@ export class SessionDirector {
|
||||
// disliked, or otherwise exposed track can never leak into a replacement
|
||||
// revision for this session.
|
||||
const durableSessionTrackIds = await this.db.getVibeSessionTrackIds(sessionId, userId);
|
||||
const recentFingerprintReader = (this.db as Partial<DbService>).getRecentVibeSessionFingerprints;
|
||||
const recentSessionFingerprints = recentFingerprintReader
|
||||
? await recentFingerprintReader.call(this.db, userId, sessionId) as SessionFingerprint[]
|
||||
: [];
|
||||
// Do not let abundant track-level beliefs crowd out the artist/genre
|
||||
// affinities required by the discovery generators.
|
||||
const beliefGroups = await Promise.all([
|
||||
@@ -1795,7 +1857,7 @@ export class SessionDirector {
|
||||
return [];
|
||||
}
|
||||
const ranked = await this.rankCandidates(
|
||||
eligibleCandidates, fatigue, budgets, state, repetitionState, userId
|
||||
eligibleCandidates, fatigue, budgets, state, repetitionState, userId, recentSessionFingerprints
|
||||
);
|
||||
|
||||
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector } from './session-director.service.js';
|
||||
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js';
|
||||
import { DbService } from './db.service.js';
|
||||
import { ALL_GENERATORS } from './generators.service.js';
|
||||
|
||||
@@ -425,6 +425,24 @@ describe('SessionDirector', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('recent session fingerprint penalty', () => {
|
||||
it('softly penalizes a repeated session shape without excluding it', () => {
|
||||
const repeated = sessionSimilarityPenalty(
|
||||
{ artistId: 'artist-1', genreId: 'genre-1' },
|
||||
'comfort',
|
||||
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
|
||||
);
|
||||
const newShape = sessionSimilarityPenalty(
|
||||
{ artistId: 'artist-2', genreId: 'genre-2' },
|
||||
'discovery',
|
||||
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
|
||||
);
|
||||
expect(repeated).toBeGreaterThan(0);
|
||||
expect(repeated).toBeLessThanOrEqual(0.18);
|
||||
expect(newShape).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequence constraints', () => {
|
||||
const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' }));
|
||||
const budgets = [
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { initialVibeState, normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js';
|
||||
|
||||
describe('Vibe context', () => {
|
||||
it('keeps only coarse structured values and derives time on the server', () => {
|
||||
const context = normalizeVibeContext({
|
||||
timeZone: 'UTC', activity: 'workout', device: 'headphones',
|
||||
exactCoordinates: '53.2,50.1', localHour: 3,
|
||||
}, new Date('2026-08-02T19:00:00.000Z'));
|
||||
|
||||
expect(context).toMatchObject({ localHour: 19, weekday: 0, dayKind: 'weekend', activity: 'workout' });
|
||||
expect(context).not.toHaveProperty('exactCoordinates');
|
||||
expect(context).not.toHaveProperty('localHour', 3);
|
||||
});
|
||||
|
||||
it('uses context only as a bounded initial prior and gives focus a comfort goal', () => {
|
||||
const state = initialVibeState(normalizeVibeContext({ activity: 'focus' }, new Date('2026-08-03T12:00:00.000Z')));
|
||||
expect(state.energy).toBeGreaterThan(0);
|
||||
expect(state.energy).toBeLessThan(1);
|
||||
expect(state.sessionGoal).toEqual({ type: 'familiar', target: 1, progress: 0 });
|
||||
});
|
||||
|
||||
it('persists only canonical context for context-change events', () => {
|
||||
const payload = normalizeVibeEventPayload('context_changed', {
|
||||
context: { activity: 'walking', exactCoordinates: '53.2,50.1', adId: 'do-not-store' },
|
||||
rawBrowserTelemetry: { battery: 4 },
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
context: expect.objectContaining({ activity: 'walking' }),
|
||||
});
|
||||
expect(payload).not.toHaveProperty('rawBrowserTelemetry');
|
||||
expect((payload?.context as Record<string, unknown>)).not.toHaveProperty('exactCoordinates');
|
||||
expect((payload?.context as Record<string, unknown>)).not.toHaveProperty('adId');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Coarse, opt-in context accepted by the durable Vibe API. It deliberately
|
||||
* has no precise location, identifiers, or browser telemetry: clients can
|
||||
* supply a hint, but the server owns the time fields and can ignore all of it.
|
||||
*/
|
||||
export const VIBE_CONTEXT_VALUES = {
|
||||
device: ['desktop', 'phone', 'speaker', 'car', 'headphones'] as const,
|
||||
activity: ['focus', 'relax', 'walking', 'workout', 'social', 'unknown'] as const,
|
||||
locationCategory: ['home', 'work', 'gym', 'travel', 'unknown'] as const,
|
||||
weather: ['clear', 'rain', 'snow', 'hot', 'cold', 'unknown'] as const,
|
||||
source: ['current_track', 'artist', 'genre', 'surprise', 'resume'] as const,
|
||||
};
|
||||
|
||||
export interface VibeContext {
|
||||
timeZone?: string;
|
||||
localHour?: number;
|
||||
weekday?: number;
|
||||
dayKind?: 'weekday' | 'weekend' | 'holiday';
|
||||
device?: (typeof VIBE_CONTEXT_VALUES.device)[number];
|
||||
activity?: (typeof VIBE_CONTEXT_VALUES.activity)[number];
|
||||
locationCategory?: (typeof VIBE_CONTEXT_VALUES.locationCategory)[number];
|
||||
weather?: (typeof VIBE_CONTEXT_VALUES.weather)[number];
|
||||
source?: (typeof VIBE_CONTEXT_VALUES.source)[number];
|
||||
}
|
||||
|
||||
export interface InitialVibeState {
|
||||
contextLabel: string | undefined;
|
||||
energy: number;
|
||||
noveltyHunger: number;
|
||||
explorationCoefficient: number;
|
||||
discoveryRadius: number;
|
||||
sessionGoal: { type: 'surprise' | 'familiar' | 'discovery' | 'artist_introduction'; target: number; progress: number };
|
||||
}
|
||||
|
||||
const hasValue = <T extends readonly string[]>(values: T, value: unknown): value is T[number] =>
|
||||
typeof value === 'string' && (values as readonly string[]).includes(value);
|
||||
|
||||
function serverTime(timeZone?: string, now = new Date()): Pick<VibeContext, 'timeZone' | 'localHour' | 'weekday' | 'dayKind'> {
|
||||
// Intl rejects bad IANA names. Falling back to the server clock is safe and
|
||||
// still makes time a weak prior rather than client-controlled fact.
|
||||
let zone: string | undefined;
|
||||
try {
|
||||
if (timeZone) new Intl.DateTimeFormat('en-US', { timeZone }).format(now);
|
||||
zone = timeZone;
|
||||
} catch { /* server-local fallback */ }
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone, hour: 'numeric', weekday: 'short', hourCycle: 'h23',
|
||||
}).formatToParts(now);
|
||||
const hour = Number(parts.find(part => part.type === 'hour')?.value ?? now.getHours());
|
||||
const weekdayName = parts.find(part => part.type === 'weekday')?.value;
|
||||
const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayName ?? '');
|
||||
return {
|
||||
...(zone ? { timeZone: zone } : {}),
|
||||
localHour: Number.isInteger(hour) ? hour : now.getHours(),
|
||||
weekday: weekday >= 0 ? weekday : now.getDay(),
|
||||
dayKind: ([0, 6].includes(weekday >= 0 ? weekday : now.getDay()) ? 'weekend' : 'weekday'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove unknown fields and derive time server-side. This preserves old
|
||||
* clients that send an empty object while preventing opaque context blobs from
|
||||
* becoming a permanent behavioural profile. */
|
||||
export function normalizeVibeContext(input: Record<string, unknown> = {}, now = new Date()): VibeContext {
|
||||
const time = serverTime(typeof input.timeZone === 'string' ? input.timeZone : undefined, now);
|
||||
return {
|
||||
...time,
|
||||
...(hasValue(VIBE_CONTEXT_VALUES.device, input.device) ? { device: input.device } : {}),
|
||||
...(hasValue(VIBE_CONTEXT_VALUES.activity, input.activity) ? { activity: input.activity } : {}),
|
||||
...(hasValue(VIBE_CONTEXT_VALUES.locationCategory, input.locationCategory) ? { locationCategory: input.locationCategory } : {}),
|
||||
...(hasValue(VIBE_CONTEXT_VALUES.weather, input.weather) ? { weather: input.weather } : {}),
|
||||
...(hasValue(VIBE_CONTEXT_VALUES.source, input.source) ? { source: input.source } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Context changes are the only event payload with a structured, durable
|
||||
* context body. Keep their ledger representation intentionally tiny: callers
|
||||
* cannot smuggle precise location or arbitrary browser telemetry into an
|
||||
* immutable event by adding sibling fields or unknown context keys.
|
||||
*/
|
||||
export function normalizeVibeEventPayload(
|
||||
type: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (type !== 'context_changed') return payload;
|
||||
const context = payload?.context;
|
||||
if (!context || typeof context !== 'object' || Array.isArray(context)) return {};
|
||||
return { context: normalizeVibeContext(context as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
/** Context is intentionally a gentle prior. It can nudge the initial arc but
|
||||
* never overrides observed playback behaviour. */
|
||||
export function initialVibeState(context: VibeContext): InitialVibeState {
|
||||
const activityEnergy: Record<string, number> = {
|
||||
focus: 0.42, relax: 0.34, walking: 0.58, workout: 0.72, social: 0.62, unknown: 0.5,
|
||||
};
|
||||
const hour = context.localHour ?? 12;
|
||||
const hourEnergy = hour < 6 ? 0.32 : hour < 10 ? 0.46 : hour >= 22 ? 0.38 : 0.52;
|
||||
const activity = context.activity ?? 'unknown';
|
||||
const energy = Math.max(0, Math.min(1, activityEnergy[activity] * 0.7 + hourEnergy * 0.3));
|
||||
const goal = activity === 'focus' || activity === 'relax'
|
||||
? 'familiar'
|
||||
: activity === 'workout' || activity === 'walking' ? 'surprise' : 'discovery';
|
||||
return {
|
||||
contextLabel: context.activity ?? context.device,
|
||||
energy,
|
||||
noveltyHunger: 0.3,
|
||||
explorationCoefficient: 0.3,
|
||||
discoveryRadius: 0.38,
|
||||
sessionGoal: { type: goal, target: 1, progress: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidVibeContext(input: Record<string, unknown>): boolean {
|
||||
const scalar = (key: keyof typeof VIBE_CONTEXT_VALUES) => input[key] === undefined
|
||||
|| hasValue(VIBE_CONTEXT_VALUES[key], input[key]);
|
||||
return (input.timeZone === undefined || typeof input.timeZone === 'string')
|
||||
&& scalar('device') && scalar('activity') && scalar('locationCategory')
|
||||
&& scalar('weather') && scalar('source');
|
||||
}
|
||||
@@ -154,6 +154,44 @@ describe('VibeSessionCoordinator', () => {
|
||||
expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
|
||||
});
|
||||
|
||||
it('normalizes context before the immutable event write', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||
event: { id: 'event-1', type: 'context_changed', payload: {} }, inserted: true,
|
||||
});
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
type: 'context_changed',
|
||||
payload: {
|
||||
context: { activity: 'walking', exactCoordinates: '53.2,50.1' },
|
||||
rawBrowserTelemetry: { battery: 4 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: {
|
||||
context: expect.objectContaining({ activity: 'walking' }),
|
||||
},
|
||||
}));
|
||||
const payload = (db.recordVibeEvent as any).mock.calls[0][0].payload;
|
||||
expect(payload).not.toHaveProperty('rawBrowserTelemetry');
|
||||
expect(payload.context).not.toHaveProperty('exactCoordinates');
|
||||
});
|
||||
|
||||
it('creates the durable profile from the initial context goal', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
|
||||
await coordinator.start('user-1', { context: { activity: 'focus' } });
|
||||
|
||||
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
profile: expect.objectContaining({
|
||||
goals: { type: 'familiar', target: 1, progress: 0 },
|
||||
explorationCoefficient: 0.3,
|
||||
discoveryRadius: 0.38,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('passes the durable unserved callback tail into the retention-aware replan', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValue({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
||||
import { SessionDirector } from './session-director.service.js';
|
||||
import { Candidate } from './generators.service.js';
|
||||
import {
|
||||
initialVibeState,
|
||||
normalizeVibeContext,
|
||||
normalizeVibeEventPayload,
|
||||
VibeContext,
|
||||
} from './vibe-context.service.js';
|
||||
|
||||
/**
|
||||
* This is deliberately a narrow bridge between the durable Vibe ledger and
|
||||
@@ -21,7 +27,7 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
|
||||
|
||||
export interface StartVibeSessionInput {
|
||||
seedTrackId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
context?: VibeContext | Record<string, unknown>;
|
||||
intent?: string;
|
||||
policyVersion?: string;
|
||||
resumeSessionId?: string;
|
||||
@@ -73,13 +79,19 @@ export class VibeSessionCoordinator {
|
||||
|
||||
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
||||
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
|
||||
const context = input.context ?? {};
|
||||
const context = normalizeVibeContext({ ...(input.context ?? {}) });
|
||||
const initialState = initialVibeState(context);
|
||||
const policyVersion = input.policyVersion ?? DEFAULT_VIBE_POLICY_VERSION;
|
||||
const session = await this.db.createVibeSession({
|
||||
userId,
|
||||
policyVersion,
|
||||
seedTrackId: input.seedTrackId ?? null,
|
||||
context,
|
||||
context: { ...context },
|
||||
profile: {
|
||||
goals: initialState.sessionGoal,
|
||||
explorationCoefficient: initialState.explorationCoefficient,
|
||||
discoveryRadius: initialState.discoveryRadius,
|
||||
},
|
||||
});
|
||||
|
||||
// session_state is a derived cache used by the current director. Give it
|
||||
@@ -87,8 +99,15 @@ export class VibeSessionCoordinator {
|
||||
// session while the durable tables remain the source of truth.
|
||||
await this.db.createSessionState(
|
||||
userId,
|
||||
typeof context.activity === 'string' ? context.activity : input.intent,
|
||||
{ energy: 0.5, noveltyHunger: 0.3 },
|
||||
initialState.contextLabel ?? input.intent,
|
||||
{
|
||||
energy: initialState.energy,
|
||||
noveltyHunger: initialState.noveltyHunger,
|
||||
explorationCoefficient: initialState.explorationCoefficient,
|
||||
discoveryRadius: initialState.discoveryRadius,
|
||||
sessionGoal: initialState.sessionGoal,
|
||||
context,
|
||||
},
|
||||
session.id,
|
||||
);
|
||||
await this.db.recordVibeEvent({
|
||||
@@ -180,6 +199,11 @@ export class VibeSessionCoordinator {
|
||||
input: AppendVibeEventInput,
|
||||
): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
|
||||
try {
|
||||
// Do this before the ledger write, rather than after it, because Vibe
|
||||
// events are immutable. The DB repeats this boundary for non-HTTP
|
||||
// callers; keeping it here also makes coordinator callers see exactly
|
||||
// what will be persisted.
|
||||
const payload = normalizeVibeEventPayload(input.type, input.payload);
|
||||
const result = await this.db.recordVibeEvent({
|
||||
sessionId,
|
||||
userId,
|
||||
@@ -189,8 +213,13 @@ export class VibeSessionCoordinator {
|
||||
occurredAt: input.occurredAt,
|
||||
positionMs: input.positionMs,
|
||||
durationMs: input.durationMs,
|
||||
payload: input.payload,
|
||||
payload,
|
||||
});
|
||||
// The ledger write is authoritative; this idempotent projection updates
|
||||
// exploration only after the exact event exists. Keep the compatibility
|
||||
// guard for old coordinator test doubles during the migration.
|
||||
const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
|
||||
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
|
||||
if (!isMaterialFeedback(input.type)) {
|
||||
const response = await this.getPlan(userId, sessionId);
|
||||
return { ...response, event: result.event, idempotent: !result.inserted };
|
||||
|
||||
Reference in New Issue
Block a user