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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user