5a73a6a6f3
Disliking a track in a Vibe wrote one row to the session ledger and nothing else. The ledger only excludes a track from the session it was recorded in, so the same track came back the next evening, and the one after that. A dislike in a Vibe is the same verdict as a dislike anywhere else, so it now takes the same path. Two more things undid a dislike that did land. The library scan rewrote every track's state from the file on disk, which restored every HIDDEN track to LIBRARY on every scan; finding a file again says nothing about whether the listener wants to hear it. And hiding only matched tracks in LIBRARY, so a disliked probation recommendation stayed eligible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2686 lines
106 KiB
TypeScript
2686 lines
106 KiB
TypeScript
// No `unlink` here, deliberately: the backend mounts /music `:ro` and must never
|
|
// remove a library file. Deletions are queued into pending_file_deletions and
|
|
// carried out by the worker's gated cleanup sweep.
|
|
import { readFile } from 'fs/promises';
|
|
import { fileURLToPath } from 'url';
|
|
import { dirname, join } from 'path';
|
|
import { Pool, PoolClient } from 'pg';
|
|
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;
|
|
track_id: string | null;
|
|
slot_role: string | null;
|
|
candidate_source: string | null;
|
|
score: number | null;
|
|
score_breakdown: Record<string, unknown> | null;
|
|
explanation: unknown | null;
|
|
committed: boolean | null;
|
|
};
|
|
|
|
import { MIGRATIONS } from '../db/migrations.js';
|
|
import { allowedFields } from '../db/updatable-columns.js';
|
|
|
|
// Row shapes live in ../db/types.ts; re-exported here so existing importers
|
|
// (routes, generators, session-director) need no change.
|
|
import type {
|
|
Artist,
|
|
Album,
|
|
TrackArtist,
|
|
Track,
|
|
FeedbackAction,
|
|
HistoryEntry,
|
|
Genre,
|
|
DislikeEntry,
|
|
ArtistWithAlbums,
|
|
AlbumWithTracks,
|
|
Claim,
|
|
Evidence,
|
|
ListenerBelief,
|
|
ClaimEdge,
|
|
SessionState,
|
|
DiversityBudget,
|
|
RepetitionRule,
|
|
VibeSession,
|
|
VibeSessionStatus,
|
|
VibeEvent,
|
|
RecordedVibeEvent,
|
|
VibePlan,
|
|
VibePlanItem,
|
|
VibeSessionProfile,
|
|
} from '../db/types.js';
|
|
import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js';
|
|
export * from '../db/types.js';
|
|
|
|
/**
|
|
* Every play_history write goes through this statement.
|
|
*
|
|
* The title/artist copy is taken in the same round trip via LEFT JOIN, so a
|
|
* play stays readable after its track is hard-deleted, and the join never
|
|
* suppresses the insert when the track id is unknown.
|
|
*/
|
|
const PLAY_HISTORY_INSERT = `
|
|
INSERT INTO play_history (
|
|
user_id, track_id, batch_id, completed, listened_ms, track_title, track_artist
|
|
)
|
|
SELECT $1::uuid, $2::uuid, $3::uuid, $4::boolean, $5::int, t.title, t.artist
|
|
FROM (SELECT 1) AS always
|
|
LEFT JOIN tracks t ON t.id = $2::uuid
|
|
RETURNING id`;
|
|
|
|
export class DbService {
|
|
/** Exposed so route handlers (e.g. settings) can query the database directly. */
|
|
readonly pgClient: Pool;
|
|
|
|
constructor(
|
|
pgClient: Pool,
|
|
private searchService?: SearchService
|
|
) {
|
|
this.pgClient = pgClient;
|
|
}
|
|
|
|
/**
|
|
* Check out a dedicated client for a transaction and BEGIN/COMMIT/ROLLBACK
|
|
* on it, then release it back to the pool. Never run BEGIN...COMMIT on
|
|
* `this.pgClient` directly — the Pool hands out a different connection to
|
|
* every query, so a shared-connection transaction would let concurrent
|
|
* requests' queries land inside it (a ROLLBACK could discard another
|
|
* request's writes). Pass `client` through to any nested calls that must
|
|
* participate in the same transaction (e.g. recordEvidence, upsertClaim).
|
|
*/
|
|
private async withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
const client = await this.pgClient.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const result = await fn(client);
|
|
await client.query('COMMIT');
|
|
return result;
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply the canonical schema (backend/src/db/schema.sql) on boot. The file is
|
|
* fully idempotent — enums are guarded with DO/EXCEPTION blocks and every
|
|
* table/index uses IF NOT EXISTS — so running it on every startup is safe and
|
|
* self-provisions tables on databases whose volume predates a schema change
|
|
* (the docker-entrypoint-initdb.d mount only runs on FIRST init). This is what
|
|
* keeps "relation \"play_history\"/\"feedback\" does not exist" from recurring.
|
|
*/
|
|
async ensureSchema(): Promise<void> {
|
|
// Resolve relative to this module. At runtime this is dist/services/, and
|
|
// the SQL ships unbuilt at src/db/schema.sql (Dockerfile `COPY . .`), so go
|
|
// up two levels from dist/services -> app root, then into src/db.
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const schemaPath = join(here, '..', '..', 'src', 'db', 'schema.sql');
|
|
const sql = await readFile(schemaPath, 'utf8');
|
|
await this.pgClient.query(sql);
|
|
}
|
|
|
|
/**
|
|
* Run pending schema migrations on boot.
|
|
*
|
|
* Each migration is a { id, sql } object. `id` must be a stable, unique string
|
|
* (convention: "YYYYMMDD_short_description"). Once applied, the id is recorded
|
|
* in `schema_migrations` and never re-run — even if the SQL changes.
|
|
*
|
|
* To add a new migration: append to the MIGRATIONS array below. Never edit or
|
|
* remove an existing entry — that would leave the migration "applied" in the DB
|
|
* but with different SQL in code, which is a lie. Instead, add a new migration.
|
|
*/
|
|
async runMigrations(): Promise<void> {
|
|
await this.pgClient.query(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
id TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
const applied = await this.pgClient.query('SELECT id FROM schema_migrations');
|
|
const appliedIds = new Set(applied.rows.map((r: any) => r.id as string));
|
|
|
|
for (const migration of MIGRATIONS) {
|
|
if (appliedIds.has(migration.id)) continue;
|
|
console.log(`[DB] Running migration: ${migration.id}`);
|
|
try {
|
|
await this.withTransaction(async (client) => {
|
|
await client.query(migration.sql);
|
|
await client.query('INSERT INTO schema_migrations (id) VALUES ($1)', [migration.id]);
|
|
});
|
|
console.log(`[DB] Migration applied: ${migration.id}`);
|
|
} catch (err) {
|
|
console.error(`[DB] Migration failed: ${migration.id}`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
async getTracks(params: { limit?: number; offset?: number; sort_by?: string; order?: 'ASC' | 'DESC'; search?: string } = {}) {
|
|
const { limit = 100, offset = 0, sort_by = 'title', order = 'ASC', search } = params;
|
|
const validSortBy = ['title', 'artist', 'album_id', 'duration', 'play_count'];
|
|
const sortColumn = validSortBy.includes(sort_by) ? `"${sort_by}"` : '"title"';
|
|
const sortOrder = order === 'DESC' ? 'DESC' : 'ASC';
|
|
|
|
if (search && this.searchService) {
|
|
const searchResults = await this.searchService.search('tracks', search, {
|
|
query_by: 'title,artist'
|
|
});
|
|
return (searchResults?.hits || []).map((h: any) => h.document) as Track[];
|
|
}
|
|
|
|
// Exclude HIDDEN (disliked) and DELETED tracks from library views.
|
|
// Join albums to get artwork_id for track cover display.
|
|
let query = `
|
|
SELECT t.*, al.artwork_id
|
|
FROM tracks t
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE t.state NOT IN ('HIDDEN', 'DELETED')
|
|
`;
|
|
const queryParams: any[] = [limit, offset];
|
|
let paramIndex = 3;
|
|
|
|
if (search) {
|
|
query += ` AND (t.title ILIKE $${paramIndex} OR t.artist ILIKE $${paramIndex})`;
|
|
queryParams.push(`%${search}%`);
|
|
}
|
|
|
|
query += ` ORDER BY ${sortColumn} ${sortOrder} LIMIT $1 OFFSET $2`;
|
|
|
|
const res = await this.pgClient.query(query, queryParams);
|
|
return this.attachArtists(res.rows as Track[]);
|
|
}
|
|
|
|
/** Attach artists array (from track_artists join) to a list of tracks. */
|
|
private async attachArtists(tracks: Track[]): Promise<Track[]> {
|
|
if (tracks.length === 0) return tracks;
|
|
const ids = tracks.map((t) => t.id);
|
|
const artistsRes = await this.pgClient.query(
|
|
`SELECT ta.track_id, ta.role, a.id, a.name
|
|
FROM track_artists ta
|
|
JOIN artists a ON a.id = ta.artist_id
|
|
WHERE ta.track_id = ANY($1)
|
|
ORDER BY ta.role DESC`,
|
|
[ids]
|
|
);
|
|
const byTrack = new Map<string, TrackArtist[]>();
|
|
for (const row of artistsRes.rows) {
|
|
if (!byTrack.has(row.track_id)) byTrack.set(row.track_id, []);
|
|
byTrack.get(row.track_id)!.push({ id: row.id, name: row.name, role: row.role });
|
|
}
|
|
return tracks.map((t) => ({ ...t, artists: byTrack.get(t.id) ?? [] }));
|
|
}
|
|
|
|
// Search returning a Typesense-compatible shape ({ found, hits:[{document}] }).
|
|
// Prefers Typesense when its 'tracks' collection is populated; falls back to a
|
|
// Postgres ILIKE scan when Typesense isn't indexed/reachable yet (no indexing
|
|
// pipeline exists today — see TODO: build a tracks reindex into Typesense).
|
|
async searchTracks(q: string, limit = 50): Promise<{ found: number; hits: { document: Track }[] }> {
|
|
if (this.searchService) {
|
|
try {
|
|
const res: any = await this.searchService.search('tracks', q, {
|
|
query_by: 'title,artist',
|
|
per_page: limit,
|
|
});
|
|
// Only trust Typesense when it actually returns hits. An empty result is
|
|
// ambiguous: it usually means the 'tracks' collection is unindexed (no
|
|
// indexing pipeline runs on scan), not that there are genuinely no
|
|
// matches — so fall through to the Postgres scan instead of returning [].
|
|
if (res && Array.isArray(res.hits) && res.hits.length > 0) {
|
|
return {
|
|
found: res.found ?? res.hits.length,
|
|
hits: res.hits.map((h: any) => ({ document: h.document as Track })),
|
|
};
|
|
}
|
|
} catch {
|
|
// Typesense collection missing/unreachable — fall through to Postgres.
|
|
}
|
|
}
|
|
|
|
const like = `%${q}%`;
|
|
const res = await this.pgClient.query(
|
|
`SELECT t.*, al.artwork_id FROM tracks t
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE t.state = 'LIBRARY' AND (t.title ILIKE $1 OR t.artist ILIKE $1)
|
|
ORDER BY t.play_count DESC, t.title ASC
|
|
LIMIT $2`,
|
|
[like, limit]
|
|
);
|
|
const rows = res.rows as Track[];
|
|
return { found: rows.length, hits: rows.map((t) => ({ document: t })) };
|
|
}
|
|
|
|
async getArtists(params: { limit?: number; offset?: number } = {}): Promise<Artist[]> {
|
|
const { limit = 50, offset = 0 } = params;
|
|
const res = await this.pgClient.query(
|
|
'SELECT * FROM artists ORDER BY name ASC LIMIT $1 OFFSET $2',
|
|
[limit, offset]
|
|
);
|
|
return res.rows as Artist[];
|
|
}
|
|
|
|
async getArtistsById(id: string): Promise<ArtistWithAlbums | null> {
|
|
const artistRes = await this.pgClient.query('SELECT * FROM artists WHERE id = $1', [id]);
|
|
const artist = artistRes.rows[0] as Artist;
|
|
if (!artist) return null;
|
|
|
|
const albumsRes = await this.pgClient.query('SELECT * FROM albums WHERE artist_id = $1', [id]);
|
|
const albums = albumsRes.rows as Album[];
|
|
|
|
return {
|
|
...artist,
|
|
albums,
|
|
};
|
|
}
|
|
|
|
async getSimilarArtists(artistId: string): Promise<{ similar_name: string; match: number }[]> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT similar_name, match
|
|
FROM artist_similar
|
|
WHERE artist_id = $1
|
|
ORDER BY match DESC`,
|
|
[artistId]
|
|
);
|
|
return res.rows;
|
|
}
|
|
|
|
async getAlbums(params: { limit?: number; offset?: number } = {}): Promise<Album[]> {
|
|
const { limit = 50, offset = 0 } = params;
|
|
const res = await this.pgClient.query(
|
|
`SELECT al.*, ar.name AS artist_name
|
|
FROM albums al
|
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
|
ORDER BY al.title ASC
|
|
LIMIT $1 OFFSET $2`,
|
|
[limit, offset]
|
|
);
|
|
return res.rows as Album[];
|
|
}
|
|
|
|
async getAlbumById(id: string): Promise<AlbumWithTracks | null> {
|
|
const albumRes = await this.pgClient.query('SELECT * FROM albums WHERE id = $1', [id]);
|
|
const album = albumRes.rows[0] as Album;
|
|
if (!album) return null;
|
|
|
|
const tracksRes = await this.pgClient.query(
|
|
`SELECT t.*, al.artwork_id FROM tracks t
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE t.album_id = $1 ORDER BY t.title ASC`, [id]);
|
|
const tracks = await this.attachArtists(tracksRes.rows as Track[]);
|
|
|
|
return {
|
|
...album,
|
|
tracks,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
FROM genre g
|
|
LEFT JOIN track_genre tg ON tg.genre_id = g.id
|
|
GROUP BY g.id
|
|
ORDER BY track_count DESC, g.name`
|
|
);
|
|
return res.rows as Genre[];
|
|
}
|
|
|
|
async getGenreById(id: string): Promise<Genre | null> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT g.id, g.name, g.parent_id, COUNT(tg.track_id)::int AS track_count
|
|
FROM genre g
|
|
LEFT JOIN track_genre tg ON tg.genre_id = g.id
|
|
WHERE g.id = $1
|
|
GROUP BY g.id`,
|
|
[id]
|
|
);
|
|
return (res.rows[0] as Genre) || null;
|
|
}
|
|
|
|
async getTracksByGenre(genreId: string, limit = 100, offset = 0): Promise<Track[]> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT t.id, t.path, t.hash, t.title, t.artist, t.album_id, t.duration,
|
|
t.state, t.play_count, t.skip_count, t.dislike_count,
|
|
t.last_played_at, t.mtime, t.source_type, al.artwork_id
|
|
FROM tracks t
|
|
JOIN track_genre tg ON tg.track_id = t.id
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE tg.genre_id = $1 AND t.state = 'LIBRARY'
|
|
ORDER BY tg.weight DESC
|
|
LIMIT $2 OFFSET $3`,
|
|
[genreId, limit, offset]
|
|
);
|
|
return res.rows as Track[];
|
|
}
|
|
|
|
async getFavorites(userId: string): Promise<Track[]> {
|
|
// Favorites and disliked tracks are mutually exclusive — a disliked track
|
|
// has state='HIDDEN' so it won't appear here. Defensive filter anyway.
|
|
const query = `
|
|
SELECT t.*, al.artwork_id FROM tracks t
|
|
JOIN favorites f ON t.id = f.track_id
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE f.user_id = $1 AND t.state = 'LIBRARY'
|
|
`;
|
|
const res = await this.pgClient.query(query, [userId]);
|
|
return res.rows as Track[];
|
|
}
|
|
|
|
async addFavorite(userId: string, trackId: string): Promise<void> {
|
|
await this.pgClient.query('INSERT INTO favorites (user_id, track_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [userId, trackId]);
|
|
}
|
|
|
|
async removeFavorite(userId: string, trackId: string): Promise<void> {
|
|
await this.pgClient.query('DELETE FROM favorites WHERE user_id = $1 AND track_id = $2', [userId, trackId]);
|
|
}
|
|
|
|
/**
|
|
* Dislike a track: atomically hides the track, inserts a dislike row, and
|
|
* logs a feedback event. Per the lifecycle spec, the track transitions from
|
|
* LIBRARY -> HIDDEN and is removed from all active views immediately.
|
|
*/
|
|
async dislikeTrack(userId: string, trackId: string): Promise<void> {
|
|
await this.withTransaction(async (client) => {
|
|
// Phase 1: hide the track in all active views. A probation recommendation
|
|
// is disliked the same way a library track is, and retires on the spot —
|
|
// the listener has answered the question probation exists to ask.
|
|
await client.query(
|
|
`UPDATE tracks
|
|
SET state = 'HIDDEN',
|
|
probation_status = CASE WHEN state = 'RECOMMENDED' THEN 'retired' ELSE probation_status END
|
|
WHERE id = $1 AND state IN ('LIBRARY', 'RECOMMENDED')`,
|
|
[trackId]
|
|
);
|
|
|
|
// Phase 1: insert dislike row (idempotent — won't create duplicate)
|
|
await client.query(
|
|
'INSERT INTO dislikes (track_id) VALUES ($1) ON CONFLICT (track_id) DO NOTHING',
|
|
[trackId]
|
|
);
|
|
|
|
// Phase 1: log feedback signal for the Vibe learning loop
|
|
await client.query(
|
|
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'disliked')",
|
|
[userId, trackId]
|
|
);
|
|
});
|
|
|
|
// Write evidence: hidden → negative profile (only on success), then carry
|
|
// that signal through the track's artist/genre/audio identities.
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'hidden',
|
|
profile: 'negative',
|
|
weight: -0.60,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Restore a disliked track: atomically removes the dislike row and sets the
|
|
* track back to LIBRARY. Per the lifecycle spec, this is the "User Recovery"
|
|
* reversal of the dislike action.
|
|
*/
|
|
async restoreDislike(trackId: string): Promise<void> {
|
|
await this.withTransaction(async (client) => {
|
|
await client.query('DELETE FROM dislikes WHERE track_id = $1', [trackId]);
|
|
await client.query("UPDATE tracks SET state = 'LIBRARY' WHERE id = $1", [trackId]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch all currently disliked tracks (in HIDDEN state via dislike rows).
|
|
* Returns DislikeEntry rows joined with track metadata. Used by the
|
|
* Quarantine/dislikes list endpoint.
|
|
*/
|
|
async getDislikedTracks(): Promise<DislikeEntry[]> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT d.track_id, d.disliked_at, d.warned_at, d.deleted_at,
|
|
d.grace_hours, d.state,
|
|
t.title AS track_title, t.artist AS track_artist, t.path AS track_path
|
|
FROM dislikes d
|
|
JOIN tracks t ON t.id = d.track_id
|
|
ORDER BY d.disliked_at DESC`
|
|
);
|
|
return res.rows as DislikeEntry[];
|
|
}
|
|
|
|
/**
|
|
* Fetch a single dislike row by track_id, or null if not disliked.
|
|
*/
|
|
async getDislikeByTrackId(trackId: string): Promise<DislikeEntry | null> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT d.track_id, d.disliked_at, d.warned_at, d.deleted_at,
|
|
d.grace_hours, d.state,
|
|
t.title AS track_title, t.artist AS track_artist, t.path AS track_path
|
|
FROM dislikes d
|
|
JOIN tracks t ON t.id = d.track_id
|
|
WHERE d.track_id = $1`,
|
|
[trackId]
|
|
);
|
|
return (res.rows[0] as DislikeEntry) || null;
|
|
}
|
|
|
|
// NOTE: hardDeleteTrack() used to live here as a second, unreferenced deletion
|
|
// path with the same insert-then-delete shape as permanentlyDeleteTrack(), but
|
|
// with the audit insert outside any transaction and the unlink after the DELETE
|
|
// and silently swallowed. It had no callers. Removed rather than fixed twice
|
|
// over: there is now exactly one deletion path in the backend
|
|
// (permanentlyDeleteTrack, below) and exactly one unlink site in the whole
|
|
// system (workers/src/cleanup.service.ts).
|
|
|
|
async recordPlay(
|
|
userId: string,
|
|
trackId: string,
|
|
completed: boolean,
|
|
batchId?: string,
|
|
listenedMs?: number,
|
|
): Promise<string> {
|
|
if (!completed) {
|
|
const res = await this.pgClient.query(
|
|
PLAY_HISTORY_INSERT,
|
|
[userId, trackId, batchId ?? null, false, listenedMs ?? null]
|
|
);
|
|
return res.rows[0].id as string;
|
|
}
|
|
|
|
// Completed play: history insert + play_count bump + Success-Driven Center
|
|
// + evidence writing + listener-behavior claims. All atomic.
|
|
return this.withTransaction(async (client) => {
|
|
// 1. Record play history
|
|
const insertRes = await client.query(
|
|
PLAY_HISTORY_INSERT,
|
|
[userId, trackId, batchId ?? null, true, listenedMs ?? null]
|
|
);
|
|
const historyId = insertRes.rows[0].id as string;
|
|
|
|
// 2. Bump play count + last_played_at
|
|
await client.query(
|
|
'UPDATE tracks SET play_count = play_count + 1, last_played_at = NOW() WHERE id = $1',
|
|
[trackId]
|
|
);
|
|
|
|
// 3. Write evidence: playback_completed → longterm affinity
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'playback_completed',
|
|
profile: 'longterm',
|
|
weight: 0.10,
|
|
context: batchId ? { batch_id: batchId } : undefined,
|
|
}, client);
|
|
|
|
// 4. Check for replay within 24h → strengthens longterm + obsession
|
|
const recentPlays = await client.query(
|
|
`SELECT COUNT(*)::int AS cnt FROM play_history
|
|
WHERE user_id = $1 AND track_id = $2 AND completed = true
|
|
AND played_at > NOW() - INTERVAL '24 hours'`,
|
|
[userId, trackId]
|
|
);
|
|
if ((recentPlays.rows[0]?.cnt as number) > 1) {
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'replay_within_24h',
|
|
profile: 'longterm',
|
|
weight: 0.25,
|
|
}, client);
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'replay_within_24h',
|
|
profile: 'obsession',
|
|
weight: 0.40,
|
|
}, client);
|
|
}
|
|
|
|
// 5. Listener-behavior writer: back-to-back play within 30 min → weak edges
|
|
// Resolve artist IDs for current track and previous track, then write
|
|
// alias_of (same artist, different name) or same_scene_as (different artists).
|
|
// Reads track_artists_v2 (claim-fusion-backed), NOT the legacy
|
|
// track_artists table — the vibe engine reads claim_fusion, so writing
|
|
// claims keyed off a possibly-stale legacy table silently stops these
|
|
// edges from being written for newly-enriched tracks.
|
|
const currentArtist = await client.query(
|
|
`SELECT tv.artist_id, a.normalized_name
|
|
FROM track_artists_v2 tv
|
|
JOIN artists a ON a.id = tv.artist_id
|
|
WHERE tv.track_id = $1 AND tv.role = 'main'
|
|
ORDER BY tv.confidence DESC
|
|
LIMIT 1`,
|
|
[trackId]
|
|
);
|
|
const currentArtistRow = currentArtist.rows[0] as { artist_id: string; normalized_name: string } | undefined;
|
|
|
|
if (currentArtistRow) {
|
|
// Previous completed play's track + the gap in minutes, in one query
|
|
// via LAG() instead of a separate prev-track lookup + gap lookup.
|
|
const prevPlay = await client.query(
|
|
`SELECT prev_track_id, EXTRACT(EPOCH FROM (played_at - prev_played_at)) / 60 AS min_gap
|
|
FROM (
|
|
SELECT track_id, played_at,
|
|
LAG(track_id) OVER (ORDER BY played_at) AS prev_track_id,
|
|
LAG(played_at) OVER (ORDER BY played_at) AS prev_played_at
|
|
FROM play_history
|
|
WHERE user_id = $1 AND completed = true
|
|
) w
|
|
WHERE w.track_id = $2
|
|
ORDER BY w.played_at DESC
|
|
LIMIT 1`,
|
|
[userId, trackId]
|
|
);
|
|
const prevTrackId = prevPlay.rows[0]?.prev_track_id as string | undefined;
|
|
const gapMinutes = prevPlay.rows[0]?.min_gap as number | undefined;
|
|
|
|
if (prevTrackId && gapMinutes !== undefined && gapMinutes <= 30) {
|
|
const prevArtist = await client.query(
|
|
`SELECT tv.artist_id, a.normalized_name
|
|
FROM track_artists_v2 tv
|
|
JOIN artists a ON a.id = tv.artist_id
|
|
WHERE tv.track_id = $1 AND tv.role = 'main'
|
|
ORDER BY tv.confidence DESC
|
|
LIMIT 1`,
|
|
[prevTrackId]
|
|
);
|
|
const prevArtistRow = prevArtist.rows[0] as { artist_id: string; normalized_name: string } | undefined;
|
|
|
|
if (prevArtistRow) {
|
|
if (prevArtistRow.normalized_name === currentArtistRow.normalized_name) {
|
|
// Same normalized artist name → weak alias_of
|
|
await this.upsertClaim({
|
|
user_id: userId,
|
|
subject_type: 'artist',
|
|
subject_id: prevArtistRow.artist_id,
|
|
predicate: 'alias_of',
|
|
object_type: 'artist',
|
|
object_id: currentArtistRow.artist_id,
|
|
source: 'listener_behavior',
|
|
confidence: 0.2,
|
|
}, client);
|
|
} else {
|
|
// Different artists played back-to-back → weak same_scene_as
|
|
await this.upsertClaim({
|
|
user_id: userId,
|
|
subject_type: 'artist',
|
|
subject_id: prevArtistRow.artist_id,
|
|
predicate: 'same_scene_as',
|
|
object_type: 'artist',
|
|
object_id: currentArtistRow.artist_id,
|
|
source: 'listener_behavior',
|
|
confidence: 0.3,
|
|
}, client);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return historyId;
|
|
});
|
|
}
|
|
|
|
async recordSkip(userId: string, trackId: string): Promise<void> {
|
|
// Skips do NOT move the center (transient per spec). Also writes negative evidence.
|
|
await this.withTransaction(async (client) => {
|
|
await client.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [trackId]);
|
|
await client.query(
|
|
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'skipped')",
|
|
[userId, trackId]
|
|
);
|
|
// Write evidence: skip_quick → negative profile
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'skip_quick',
|
|
profile: 'negative',
|
|
weight: -0.20,
|
|
}, client);
|
|
});
|
|
}
|
|
|
|
async recordFeedback(userId: string, trackId: string, action: FeedbackAction): Promise<void> {
|
|
if (!FEEDBACK_ACTIONS.includes(action)) {
|
|
throw new Error(`Invalid feedback action: ${action}`);
|
|
}
|
|
await this.pgClient.query(
|
|
'INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, $3)',
|
|
[userId, trackId, action]
|
|
);
|
|
|
|
// Also write evidence for promoted/disliked signals
|
|
if (action === 'promoted') {
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'add_to_favorites',
|
|
profile: 'longterm',
|
|
weight: 0.60,
|
|
});
|
|
} else if (action === 'disliked') {
|
|
await this.recordTrackEvidence({
|
|
user_id: userId,
|
|
track_id: trackId,
|
|
signal: 'hidden',
|
|
profile: 'negative',
|
|
weight: -0.60,
|
|
});
|
|
}
|
|
}
|
|
|
|
async getHistory(userId: string, limit = 50): Promise<HistoryEntry[]> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT t.*, al.artwork_id, ph.played_at AS played_at, ph.completed AS completed, ph.id AS history_id, ph.batch_id AS batch_id
|
|
FROM play_history ph
|
|
JOIN tracks t ON t.id = ph.track_id
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE ph.user_id = $1
|
|
ORDER BY ph.played_at DESC
|
|
LIMIT $2`,
|
|
[userId, limit]
|
|
);
|
|
return res.rows as HistoryEntry[];
|
|
}
|
|
|
|
async createArtist(data: Artist): Promise<Artist> {
|
|
const res = await this.pgClient.query(
|
|
// `canonical_name` is NOT NULL with no default, so omitting it fails on a
|
|
// fresh volume (the live DB's column predates the constraint). It holds the
|
|
// DISPLAY name: the raw input, not normalize_artist()'s output, which
|
|
// truncates on `/` and a standalone `x` ("AC/DC" -> "AC"). Matches the
|
|
// convention in workers' scanner.service.resolveOrCreateArtist.
|
|
`INSERT INTO artists (name, canonical_name, mbid, discogs_id, image_path)
|
|
VALUES (normalize_artist($1), $2, $3, $4, $5) RETURNING *`,
|
|
[data.name, data.name?.trim() || data.name, data.mbid, data.discogs_id, data.image_path]
|
|
);
|
|
return res.rows[0];
|
|
}
|
|
|
|
async updateArtist(id: string, data: Partial<Artist>): Promise<Artist> {
|
|
const fields = allowedFields('artists', data);
|
|
if (fields.length === 0) throw new Error('No fields to update');
|
|
|
|
// Normalize name if it's being updated (the normalized_name generated column
|
|
// handles it automatically, but we want the stored name itself to match).
|
|
if (data.name !== undefined) {
|
|
const norm = await this.pgClient.query('SELECT normalize_artist($1) AS n', [data.name]);
|
|
data.name = norm.rows[0].n;
|
|
}
|
|
|
|
const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', ');
|
|
const values = fields.map(f => data[f as keyof Artist]);
|
|
|
|
const res = await this.pgClient.query(
|
|
`UPDATE artists SET ${setClause} WHERE id = $1 RETURNING *`,
|
|
[id, ...values]
|
|
);
|
|
return res.rows[0];
|
|
}
|
|
|
|
async deleteArtist(id: string): Promise<void> {
|
|
await this.pgClient.query('DELETE FROM artists WHERE id = $1', [id]);
|
|
}
|
|
|
|
async createAlbum(data: Album): Promise<Album> {
|
|
const res = await this.pgClient.query(
|
|
`INSERT INTO albums (artist_id, title, year, release_date, artwork_id)
|
|
VALUES ($1, $2, $3, $4::date, $5) RETURNING *`,
|
|
[data.artist_id, data.title, data.year, data.release_date ?? null, data.artwork_id]
|
|
);
|
|
return res.rows[0];
|
|
}
|
|
|
|
async updateAlbum(id: string, data: Partial<Album>): Promise<Album> {
|
|
const fields = allowedFields('albums', data);
|
|
if (fields.length === 0) throw new Error('No fields to update');
|
|
|
|
const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', ');
|
|
const values = fields.map(f => (data as any)[f]);
|
|
|
|
const res = await this.pgClient.query(
|
|
`UPDATE albums SET ${setClause} WHERE id = $1 RETURNING *`,
|
|
[id, ...values]
|
|
);
|
|
return res.rows[0];
|
|
}
|
|
|
|
async deleteAlbum(id: string): Promise<void> {
|
|
await this.pgClient.query('DELETE FROM albums WHERE id = $1', [id]);
|
|
}
|
|
|
|
async getTrackById(id: string): Promise<Track | null> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT t.*, al.artwork_id FROM tracks t
|
|
LEFT JOIN albums al ON al.id = t.album_id
|
|
WHERE t.id = $1`, [id]);
|
|
return (res.rows[0] as Track) || null;
|
|
}
|
|
|
|
async getTrackLyrics(trackId: string): Promise<{ lyrics_text: string | null; synced_lyrics: unknown | null; provider: string | null } | null> {
|
|
const res = await this.pgClient.query(
|
|
'SELECT lyrics_text, synced_lyrics, provider FROM track_lyrics WHERE track_id = $1',
|
|
[trackId]
|
|
);
|
|
return res.rows[0] ?? null;
|
|
}
|
|
|
|
async createTrack(data: Track): Promise<Track> {
|
|
const res = await this.pgClient.query(
|
|
`INSERT INTO tracks (path, hash, title, artist, album_id, duration, state, source_type)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
|
[data.path, data.hash, data.title, data.artist, data.album_id, data.duration, data.state, data.source_type]
|
|
);
|
|
return res.rows[0];
|
|
}
|
|
|
|
async updateTrack(id: string, data: Partial<Track>): Promise<Track> {
|
|
const fields = allowedFields('tracks', data);
|
|
if (fields.length === 0) throw new Error('No fields to update');
|
|
|
|
const setClause = fields.map((f, i) => `"${f}" = $${i + 2}`).join(', ');
|
|
const values = fields.map(f => (data as any)[f]);
|
|
|
|
const res = await this.pgClient.query(
|
|
`UPDATE tracks SET ${setClause} WHERE id = $1 RETURNING *`,
|
|
[id, ...values]
|
|
);
|
|
return res.rows[0];
|
|
}
|
|
|
|
async deleteTrack(id: string): Promise<void> {
|
|
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [id]);
|
|
}
|
|
|
|
/**
|
|
* Permanently delete a disliked track, skipping the grace period. Cascade-
|
|
* deletes the DB record (which also removes the dislikes row), writes the
|
|
* 'deleted_permanent' audit row, and hands the file itself to the worker.
|
|
*
|
|
* This method does NOT unlink. The backend mounts /music `:ro` on purpose —
|
|
* nothing reachable from an HTTP request may write to the library — so the
|
|
* unlink is queued as a pending_file_deletions row and performed by the
|
|
* cleanup sweep in the worker, the one process with an rw mount, and only when
|
|
* MUZICK_ALLOW_HARD_DELETE is on. Until then the row is visible evidence of an
|
|
* orphaned file rather than a lost one.
|
|
*
|
|
* Ordering: everything durable commits in one transaction, and the
|
|
* irreversible filesystem act happens strictly afterwards. The previous
|
|
* implementation unlinked first, which on this deployment meant EROFS and a
|
|
* 500 with the track still present.
|
|
*
|
|
* `filePath` is accepted for call-site compatibility but the path is re-read
|
|
* from the row under the transaction, so the queued deletion can never target
|
|
* a stale or caller-supplied path.
|
|
*/
|
|
async permanentlyDeleteTrack(userId: string, trackId: string, _filePath?: string): Promise<void> {
|
|
await this.withTransaction(async (client) => {
|
|
const trackRes = await client.query(
|
|
'SELECT path, title, artist FROM tracks WHERE id = $1 FOR UPDATE',
|
|
[trackId]
|
|
);
|
|
const track = trackRes.rows[0] as
|
|
| { path: string; title: string | null; artist: string | null }
|
|
| undefined;
|
|
if (!track) return; // Already gone; nothing to audit or unlink.
|
|
|
|
// Denormalised identity: track_id is ON DELETE SET NULL, so without these
|
|
// the surviving audit row would not say which track was destroyed.
|
|
await client.query(
|
|
`INSERT INTO feedback (user_id, track_id, action, track_path, track_title, track_artist)
|
|
VALUES ($1, $2, 'deleted_permanent', $3, $4, $5)`,
|
|
[userId, trackId, track.path, track.title, track.artist]
|
|
);
|
|
|
|
await client.query(
|
|
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
|
|
[track.path, trackId, track.title, track.artist]
|
|
);
|
|
|
|
// CASCADE deletes dislikes, play_history, track_genre, etc.
|
|
await client.query('DELETE FROM tracks WHERE id = $1', [trackId]);
|
|
});
|
|
|
|
// Strongest negative signal (spec §B.3). entity_id has no FK to tracks, so
|
|
// the row survives the deletion. Outside the transaction: a failure here
|
|
// must not resurrect an already-audited deletion.
|
|
await this.recordEvidence({
|
|
user_id: userId,
|
|
entity_type: 'track',
|
|
entity_id: trackId,
|
|
signal: 'manual_deleted',
|
|
profile: 'negative',
|
|
weight: -0.90,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Return duplicate track groups, keyed by the given mode.
|
|
*
|
|
* `hash` (default) — groups of tracks with the same content hash (byte-
|
|
* identical duplicates). Excludes placeholder-hash rows that were written
|
|
* before proper hashing existed.
|
|
*
|
|
* `title-artist` — groups of tracks with the same normalised title AND
|
|
* normalised artist across different albums (catches the same song appearing
|
|
* in a compilation or reissue). Excludes same-hash groups since those are
|
|
* already caught by the hash mode.
|
|
*
|
|
* Each group returned as `{ key, tracks }` where `key` is the hash (mode
|
|
* `hash`) or `"title // artist"` (mode `title-artist`).
|
|
*/
|
|
async getDuplicateGroups(
|
|
mode: 'hash' | 'title-artist' = 'hash'
|
|
): Promise<{ key: string; tracks: Track[] }[]> {
|
|
if (mode === 'title-artist') {
|
|
const res = await this.pgClient.query<Track & { normalized_title: string; normalized_artist: string }>(
|
|
`SELECT t.*, LOWER(TRIM(t.title)) AS normalized_title
|
|
FROM tracks t
|
|
JOIN (
|
|
SELECT LOWER(TRIM(title)) AS nt, normalized_artist AS na
|
|
FROM tracks
|
|
WHERE state = 'LIBRARY'
|
|
AND hash IS NOT NULL AND hash <> '' AND hash <> 'placeholder-hash'
|
|
AND normalized_artist IS NOT NULL AND normalized_artist <> ''
|
|
GROUP BY nt, na
|
|
HAVING COUNT(*) > 1
|
|
) dup ON LOWER(TRIM(t.title)) = dup.nt AND t.normalized_artist = dup.na
|
|
WHERE t.state = 'LIBRARY'
|
|
AND t.hash IS NOT NULL AND t.hash <> '' AND t.hash <> 'placeholder-hash'
|
|
AND t.normalized_artist IS NOT NULL AND t.normalized_artist <> ''
|
|
ORDER BY dup.nt, dup.na, t.play_count DESC, t.last_played_at DESC NULLS LAST`
|
|
);
|
|
|
|
const groups = new Map<string, Track[]>();
|
|
for (const row of res.rows) {
|
|
const key = `${row.normalized_title} // ${row.normalized_artist}`;
|
|
const list = groups.get(key) ?? [];
|
|
list.push(row);
|
|
groups.set(key, list);
|
|
}
|
|
|
|
// Exclude groups where all tracks have the same hash (hash dedup already
|
|
// covers those).
|
|
const result: { key: string; tracks: Track[] }[] = [];
|
|
for (const [key, tracks] of groups) {
|
|
const uniqueHashes = new Set(tracks.map((t) => t.hash));
|
|
if (uniqueHashes.size <= 1) continue;
|
|
result.push({ key, tracks });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Default: hash-based dedup.
|
|
const res = await this.pgClient.query<Track & { hash: string }>(
|
|
`SELECT t.*
|
|
FROM tracks t
|
|
JOIN (
|
|
SELECT hash FROM tracks
|
|
WHERE hash IS NOT NULL AND hash <> '' AND hash <> 'placeholder-hash'
|
|
GROUP BY hash HAVING COUNT(*) > 1
|
|
) dup ON dup.hash = t.hash
|
|
ORDER BY t.hash, t.play_count DESC, t.last_played_at DESC NULLS LAST`
|
|
);
|
|
const groups = new Map<string, Track[]>();
|
|
for (const row of res.rows) {
|
|
const list = groups.get(row.hash) ?? [];
|
|
list.push(row);
|
|
groups.set(row.hash, list);
|
|
}
|
|
return [...groups.entries()].map(([key, tracks]) => ({ key, tracks }));
|
|
}
|
|
|
|
/**
|
|
* Keep `keepId`, re-parent its play history / feedback onto it, delete the
|
|
* remaining files from disk, then hard-delete the rest. All inside a
|
|
* transaction so a partial failure leaves no orphans.
|
|
*
|
|
* Like permanentlyDeleteTrack(), this no longer unlinks: the loser files are
|
|
* queued into pending_file_deletions inside the same transaction and removed
|
|
* later by the worker's gated cleanup sweep. Previously the unlink ran BEFORE
|
|
* the transaction and rethrew anything other than ENOENT, so on this
|
|
* deployment (/music mounted `:ro` in the backend) every dedup merge failed
|
|
* with EROFS before touching the DB at all.
|
|
*/
|
|
async mergeDuplicates(keepId: string, deleteIds: string[]): Promise<void> {
|
|
// 1. Fetch paths + identity for the tracks being deleted (before they go).
|
|
const { rows: losers } = await this.pgClient.query<{
|
|
id: string;
|
|
path: string;
|
|
title: string | null;
|
|
artist: string | null;
|
|
}>(
|
|
`SELECT id, path, title, artist FROM tracks WHERE id = ANY($1::uuid[])`,
|
|
[deleteIds]
|
|
);
|
|
|
|
// 2. DB transaction: queue the file removals, re-parent history, delete rows.
|
|
await this.withTransaction(async (client) => {
|
|
for (const row of losers) {
|
|
await client.query(
|
|
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
|
|
[row.path, row.id, row.title, row.artist]
|
|
);
|
|
}
|
|
|
|
for (const id of deleteIds) {
|
|
await client.query(
|
|
`UPDATE play_history SET track_id = $1 WHERE track_id = $2`,
|
|
[keepId, id]
|
|
);
|
|
await client.query(
|
|
`UPDATE feedback SET track_id = $1 WHERE track_id = $2`,
|
|
[keepId, id]
|
|
);
|
|
await client.query(`DELETE FROM tracks WHERE id = $1`, [id]);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Advance a dislike to WARNED state: set warned_at and update state.
|
|
* Called by the cleanup sweep worker after the grace period expires.
|
|
*/
|
|
async markDislikeWarned(trackId: string): Promise<void> {
|
|
await this.pgClient.query(
|
|
`UPDATE dislikes SET warned_at = NOW(), state = 'WARNED' WHERE track_id = $1`,
|
|
[trackId]
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// v2 — System A: Knowledge Graph (probabilistic fusion)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* UPSERT a claim into the graph. Idempotent: same (subject, predicate, object,
|
|
* source, user_id) refreshes last_reinforced_at without duplicating.
|
|
*/
|
|
async upsertClaim(claim: {
|
|
user_id?: string | null;
|
|
subject_type: string;
|
|
subject_id: string;
|
|
predicate: string;
|
|
object_type: string;
|
|
object_id: string;
|
|
source: string;
|
|
confidence?: number;
|
|
raw?: unknown;
|
|
}, client?: Queryable): Promise<string> {
|
|
const res = await (client ?? this.pgClient).query(
|
|
`INSERT INTO claims (user_id, subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
|
DO UPDATE SET last_reinforced_at = NOW(), evidence_at = NOW(), confidence = $8, raw = COALESCE($9, claims.raw)
|
|
RETURNING id`,
|
|
[
|
|
claim.user_id ?? null,
|
|
claim.subject_type,
|
|
claim.subject_id,
|
|
claim.predicate,
|
|
claim.object_type,
|
|
claim.object_id,
|
|
claim.source,
|
|
claim.confidence ?? 1.0,
|
|
claim.raw ? JSON.stringify(claim.raw) : null,
|
|
]
|
|
);
|
|
return res.rows[0].id as string;
|
|
}
|
|
|
|
/**
|
|
* Batch UPSERT claims. Wraps multiple upsertClaim calls in a transaction.
|
|
*/
|
|
async upsertClaims(claims: Parameters<DbService['upsertClaim']>[0][]): Promise<string[]> {
|
|
return this.withTransaction(async (client) => {
|
|
const ids: string[] = [];
|
|
for (const claim of claims) {
|
|
ids.push(await this.upsertClaim(claim, client));
|
|
}
|
|
return ids;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get claims by subject (entity + predicate filter).
|
|
*/
|
|
async getClaimsBySubject(
|
|
subjectType: string,
|
|
subjectId: string,
|
|
predicate?: string,
|
|
userId?: string
|
|
): Promise<Claim[]> {
|
|
let sql = `SELECT * FROM claims WHERE subject_type = $1 AND subject_id = $2`;
|
|
const params: unknown[] = [subjectType, subjectId];
|
|
let idx = 3;
|
|
|
|
if (predicate) {
|
|
sql += ` AND predicate = $${idx}`;
|
|
params.push(predicate);
|
|
idx++;
|
|
}
|
|
if (userId) {
|
|
sql += ` AND (user_id IS NULL OR user_id = $${idx})`;
|
|
params.push(userId);
|
|
}
|
|
|
|
sql += ` ORDER BY last_reinforced_at DESC`;
|
|
const res = await this.pgClient.query(sql, params);
|
|
return res.rows as Claim[];
|
|
}
|
|
|
|
/**
|
|
* Get fused value for a (subject, predicate, object) triple, optionally
|
|
* scoped to a user (includes user-keyed claims).
|
|
*/
|
|
async getFusedValue(
|
|
subjectType: string,
|
|
subjectId: string,
|
|
predicate: string,
|
|
objectType: string,
|
|
objectId: string,
|
|
userId?: string
|
|
): Promise<number> {
|
|
let sql = `
|
|
SELECT COALESCE(SUM(st.trust * c.confidence *
|
|
GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0)), 0) AS fused
|
|
FROM claims c
|
|
JOIN source_trust st ON st.key = c.source
|
|
WHERE c.subject_type = $1 AND c.subject_id = $2
|
|
AND c.predicate = $3
|
|
AND c.object_type = $4 AND c.object_id = $5
|
|
`;
|
|
const params: unknown[] = [subjectType, subjectId, predicate, objectType, objectId];
|
|
|
|
if (userId) {
|
|
sql += ` AND (c.user_id IS NULL OR c.user_id = $6)`;
|
|
params.push(userId);
|
|
} else {
|
|
sql += ` AND c.user_id IS NULL`;
|
|
}
|
|
|
|
const res = await this.pgClient.query(sql, params);
|
|
return (res.rows[0]?.fused as number) ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Get fused artist credits for a track, returning the same shape as the old
|
|
* attachArtists() for backward compatibility, but sourced from claim_fusion.
|
|
*/
|
|
async getFusedTrackArtists(trackId: string, userId?: string): Promise<TrackArtist[]> {
|
|
let sql = `
|
|
SELECT a.id, a.name,
|
|
CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role,
|
|
cf.fused_value AS confidence
|
|
FROM claim_fusion cf
|
|
JOIN artists a ON a.id = cf.object_id
|
|
WHERE cf.subject_type = 'track' AND cf.subject_id = $1
|
|
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
|
AND cf.object_type = 'artist'
|
|
`;
|
|
const params: unknown[] = [trackId];
|
|
|
|
if (userId) {
|
|
sql += ` AND cf.user_id IN ('00000000-0000-0000-0000-000000000000', $2) ORDER BY cf.fused_value DESC, cf.predicate`;
|
|
params.push(userId);
|
|
} else {
|
|
sql += ` AND cf.user_id = '00000000-0000-0000-0000-000000000000' ORDER BY cf.fused_value DESC, cf.predicate`;
|
|
}
|
|
|
|
const res = await this.pgClient.query(sql, params);
|
|
return res.rows as TrackArtist[];
|
|
}
|
|
|
|
// =========================================================================
|
|
// v2 — System B: Listener Model
|
|
// =========================================================================
|
|
|
|
private beliefDimensionForSignal(signal: string): string {
|
|
return signal === 'play_of_never_seen' ? 'novelty_tolerance' : 'affinity';
|
|
}
|
|
|
|
/**
|
|
* Resolve the durable identities represented by a track. Artist credits use
|
|
* the fusion-backed view (with the legacy table as a fallback during an
|
|
* enrichment transition); genres and audio features are direct metadata.
|
|
*/
|
|
private async getTrackBeliefTargets(trackId: string, client?: Queryable): Promise<Array<{
|
|
entity_type: 'artist' | 'genre' | 'audio';
|
|
entity_id: string;
|
|
factor: number;
|
|
context: Record<string, unknown>;
|
|
}>> {
|
|
const queryable = client ?? this.pgClient;
|
|
const targets: Array<{
|
|
entity_type: 'artist' | 'genre' | 'audio';
|
|
entity_id: string;
|
|
factor: number;
|
|
context: Record<string, unknown>;
|
|
}> = [];
|
|
|
|
const identities = await queryable.query(
|
|
`WITH artist_ids AS (
|
|
SELECT artist_id FROM track_artists_v2 WHERE track_id = $1
|
|
UNION
|
|
SELECT artist_id FROM track_artists WHERE track_id = $1
|
|
)
|
|
SELECT 'artist' AS entity_type, artist_id AS entity_id FROM artist_ids
|
|
UNION ALL
|
|
SELECT 'genre' AS entity_type, genre_id AS entity_id
|
|
FROM track_genre WHERE track_id = $1`,
|
|
[trackId]
|
|
);
|
|
for (const row of identities.rows as Array<{ entity_type: 'artist' | 'genre'; entity_id: string }>) {
|
|
targets.push({
|
|
entity_type: row.entity_type,
|
|
entity_id: row.entity_id,
|
|
// An explicit favourite should be enough to form a usable comfort
|
|
// artist belief; ordinary completed plays still accumulate gradually.
|
|
factor: row.entity_type === 'artist' ? 0.90 : 0.45,
|
|
context: { source_track_id: trackId, association: row.entity_type },
|
|
});
|
|
}
|
|
|
|
const featuresRes = await queryable.query(
|
|
`SELECT energy, bpm, valence
|
|
FROM track_audio_features
|
|
WHERE track_id = $1`,
|
|
[trackId]
|
|
);
|
|
const features = featuresRes.rows[0] as { energy?: number | null; bpm?: number | null; valence?: number | null } | undefined;
|
|
if (!features) return targets;
|
|
|
|
const addAudioBucket = (dimension: 'energy' | 'bpm' | 'valence', bucket: string) => {
|
|
targets.push({
|
|
entity_type: 'audio',
|
|
entity_id: bucket,
|
|
factor: 0.30,
|
|
context: { source_track_id: trackId, association: 'audio', dimension },
|
|
});
|
|
};
|
|
if (typeof features.energy === 'number' && Number.isFinite(features.energy)) {
|
|
addAudioBucket('energy', features.energy < 0.34
|
|
? AUDIO_PREFERENCE_BUCKETS.energy.low
|
|
: features.energy < 0.67 ? AUDIO_PREFERENCE_BUCKETS.energy.medium : AUDIO_PREFERENCE_BUCKETS.energy.high);
|
|
}
|
|
if (typeof features.bpm === 'number' && Number.isFinite(features.bpm) && features.bpm > 0) {
|
|
addAudioBucket('bpm', features.bpm < 90
|
|
? AUDIO_PREFERENCE_BUCKETS.bpm.slow
|
|
: features.bpm <= 140 ? AUDIO_PREFERENCE_BUCKETS.bpm.medium : AUDIO_PREFERENCE_BUCKETS.bpm.fast);
|
|
}
|
|
if (typeof features.valence === 'number' && Number.isFinite(features.valence)) {
|
|
addAudioBucket('valence', features.valence < 0.34
|
|
? AUDIO_PREFERENCE_BUCKETS.valence.low
|
|
: features.valence < 0.67 ? AUDIO_PREFERENCE_BUCKETS.valence.neutral : AUDIO_PREFERENCE_BUCKETS.valence.high);
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
/**
|
|
* Append the track-level event, then project it onto the track's meaningful
|
|
* shared identities. The original event remains the canonical audit record;
|
|
* projected evidence makes artist/genre/audio affinity directly queryable by
|
|
* Vibe generators. Callers pass their transaction client to keep the event
|
|
* and every derived belief atomic.
|
|
*/
|
|
async recordTrackEvidence(evidence: {
|
|
user_id: string;
|
|
track_id: string;
|
|
signal: string;
|
|
profile: string;
|
|
weight: number;
|
|
dimension?: string;
|
|
context?: Record<string, unknown>;
|
|
}, client?: Queryable): Promise<string> {
|
|
const { track_id: trackId, ...event } = evidence;
|
|
const id = await this.recordEvidence({
|
|
...event,
|
|
entity_type: 'track',
|
|
entity_id: trackId,
|
|
}, client);
|
|
const targets = await this.getTrackBeliefTargets(trackId, client);
|
|
for (const target of targets) {
|
|
await this.recordEvidence({
|
|
user_id: event.user_id,
|
|
entity_type: target.entity_type,
|
|
entity_id: target.entity_id,
|
|
signal: event.signal,
|
|
profile: event.profile,
|
|
weight: event.weight * target.factor,
|
|
dimension: event.dimension,
|
|
context: { ...event.context, ...target.context },
|
|
}, client);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Record evidence (append-only). Writes a signal into the evidence stream.
|
|
*/
|
|
async recordEvidence(evidence: {
|
|
user_id: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
signal: string;
|
|
profile: string;
|
|
weight: number;
|
|
dimension?: string;
|
|
context?: unknown;
|
|
}, client?: Queryable): Promise<string> {
|
|
const res = await (client ?? this.pgClient).query(
|
|
`INSERT INTO evidence (user_id, entity_type, entity_id, signal, profile, weight, context)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
|
[
|
|
evidence.user_id,
|
|
evidence.entity_type,
|
|
evidence.entity_id,
|
|
evidence.signal,
|
|
evidence.profile,
|
|
evidence.weight,
|
|
evidence.context ? JSON.stringify(evidence.context) : null,
|
|
]
|
|
);
|
|
const id = res.rows[0].id as string;
|
|
|
|
// Derive the belief dimension from the signal. Per spec §B.3, every
|
|
// signal feeds the 'affinity' dimension EXCEPT 'play_of_never_seen',
|
|
// 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 = evidence.dimension ?? this.beliefDimensionForSignal(evidence.signal);
|
|
await this.updateListenerBelief({
|
|
user_id: evidence.user_id,
|
|
profile: evidence.profile,
|
|
entity_type: evidence.entity_type,
|
|
entity_id: evidence.entity_id,
|
|
dimension,
|
|
value_delta: evidence.weight,
|
|
confidence_delta: 0.05,
|
|
}, client);
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Batch record evidence. All or nothing.
|
|
*/
|
|
async recordEvidenceBatch(
|
|
evidenceList: Parameters<DbService['recordEvidence']>[0][]
|
|
): Promise<string[]> {
|
|
return this.withTransaction(async (client) => {
|
|
const ids: string[] = [];
|
|
for (const ev of evidenceList) {
|
|
ids.push(await this.recordEvidence(ev, client));
|
|
}
|
|
return ids;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rebuild only the derived shared-preference layer from durable local
|
|
* interaction history. This intentionally does not append new evidence (the
|
|
* evidence log is an audit stream) and does not replace track beliefs. It is
|
|
* safe to run repeatedly after deploying propagation or after enrichment has
|
|
* added artist/genre/audio metadata to old tracks.
|
|
*/
|
|
async rebuildDerivedListenerBeliefs(userId: string): Promise<{ interactions: number; beliefs: number }> {
|
|
return this.withTransaction(async (client) => {
|
|
await client.query(
|
|
`DELETE FROM listener_beliefs
|
|
WHERE user_id = $1 AND entity_type IN ('artist', 'genre', 'audio')`,
|
|
[userId]
|
|
);
|
|
|
|
const interactionRes = await client.query(
|
|
`SELECT track_id, signal, profile, weight
|
|
FROM (
|
|
SELECT ph.track_id, ph.played_at AS occurred_at,
|
|
'playback_completed'::text AS signal,
|
|
'longterm'::text AS profile,
|
|
0.10::real AS weight
|
|
FROM play_history ph
|
|
WHERE ph.user_id = $1 AND ph.completed = true
|
|
|
|
UNION ALL
|
|
|
|
SELECT replay.track_id, replay.played_at AS occurred_at,
|
|
'replay_within_24h'::text AS signal,
|
|
'longterm'::text AS profile,
|
|
0.25::real AS weight
|
|
FROM (
|
|
SELECT track_id, played_at,
|
|
COUNT(*) OVER (
|
|
PARTITION BY track_id ORDER BY played_at
|
|
RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND CURRENT ROW
|
|
) AS recent_plays
|
|
FROM play_history WHERE user_id = $1 AND completed = true
|
|
) replay
|
|
WHERE replay.recent_plays > 1
|
|
|
|
UNION ALL
|
|
|
|
SELECT replay.track_id, replay.played_at AS occurred_at,
|
|
'replay_within_24h'::text AS signal,
|
|
'obsession'::text AS profile,
|
|
0.40::real AS weight
|
|
FROM (
|
|
SELECT track_id, played_at,
|
|
COUNT(*) OVER (
|
|
PARTITION BY track_id ORDER BY played_at
|
|
RANGE BETWEEN INTERVAL '24 hours' PRECEDING AND CURRENT ROW
|
|
) AS recent_plays
|
|
FROM play_history WHERE user_id = $1 AND completed = true
|
|
) replay
|
|
WHERE replay.recent_plays > 1
|
|
|
|
UNION ALL
|
|
|
|
SELECT fav.track_id, fav.created_at AS occurred_at,
|
|
'add_to_favorites'::text AS signal,
|
|
'longterm'::text AS profile,
|
|
0.60::real AS weight
|
|
FROM favorites fav WHERE fav.user_id = $1
|
|
|
|
UNION ALL
|
|
|
|
SELECT f.track_id, f.created_at AS occurred_at,
|
|
CASE f.action
|
|
WHEN 'promoted' THEN 'add_to_favorites'
|
|
WHEN 'disliked' THEN 'hidden'
|
|
WHEN 'skipped' THEN 'skip_quick'
|
|
END AS signal,
|
|
CASE WHEN f.action = 'promoted' THEN 'longterm' ELSE 'negative' END AS profile,
|
|
CASE f.action
|
|
WHEN 'promoted' THEN 0.60::real
|
|
WHEN 'disliked' THEN -0.60::real
|
|
WHEN 'skipped' THEN -0.20::real
|
|
END AS weight
|
|
FROM feedback f
|
|
WHERE f.user_id = $1
|
|
AND f.track_id IS NOT NULL
|
|
AND f.action IN ('promoted', 'disliked', 'skipped')
|
|
) interactions
|
|
ORDER BY occurred_at ASC`,
|
|
[userId]
|
|
);
|
|
|
|
let beliefs = 0;
|
|
for (const interaction of interactionRes.rows as Array<{
|
|
track_id: string;
|
|
signal: string;
|
|
profile: string;
|
|
weight: number;
|
|
}>) {
|
|
const targets = await this.getTrackBeliefTargets(interaction.track_id, client);
|
|
for (const target of targets) {
|
|
await this.updateListenerBelief({
|
|
user_id: userId,
|
|
profile: interaction.profile,
|
|
entity_type: target.entity_type,
|
|
entity_id: target.entity_id,
|
|
dimension: this.beliefDimensionForSignal(interaction.signal),
|
|
value_delta: interaction.weight * target.factor,
|
|
confidence_delta: 0.05,
|
|
}, client);
|
|
beliefs++;
|
|
}
|
|
}
|
|
return { interactions: interactionRes.rows.length, beliefs };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get listener beliefs for a user, optionally filtered by profile/entity.
|
|
*/
|
|
async getListenerBeliefs(params: {
|
|
userId: string;
|
|
profile?: string;
|
|
entityType?: string;
|
|
entityId?: string;
|
|
dimension?: string;
|
|
limit?: number;
|
|
orderBy?: 'value' | 'last_reinforced_at';
|
|
order?: 'ASC' | 'DESC';
|
|
}): Promise<ListenerBelief[]> {
|
|
const {
|
|
userId, profile, entityType, entityId, dimension,
|
|
limit = 50, orderBy = 'value', order = 'DESC',
|
|
} = params;
|
|
|
|
let sql = `SELECT * FROM listener_beliefs WHERE user_id = $1`;
|
|
const sqlParams: unknown[] = [userId];
|
|
let idx = 2;
|
|
|
|
if (profile) { sql += ` AND profile = $${idx}`; sqlParams.push(profile); idx++; }
|
|
if (entityType) { sql += ` AND entity_type = $${idx}`; sqlParams.push(entityType); idx++; }
|
|
if (entityId) { sql += ` AND entity_id = $${idx}`; sqlParams.push(entityId); idx++; }
|
|
if (dimension) { sql += ` AND dimension = $${idx}`; sqlParams.push(dimension); idx++; }
|
|
|
|
const validOrderBy = ['value', 'last_reinforced_at', 'confidence', 'evidence_count'];
|
|
const sortCol = validOrderBy.includes(orderBy) ? orderBy : 'value';
|
|
const sortOrder = order === 'ASC' ? 'ASC' : 'DESC';
|
|
sql += ` ORDER BY ${sortCol} ${sortOrder} LIMIT $${idx}`;
|
|
sqlParams.push(limit);
|
|
|
|
const res = await this.pgClient.query(sql, sqlParams);
|
|
return res.rows as ListenerBelief[];
|
|
}
|
|
|
|
/**
|
|
* UPSERT a listener belief. Updates value, confidence, and evidence_count.
|
|
* Implements the belief update formula from spec §B.4.
|
|
*/
|
|
async updateListenerBelief(params: {
|
|
user_id: string;
|
|
profile: string;
|
|
entity_type: string;
|
|
entity_id: string;
|
|
dimension: string;
|
|
value_delta: number;
|
|
confidence_delta?: number;
|
|
}, client?: Queryable): Promise<void> {
|
|
const { user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta = 0.05 } = params;
|
|
|
|
await (client ?? this.pgClient).query(
|
|
`INSERT INTO listener_beliefs (user_id, profile, entity_type, entity_id, dimension, value, confidence, evidence_count, last_reinforced_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW())
|
|
ON CONFLICT (user_id, profile, entity_type, entity_id, dimension)
|
|
DO UPDATE SET
|
|
value = GREATEST(-1.0, LEAST(1.0, listener_beliefs.value + $6 * (1.0 - listener_beliefs.confidence))),
|
|
confidence = GREATEST(0, LEAST(1.0, listener_beliefs.confidence + $7)),
|
|
evidence_count = listener_beliefs.evidence_count + 1,
|
|
last_reinforced_at = NOW()`,
|
|
[user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta]
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// v2 — System D: Session support
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Create a new session state row.
|
|
*/
|
|
async createSessionState(
|
|
userId: string,
|
|
context?: string,
|
|
stateVector?: Record<string, unknown>,
|
|
sessionId?: string
|
|
): Promise<string> {
|
|
const res = await this.pgClient.query(
|
|
`INSERT INTO session_state (session_id, user_id, context, state_vector)
|
|
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4::jsonb)
|
|
RETURNING session_id`,
|
|
[sessionId ?? null, userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}']
|
|
);
|
|
return res.rows[0].session_id as string;
|
|
}
|
|
|
|
/**
|
|
* Get the most recent session state for a user.
|
|
*/
|
|
async getLatestSessionState(userId: string): Promise<SessionState | null> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT * FROM session_state WHERE user_id = $1 ORDER BY last_interaction DESC LIMIT 1`,
|
|
[userId]
|
|
);
|
|
return (res.rows[0] as SessionState) || null;
|
|
}
|
|
|
|
/**
|
|
* Create the authoritative Vibe v2 session record. This intentionally does
|
|
* not create a legacy session_state row: callers can migrate to the durable
|
|
* ledger without changing the existing v2 endpoint contract first.
|
|
*/
|
|
async createVibeSession(params: {
|
|
userId: string;
|
|
policyVersion: string;
|
|
seedTrackId?: string | null;
|
|
context?: Record<string, unknown>;
|
|
profile?: {
|
|
goals: Record<string, unknown>;
|
|
explorationCoefficient: number;
|
|
discoveryRadius: number;
|
|
};
|
|
}): Promise<VibeSession> {
|
|
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.
|
|
await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [params.userId]);
|
|
// Lock every active session first. This makes concurrent starts converge
|
|
// on one active durable session instead of creating overlapping streams.
|
|
const active = await client.query(
|
|
`SELECT id FROM vibe_sessions WHERE user_id = $1 AND status = 'active' FOR UPDATE`,
|
|
[params.userId],
|
|
);
|
|
if (active.rows.length > 0) {
|
|
const replaced = await client.query(
|
|
`UPDATE vibe_sessions
|
|
SET status = 'replaced', ended_at = NOW(), last_event_at = NOW()
|
|
WHERE user_id = $1 AND status = 'active'
|
|
RETURNING id`,
|
|
[params.userId],
|
|
);
|
|
for (const session of replaced.rows as Array<{ id: string }>) {
|
|
await client.query(
|
|
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
|
VALUES ($1, $2, 'session_ended', NOW(), '{"reason":"replaced"}'::jsonb)`,
|
|
[session.id, params.userId],
|
|
);
|
|
}
|
|
}
|
|
const res = await client.query(
|
|
`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 ? { ...params.context } : {}),
|
|
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;
|
|
});
|
|
}
|
|
|
|
/** Fetch a Vibe session only when it belongs to the requesting user. */
|
|
async getVibeSession(sessionId: string, userId: string): Promise<VibeSession | null> {
|
|
const res = await this.pgClient.query(
|
|
'SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2',
|
|
[sessionId, userId]
|
|
);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
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.
|
|
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::real ELSE $3::real END)),
|
|
discovery_radius = GREATEST(0.15, LEAST(0.9, 0.2 + (GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0::real ELSE $3::real 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::real > 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.
|
|
*/
|
|
async endVibeSession(
|
|
sessionId: string,
|
|
userId: string,
|
|
status: Extract<VibeSessionStatus, 'ended' | 'expired' | 'replaced'> = 'ended'
|
|
): Promise<VibeSession | null> {
|
|
const res = await this.pgClient.query(
|
|
`UPDATE vibe_sessions
|
|
SET status = CASE WHEN ended_at IS NULL THEN $3 ELSE status END,
|
|
ended_at = COALESCE(ended_at, NOW()),
|
|
last_event_at = CASE WHEN ended_at IS NULL THEN NOW() ELSE last_event_at END
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING *`,
|
|
[sessionId, userId, status]
|
|
);
|
|
return (res.rows[0] as VibeSession) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resume an owned paused/active session exactly once. The row lock makes
|
|
* the transition and its ledger entry inseparable and prevents retries from
|
|
* manufacturing a stream of session_resumed events.
|
|
*/
|
|
async resumeVibeSession(sessionId: string, userId: string): Promise<{ session: VibeSession; resumed: boolean }> {
|
|
return this.withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
[sessionId, userId]
|
|
);
|
|
const session = result.rows[0] as VibeSession | undefined;
|
|
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
|
|
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
|
|
throw new Error(`Cannot resume ${session.status} Vibe session`);
|
|
}
|
|
|
|
const prior = await client.query(
|
|
`SELECT 1 FROM vibe_events WHERE session_id = $1 AND type = 'session_resumed' LIMIT 1`,
|
|
[sessionId]
|
|
);
|
|
if (prior.rowCount) return { session, resumed: false };
|
|
|
|
const updated = await client.query(
|
|
`UPDATE vibe_sessions SET status = 'active', ended_at = NULL, last_event_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[sessionId]
|
|
);
|
|
const resumed = updated.rows[0] as VibeSession;
|
|
await client.query(
|
|
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
|
VALUES ($1, $2, 'session_resumed', NOW(), '{}'::jsonb)`,
|
|
[sessionId, userId]
|
|
);
|
|
return { session: resumed, resumed: true };
|
|
});
|
|
}
|
|
|
|
/** End a session and append its terminal event under one session-row lock. */
|
|
async endVibeSessionWithEvent(sessionId: string, userId: string): Promise<{ session: VibeSession; ended: boolean }> {
|
|
return this.withTransaction(async (client) => {
|
|
const result = await client.query(
|
|
`SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
[sessionId, userId]
|
|
);
|
|
const session = result.rows[0] as VibeSession | undefined;
|
|
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
|
|
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
|
|
return { session, ended: false };
|
|
}
|
|
await client.query(
|
|
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
|
VALUES ($1, $2, 'session_ended', NOW(), '{}'::jsonb)`,
|
|
[sessionId, userId]
|
|
);
|
|
const updated = await client.query(
|
|
`UPDATE vibe_sessions SET status = 'ended', ended_at = NOW(), last_event_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[sessionId]
|
|
);
|
|
return { session: updated.rows[0] as VibeSession, ended: true };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Append an immutable Vibe event. A supplied clientEventId is idempotent per
|
|
* session: a retry returns the original event and does not advance the
|
|
* session timestamp a second time. A missing id deliberately means a new,
|
|
* server-originated event.
|
|
*/
|
|
async recordVibeEvent(params: {
|
|
sessionId: string;
|
|
userId: string;
|
|
type: string;
|
|
clientEventId?: string | null;
|
|
trackId?: string | null;
|
|
occurredAt?: Date;
|
|
positionMs?: number | null;
|
|
durationMs?: number | null;
|
|
payload?: Record<string, unknown>;
|
|
}): Promise<RecordedVibeEvent> {
|
|
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
|
|
// race where ON CONFLICT observes a concurrent event but a later CTE
|
|
// cannot yet read it. The duplicate lookup happens after the lock, so an
|
|
// idempotent retry remains valid even after the session has ended.
|
|
const sessionRes = await client.query(
|
|
`SELECT id, status
|
|
FROM vibe_sessions
|
|
WHERE id = $1 AND user_id = $2
|
|
FOR UPDATE`,
|
|
[params.sessionId, params.userId]
|
|
);
|
|
const session = sessionRes.rows[0] as Pick<VibeSession, 'id' | 'status'> | undefined;
|
|
if (!session) {
|
|
throw new Error('Vibe session was not found or is not owned by this user');
|
|
}
|
|
|
|
if (params.clientEventId) {
|
|
const existingRes = await client.query(
|
|
`SELECT *
|
|
FROM vibe_events
|
|
WHERE session_id = $1 AND client_event_id = $2::uuid`,
|
|
[params.sessionId, params.clientEventId]
|
|
);
|
|
const existing = existingRes.rows[0] as VibeEvent | undefined;
|
|
if (existing) {
|
|
await this.projectVibeFeedback(existing, client);
|
|
return { event: existing, inserted: false };
|
|
}
|
|
}
|
|
|
|
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
|
|
throw new Error(`Cannot record a new event for ${session.status} Vibe session`);
|
|
}
|
|
|
|
const occurredAt = params.occurredAt?.toISOString() ?? null;
|
|
const insertRes = await client.query(
|
|
`INSERT INTO vibe_events
|
|
(client_event_id, session_id, user_id, track_id, type, occurred_at, position_ms, duration_ms, payload)
|
|
VALUES ($1::uuid, $2, $3, $4::uuid, $5, COALESCE($6::timestamptz, NOW()), $7, $8, $9::jsonb)
|
|
RETURNING *`,
|
|
[
|
|
params.clientEventId ?? null,
|
|
params.sessionId,
|
|
params.userId,
|
|
params.trackId ?? null,
|
|
params.type,
|
|
occurredAt,
|
|
params.positionMs ?? null,
|
|
params.durationMs ?? null,
|
|
JSON.stringify(params.payload ?? {}),
|
|
]
|
|
);
|
|
const event = insertRes.rows[0] as VibeEvent | undefined;
|
|
if (!event) {
|
|
throw new Error('Vibe event could not be recorded');
|
|
}
|
|
|
|
await this.projectVibeFeedback(event, client);
|
|
await client.query(
|
|
`UPDATE vibe_sessions
|
|
SET last_event_at = GREATEST(last_event_at, $2::timestamptz)
|
|
WHERE id = $1`,
|
|
[params.sessionId, event.occurred_at]
|
|
);
|
|
return { event, inserted: true };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Materialize Vibe feedback into the listener inputs used by the incumbent
|
|
* director. The projection marker and every write share the event's
|
|
* transaction, so a retry either sees the completed projection or performs
|
|
* it once; it can never double-count a completed/skip/dislike/kept signal.
|
|
*/
|
|
private async projectVibeFeedback(event: VibeEvent, client: PoolClient): Promise<void> {
|
|
if (!event.track_id || !['completed', 'skipped', 'disliked', 'kept'].includes(event.type)) return;
|
|
const projection = await client.query(
|
|
`INSERT INTO vibe_event_projections (event_id)
|
|
VALUES ($1)
|
|
ON CONFLICT (event_id) DO NOTHING
|
|
RETURNING event_id`,
|
|
[event.id],
|
|
);
|
|
if (!projection.rows[0]) return;
|
|
|
|
const occurredAt = event.occurred_at?.toISOString?.() ?? new Date().toISOString();
|
|
switch (event.type) {
|
|
case 'completed':
|
|
// The Vibe player is the one caller that knows how much was actually
|
|
// heard. position_ms is where playback reached; duration_ms is the
|
|
// fallback for a client that only reported the track length.
|
|
await client.query(
|
|
`INSERT INTO play_history (
|
|
user_id, track_id, completed, played_at, listened_ms, track_title, track_artist
|
|
)
|
|
SELECT $1::uuid, $2::uuid, true, $3::timestamptz, $4::int, t.title, t.artist
|
|
FROM (SELECT 1) AS always
|
|
LEFT JOIN tracks t ON t.id = $2::uuid`,
|
|
[event.user_id, event.track_id, occurredAt, event.position_ms ?? event.duration_ms ?? null],
|
|
);
|
|
await client.query(
|
|
`UPDATE tracks
|
|
SET play_count = play_count + 1, last_played_at = $2::timestamptz
|
|
WHERE id = $1`,
|
|
[event.track_id, occurredAt],
|
|
);
|
|
await this.recordTrackEvidence({
|
|
user_id: event.user_id,
|
|
track_id: event.track_id,
|
|
signal: 'playback_completed',
|
|
profile: 'longterm',
|
|
weight: 0.10,
|
|
context: { vibe_event_id: event.id, session_id: event.session_id },
|
|
}, client);
|
|
break;
|
|
case 'skipped':
|
|
await client.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [event.track_id]);
|
|
await client.query(
|
|
`INSERT INTO feedback (user_id, track_id, action, created_at)
|
|
VALUES ($1, $2, 'skipped', $3::timestamptz)`,
|
|
[event.user_id, event.track_id, occurredAt],
|
|
);
|
|
await this.recordTrackEvidence({
|
|
user_id: event.user_id,
|
|
track_id: event.track_id,
|
|
signal: 'skip_quick',
|
|
profile: 'negative',
|
|
weight: -0.20,
|
|
context: { vibe_event_id: event.id, session_id: event.session_id },
|
|
}, client);
|
|
break;
|
|
case 'disliked':
|
|
await client.query('UPDATE tracks SET dislike_count = dislike_count + 1 WHERE id = $1', [event.track_id]);
|
|
await client.query(
|
|
`INSERT INTO feedback (user_id, track_id, action, created_at)
|
|
VALUES ($1, $2, 'disliked', $3::timestamptz)`,
|
|
[event.user_id, event.track_id, occurredAt],
|
|
);
|
|
await this.recordTrackEvidence({
|
|
user_id: event.user_id,
|
|
track_id: event.track_id,
|
|
signal: 'hidden',
|
|
profile: 'negative',
|
|
weight: -0.60,
|
|
context: { vibe_event_id: event.id, session_id: event.session_id },
|
|
}, client);
|
|
break;
|
|
case 'kept':
|
|
await this.recordTrackEvidence({
|
|
user_id: event.user_id,
|
|
track_id: event.track_id,
|
|
signal: 'kept',
|
|
profile: 'longterm',
|
|
weight: 0.05,
|
|
context: { vibe_event_id: event.id, session_id: event.session_id },
|
|
}, client);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist one complete revision of a session plan atomically. The caller
|
|
* supplies the monotonically increasing version; session-level scheduling
|
|
* will own version allocation when the director is migrated to this ledger.
|
|
*/
|
|
async publishVibePlan(params: {
|
|
sessionId: string;
|
|
userId: string;
|
|
/** Supplying one is for the first revision; otherwise allocate the next. */
|
|
version?: number;
|
|
reason: string;
|
|
stateSnapshot: Record<string, unknown>;
|
|
objectiveSnapshot: Record<string, unknown>;
|
|
items: Array<Omit<VibePlanItem, 'plan_version_id'>>;
|
|
}): Promise<VibePlan> {
|
|
return this.withTransaction(async (client) => {
|
|
// The session lock is also the concurrency boundary for starts/ends and
|
|
// plan revisions. In particular, a slow initial planner cannot publish
|
|
// into a session a newer start has already replaced.
|
|
const sessionRes = await client.query(
|
|
`SELECT id, status FROM vibe_sessions
|
|
WHERE id = $1 AND user_id = $2
|
|
FOR UPDATE`,
|
|
[params.sessionId, params.userId],
|
|
);
|
|
const session = sessionRes.rows[0] as Pick<VibeSession, 'id' | 'status'> | undefined;
|
|
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
|
|
if (session.status !== 'active') {
|
|
throw new Error(`Cannot publish a plan for ${session.status} Vibe session`);
|
|
}
|
|
const version = params.version ?? Number((await client.query(
|
|
`SELECT COALESCE(MAX(version), 0) + 1 AS version
|
|
FROM vibe_plan_versions WHERE session_id = $1`,
|
|
[params.sessionId],
|
|
)).rows[0].version);
|
|
const header = await client.query(
|
|
`INSERT INTO vibe_plan_versions
|
|
(session_id, version, reason, state_snapshot, objective_snapshot)
|
|
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
|
|
RETURNING *`,
|
|
[
|
|
params.sessionId,
|
|
version,
|
|
params.reason,
|
|
JSON.stringify(params.stateSnapshot),
|
|
JSON.stringify(params.objectiveSnapshot),
|
|
],
|
|
);
|
|
const planVersion = header.rows[0] as VibePlan | undefined;
|
|
if (!planVersion) throw new Error('Vibe plan could not be published');
|
|
for (const item of params.items) {
|
|
await client.query(
|
|
`INSERT INTO vibe_plan_items
|
|
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
|
[
|
|
planVersion.id, item.ordinal, item.track_id, item.slot_role,
|
|
item.candidate_source, item.score, JSON.stringify(item.score_breakdown),
|
|
JSON.stringify(item.explanation), item.committed,
|
|
],
|
|
);
|
|
}
|
|
// Header/items and this ledger event deliberately commit together. A
|
|
// client retry can therefore find either neither or the same canonical
|
|
// revision; it can never observe a published event without its plan.
|
|
await client.query(
|
|
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
|
|
VALUES ($1, $2, 'plan_published', NOW(), $3::jsonb)`,
|
|
[params.sessionId, params.userId, JSON.stringify({
|
|
planVersion: planVersion.version,
|
|
planVersionId: planVersion.id,
|
|
reason: params.reason,
|
|
itemCount: params.items.length,
|
|
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],
|
|
);
|
|
return {
|
|
...planVersion,
|
|
items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })),
|
|
};
|
|
});
|
|
}
|
|
|
|
async persistVibePlan(params: {
|
|
sessionId: string;
|
|
userId: string;
|
|
version: number;
|
|
reason: string;
|
|
stateSnapshot: Record<string, unknown>;
|
|
objectiveSnapshot: Record<string, unknown>;
|
|
items: Array<Omit<VibePlanItem, 'plan_version_id'>>;
|
|
}): Promise<VibePlan> {
|
|
return this.withTransaction(async (client) => {
|
|
const header = await client.query(
|
|
`INSERT INTO vibe_plan_versions
|
|
(session_id, version, reason, state_snapshot, objective_snapshot)
|
|
SELECT s.id, $3, $4, $5::jsonb, $6::jsonb
|
|
FROM vibe_sessions s
|
|
WHERE s.id = $1 AND s.user_id = $2
|
|
RETURNING *`,
|
|
[
|
|
params.sessionId,
|
|
params.userId,
|
|
params.version,
|
|
params.reason,
|
|
JSON.stringify(params.stateSnapshot),
|
|
JSON.stringify(params.objectiveSnapshot),
|
|
]
|
|
);
|
|
const planVersion = header.rows[0] as VibePlan | undefined;
|
|
if (!planVersion) {
|
|
throw new Error('Vibe session was not found or is not owned by this user');
|
|
}
|
|
|
|
for (const item of params.items) {
|
|
await client.query(
|
|
`INSERT INTO vibe_plan_items
|
|
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
|
[
|
|
planVersion.id,
|
|
item.ordinal,
|
|
item.track_id,
|
|
item.slot_role,
|
|
item.candidate_source,
|
|
item.score,
|
|
JSON.stringify(item.score_breakdown),
|
|
JSON.stringify(item.explanation),
|
|
item.committed,
|
|
]
|
|
);
|
|
}
|
|
|
|
return { ...planVersion, items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })) };
|
|
});
|
|
}
|
|
|
|
/** Allocate and persist the next immutable revision while holding the session lock. */
|
|
async persistNextVibePlan(params: Omit<Parameters<DbService['persistVibePlan']>[0], 'version'>): Promise<VibePlan> {
|
|
return this.withTransaction(async (client) => {
|
|
const session = await client.query(
|
|
`SELECT id, status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
[params.sessionId, params.userId]
|
|
);
|
|
if (!session.rows[0]) throw new Error('Vibe session was not found or is not owned by this user');
|
|
if ((session.rows[0] as Pick<VibeSession, 'status'>).status !== 'active') {
|
|
throw new Error(`Cannot record a new event for ${(session.rows[0] as Pick<VibeSession, 'status'>).status} Vibe session`);
|
|
}
|
|
const versionResult = await client.query(
|
|
`SELECT COALESCE(MAX(version), 0) + 1 AS version FROM vibe_plan_versions WHERE session_id = $1`,
|
|
[params.sessionId]
|
|
);
|
|
const version = Number(versionResult.rows[0].version);
|
|
const header = await client.query(
|
|
`INSERT INTO vibe_plan_versions
|
|
(session_id, version, reason, state_snapshot, objective_snapshot)
|
|
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) RETURNING *`,
|
|
[params.sessionId, version, params.reason, JSON.stringify(params.stateSnapshot), JSON.stringify(params.objectiveSnapshot)]
|
|
);
|
|
const planVersion = header.rows[0] as VibePlan;
|
|
for (const item of params.items) {
|
|
await client.query(
|
|
`INSERT INTO vibe_plan_items
|
|
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
|
[planVersion.id, item.ordinal, item.track_id, item.slot_role, item.candidate_source,
|
|
item.score, JSON.stringify(item.score_breakdown), JSON.stringify(item.explanation), item.committed]
|
|
);
|
|
}
|
|
return { ...planVersion, items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })) };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Atomically commit one item from the latest plan. A version-aware request
|
|
* acts as an idempotency key: retrying the same expected version receives
|
|
* the original item, while a replaced plan is returned as stale without
|
|
* committing any old item. Calls without an expected version preserve the
|
|
* original legacy cursor behaviour.
|
|
*/
|
|
async serveNextVibePlanItem(
|
|
sessionId: string,
|
|
userId: string,
|
|
expectedPlanVersion?: number,
|
|
): Promise<{ item: VibePlanItem | null; stale: boolean }> {
|
|
return this.withTransaction(async (client) => {
|
|
const session = await client.query(
|
|
`SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
[sessionId, userId]
|
|
);
|
|
const row = session.rows[0] as Pick<VibeSession, 'status'> | undefined;
|
|
if (!row) throw new Error('Vibe session was not found or is not owned by this user');
|
|
if (row.status !== 'active') throw new Error(`Cannot record a new event for ${row.status} Vibe session`);
|
|
const latest = await client.query(
|
|
`SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`,
|
|
[sessionId],
|
|
);
|
|
const plan = latest.rows[0] as Pick<VibePlan, 'id' | 'version'> | undefined;
|
|
if (!plan) return { item: null, stale: false };
|
|
if (expectedPlanVersion !== undefined && expectedPlanVersion !== plan.version) {
|
|
return { item: null, stale: true };
|
|
}
|
|
|
|
if (expectedPlanVersion !== undefined) {
|
|
const prior = await client.query(
|
|
`SELECT i.*
|
|
FROM vibe_events e
|
|
JOIN vibe_plan_items i
|
|
ON i.plan_version_id = (e.payload->>'planVersionId')::uuid
|
|
AND i.ordinal = (e.payload->>'ordinal')::integer
|
|
WHERE e.session_id = $1
|
|
AND e.type = 'track_served'
|
|
AND e.payload->>'planVersion' = $2::text
|
|
ORDER BY e.occurred_at ASC
|
|
LIMIT 1`,
|
|
[sessionId, expectedPlanVersion],
|
|
);
|
|
const servedPreviously = prior.rows[0] as VibePlanItem | undefined;
|
|
if (servedPreviously) return { item: servedPreviously, stale: false };
|
|
}
|
|
const item = await client.query(
|
|
`WITH next_item AS (
|
|
SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i
|
|
WHERE i.plan_version_id = $1 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
|
|
)
|
|
UPDATE vibe_plan_items i SET committed = true
|
|
FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal
|
|
RETURNING i.*`,
|
|
[plan.id]
|
|
);
|
|
const served = item.rows[0] as VibePlanItem | undefined;
|
|
if (!served) return { item: null, stale: false };
|
|
await client.query(
|
|
`INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload)
|
|
VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`,
|
|
[sessionId, userId, served.track_id, JSON.stringify({
|
|
planVersion: plan.version,
|
|
planVersionId: served.plan_version_id,
|
|
ordinal: served.ordinal,
|
|
})]
|
|
);
|
|
await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]);
|
|
return { item: served, stale: false };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Advance an already-served item which the player could not resolve (for
|
|
* example, its file was hidden after the revision was published). Unlike a
|
|
* normal version-aware /next retry, this has a distinct client event id and
|
|
* therefore intentionally moves beyond the item previously served for that
|
|
* revision. The event records both the rejected item and the replacement so
|
|
* a lost response can be retried without consuming another plan item.
|
|
*/
|
|
async advancePastUnplayableVibePlanItem(
|
|
sessionId: string,
|
|
userId: string,
|
|
input: {
|
|
expectedPlanVersion: number;
|
|
planVersionId: string;
|
|
ordinal: number;
|
|
trackId: string;
|
|
eventId: string;
|
|
},
|
|
): Promise<{ item: VibePlanItem | null; stale: boolean }> {
|
|
return this.withTransaction(async (client) => {
|
|
const session = await client.query(
|
|
`SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
|
|
[sessionId, userId],
|
|
);
|
|
const sessionRow = session.rows[0] as Pick<VibeSession, 'status'> | undefined;
|
|
if (!sessionRow) throw new Error('Vibe session was not found or is not owned by this user');
|
|
|
|
const latest = await client.query(
|
|
`SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`,
|
|
[sessionId],
|
|
);
|
|
const plan = latest.rows[0] as Pick<VibePlan, 'id' | 'version'> | undefined;
|
|
if (!plan) return { item: null, stale: false };
|
|
if (plan.version !== input.expectedPlanVersion || plan.id !== input.planVersionId) {
|
|
return { item: null, stale: true };
|
|
}
|
|
|
|
// Idempotency is scoped to this explicit advancement operation, rather
|
|
// than overloading the version-aware /next retry which must keep
|
|
// returning the originally served item.
|
|
const prior = await client.query(
|
|
`SELECT type, payload
|
|
FROM vibe_events
|
|
WHERE session_id = $1 AND client_event_id = $2::uuid`,
|
|
[sessionId, input.eventId],
|
|
);
|
|
const priorEvent = prior.rows[0] as Pick<VibeEvent, 'type' | 'payload'> | undefined;
|
|
if (priorEvent && priorEvent.type !== 'playback_error') {
|
|
throw new Error('Vibe client event id was already used for a different event');
|
|
}
|
|
const priorPayload = priorEvent?.payload as Record<string, unknown> | undefined;
|
|
if (priorPayload) {
|
|
if (priorPayload.planVersionId !== input.planVersionId
|
|
|| priorPayload.ordinal !== input.ordinal
|
|
|| priorPayload.trackId !== input.trackId) {
|
|
throw new Error('Vibe playback-error event does not match the served plan item');
|
|
}
|
|
const advancedTo = priorPayload.advancedTo as { planVersionId?: unknown; ordinal?: unknown } | null | undefined;
|
|
if (!advancedTo || typeof advancedTo.planVersionId !== 'string' || !Number.isInteger(advancedTo.ordinal)) {
|
|
return { item: null, stale: false };
|
|
}
|
|
const replacement = await client.query(
|
|
`SELECT * FROM vibe_plan_items WHERE plan_version_id = $1 AND ordinal = $2`,
|
|
[advancedTo.planVersionId, advancedTo.ordinal],
|
|
);
|
|
return { item: (replacement.rows[0] as VibePlanItem | undefined) ?? null, stale: false };
|
|
}
|
|
|
|
if (sessionRow.status !== 'active') {
|
|
throw new Error(`Cannot record a new event for ${sessionRow.status} Vibe session`);
|
|
}
|
|
|
|
// A client may advance only the cursor it was just served. Checking for
|
|
// any historical serve event would let an old version-aware /next
|
|
// response consume whichever future item happens to be uncommitted.
|
|
const current = await client.query(
|
|
`SELECT i.track_id, i.ordinal
|
|
FROM vibe_events e
|
|
JOIN vibe_plan_items i
|
|
ON i.plan_version_id = $3::uuid
|
|
AND i.ordinal = (e.payload->>'ordinal')::integer
|
|
AND i.track_id = e.track_id
|
|
WHERE e.session_id = $1
|
|
AND e.type = 'track_served'
|
|
AND e.payload->>'planVersion' = $2::text
|
|
AND e.payload->>'planVersionId' = $3
|
|
ORDER BY i.ordinal DESC
|
|
LIMIT 1`,
|
|
[sessionId, input.expectedPlanVersion, input.planVersionId],
|
|
);
|
|
const currentCursor = current.rows[0] as Pick<VibePlanItem, 'track_id' | 'ordinal'> | undefined;
|
|
if (currentCursor?.track_id !== input.trackId || currentCursor.ordinal !== input.ordinal) {
|
|
throw new Error('Vibe plan item is not the current served cursor for this session revision');
|
|
}
|
|
|
|
const payload = {
|
|
kind: 'unplayable_plan_item',
|
|
planVersion: input.expectedPlanVersion,
|
|
planVersionId: input.planVersionId,
|
|
ordinal: input.ordinal,
|
|
trackId: input.trackId,
|
|
};
|
|
const playbackError = await client.query(
|
|
`INSERT INTO vibe_events (client_event_id, session_id, user_id, track_id, type, occurred_at, payload)
|
|
VALUES ($1::uuid, $2, $3, $4::uuid, 'playback_error', NOW(), $5::jsonb)
|
|
RETURNING id`,
|
|
[input.eventId, sessionId, userId, input.trackId, JSON.stringify(payload)],
|
|
);
|
|
const eventId = playbackError.rows[0]?.id as string | undefined;
|
|
if (!eventId) throw new Error('Vibe playback-error event could not be recorded');
|
|
|
|
const item = await client.query(
|
|
`WITH next_item AS (
|
|
SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i
|
|
WHERE i.plan_version_id = $1 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
|
|
)
|
|
UPDATE vibe_plan_items i SET committed = true
|
|
FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal
|
|
RETURNING i.*`,
|
|
[plan.id],
|
|
);
|
|
const replacement = item.rows[0] as VibePlanItem | undefined;
|
|
if (replacement) {
|
|
await client.query(
|
|
`INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload)
|
|
VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`,
|
|
[sessionId, userId, replacement.track_id, JSON.stringify({
|
|
planVersion: plan.version,
|
|
planVersionId: replacement.plan_version_id,
|
|
ordinal: replacement.ordinal,
|
|
})],
|
|
);
|
|
}
|
|
await client.query(
|
|
`UPDATE vibe_events SET payload = $2::jsonb WHERE id = $1`,
|
|
[eventId, JSON.stringify({
|
|
...payload,
|
|
advancedTo: replacement
|
|
? { planVersionId: replacement.plan_version_id, ordinal: replacement.ordinal }
|
|
: null,
|
|
})],
|
|
);
|
|
await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]);
|
|
return { item: replacement ?? null, stale: false };
|
|
});
|
|
}
|
|
|
|
/** Read a specific plan revision, or the latest revision for a session. */
|
|
async getVibePlan(sessionId: string, userId: string, version?: number): Promise<VibePlan | null> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT p.*, i.plan_version_id AS item_plan_version_id, i.ordinal, i.track_id,
|
|
i.slot_role, i.candidate_source, i.score, i.score_breakdown,
|
|
i.explanation, i.committed
|
|
FROM vibe_plan_versions p
|
|
JOIN vibe_sessions s ON s.id = p.session_id
|
|
LEFT JOIN vibe_plan_items i ON i.plan_version_id = p.id
|
|
WHERE p.session_id = $1 AND s.user_id = $2
|
|
AND (
|
|
($3::integer IS NOT NULL AND p.version = $3)
|
|
OR ($3::integer IS NULL AND p.version = (
|
|
SELECT MAX(version) FROM vibe_plan_versions WHERE session_id = $1
|
|
))
|
|
)
|
|
ORDER BY i.ordinal ASC`,
|
|
[sessionId, userId, version ?? null]
|
|
);
|
|
if (!res.rows[0]) return null;
|
|
|
|
const first = res.rows[0] as VibePlanVersionRow;
|
|
const plan: VibePlan = {
|
|
id: first.id,
|
|
session_id: first.session_id,
|
|
version: first.version,
|
|
reason: first.reason,
|
|
state_snapshot: first.state_snapshot,
|
|
objective_snapshot: first.objective_snapshot,
|
|
created_at: first.created_at,
|
|
items: [],
|
|
};
|
|
for (const row of res.rows as VibePlanVersionRow[]) {
|
|
if (!row.item_plan_version_id) continue;
|
|
plan.items.push({
|
|
plan_version_id: row.item_plan_version_id,
|
|
ordinal: row.ordinal!,
|
|
track_id: row.track_id!,
|
|
slot_role: row.slot_role,
|
|
candidate_source: row.candidate_source!,
|
|
score: row.score!,
|
|
score_breakdown: row.score_breakdown!,
|
|
explanation: row.explanation,
|
|
committed: row.committed!,
|
|
});
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
/** Return the replacement revision caused by a material feedback event. */
|
|
async getVibePlanForFeedbackEvent(sessionId: string, userId: string, eventId: string): Promise<VibePlan | null> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT version
|
|
FROM vibe_plan_versions p
|
|
JOIN vibe_sessions s ON s.id = p.session_id
|
|
WHERE p.session_id = $1
|
|
AND s.user_id = $2
|
|
AND p.objective_snapshot->>'feedbackEventId' = $3
|
|
ORDER BY p.version DESC
|
|
LIMIT 1`,
|
|
[sessionId, userId, eventId],
|
|
);
|
|
const version = res.rows[0]?.version as number | undefined;
|
|
return version === undefined ? null : this.getVibePlan(sessionId, userId, version);
|
|
}
|
|
|
|
/**
|
|
* Tracks exposed by a durable session are never eligible for another
|
|
* revision of that same session. This includes served items and every
|
|
* explicit feedback target, not just completed play history.
|
|
*/
|
|
async getVibeSessionTrackIds(sessionId: string, userId: string): Promise<string[]> {
|
|
const res = await this.pgClient.query(
|
|
`SELECT DISTINCT e.track_id
|
|
FROM vibe_events e
|
|
JOIN vibe_sessions s ON s.id = e.session_id
|
|
WHERE e.session_id = $1
|
|
AND s.user_id = $2
|
|
AND e.track_id IS NOT NULL`,
|
|
[sessionId, userId],
|
|
);
|
|
return res.rows.map((row: { track_id: string }) => row.track_id);
|
|
}
|
|
|
|
/**
|
|
* Upsert a diversity budget for a user.
|
|
*/
|
|
async upsertDiversityBudget(budget: {
|
|
user_id: string;
|
|
dimension: string;
|
|
budget_share: number;
|
|
horizon_min: number;
|
|
}): Promise<void> {
|
|
await this.pgClient.query(
|
|
`INSERT INTO diversity_budgets (user_id, dimension, budget_share, horizon_min)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (user_id, dimension, horizon_min)
|
|
DO UPDATE SET budget_share = $3`,
|
|
[budget.user_id, budget.dimension, budget.budget_share, budget.horizon_min]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Seed default diversity budgets for a new user.
|
|
*/
|
|
async seedDefaultDiversityBudgets(userId: string): Promise<void> {
|
|
const defaults: { dimension: string; share: number; horizon: number }[] = [
|
|
{ dimension: 'artist', share: 0.20, horizon: 30 },
|
|
{ dimension: 'genre', share: 0.40, horizon: 30 },
|
|
{ dimension: 'language', share: 0.60, horizon: 30 },
|
|
{ dimension: 'instrumental', share: 0.10, horizon: 30 },
|
|
{ dimension: 'new_artist', share: 0.15, horizon: 60 },
|
|
{ dimension: 'favorite', share: 0.25, horizon: 60 },
|
|
];
|
|
|
|
for (const d of defaults) {
|
|
await this.upsertDiversityBudget({
|
|
user_id: userId,
|
|
dimension: d.dimension,
|
|
budget_share: d.share,
|
|
horizon_min: d.horizon,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Watermark for refreshClaimFusion() — last-seen MAX(claims.last_reinforced_at). */
|
|
private lastClaimsWatermark: string | null = null;
|
|
|
|
/**
|
|
* Refresh the claim_fusion materialised view. Called on a periodic timer so
|
|
* the graph's read path stays current with new claims. Skips the (fairly
|
|
* expensive, shared-connection-stalling) REFRESH when claims haven't
|
|
* changed since the last tick — a NOTIFY trigger exists on the claims
|
|
* table but nothing LISTENs for it yet, so this cheap watermark check
|
|
* stands in for that. last_reinforced_at is bumped by both inserts
|
|
* (DEFAULT NOW()) and upsertClaim's ON CONFLICT UPDATE, so it tracks all
|
|
* claim mutations that would change the view's output.
|
|
* CONCURRENTLY requires the unique index (idx_claim_fusion_pk),
|
|
* which the 20260708_materialize_claim_fusion migration creates.
|
|
*/
|
|
async refreshClaimFusion(): Promise<void> {
|
|
try {
|
|
const watermarkRes = await this.pgClient.query<{ max: string | null }>(
|
|
'SELECT MAX(last_reinforced_at) AS max FROM claims'
|
|
);
|
|
const watermark = watermarkRes.rows[0]?.max ?? null;
|
|
if (watermark !== null && watermark === this.lastClaimsWatermark) {
|
|
return; // no claim changes since the last refresh — skip it
|
|
}
|
|
await this.pgClient.query('SELECT refresh_claim_fusion()');
|
|
this.lastClaimsWatermark = watermark;
|
|
} catch (err) {
|
|
// Non-fatal: the MV may not exist yet on first boot before
|
|
// migrations run. Log and move on; the next tick will retry.
|
|
console.error('[DB] refresh_claim_fusion failed:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decay all listener beliefs whose last_decayed_at is older than 1
|
|
* hour. Implements the decay formula from spec §B.4:
|
|
* value *= 0.5 ^ (elapsed / halflife)
|
|
* confidence *= 0.5 ^ (elapsed / halflife)
|
|
* Halflife is per-profile (longterm=365d, obsession=14d, discovery=30d,
|
|
* negative=180d, contextual=7d). The 'forgotten' profile is excluded
|
|
* — it is fully derived nightly by deriveForgottenProfile(), not
|
|
* decayed.
|
|
*/
|
|
async decayBeliefs(): Promise<number> {
|
|
const res = await this.pgClient.query(`
|
|
-- NOTE: this MUST be a VALUES list, not "SELECT profile, CASE profile ...",
|
|
-- which has no FROM clause and is rejected by Postgres with 42703
|
|
-- (column "profile" does not exist) on every single run.
|
|
WITH halflives(profile, halflife_sec) AS (
|
|
VALUES
|
|
('longterm', 365 * 86400),
|
|
('obsession', 14 * 86400),
|
|
('discovery', 30 * 86400),
|
|
('negative', 180 * 86400),
|
|
('contextual', 7 * 86400)
|
|
)
|
|
UPDATE listener_beliefs lb
|
|
SET value = GREATEST(-1.0, LEAST(1.0, lb.value * POWER(0.5,
|
|
EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))),
|
|
confidence = GREATEST(0, LEAST(1.0, lb.confidence * POWER(0.5,
|
|
EXTRACT(EPOCH FROM (NOW() - lb.last_decayed_at)) / h.halflife_sec))),
|
|
last_decayed_at = NOW()
|
|
FROM halflives h
|
|
WHERE lb.profile = h.profile
|
|
AND lb.profile <> 'forgotten'
|
|
AND lb.last_decayed_at < NOW() - INTERVAL '1 hour'
|
|
`);
|
|
return res.rowCount ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Derive the 'forgotten' profile nightly (spec §B.2):
|
|
* longterm affinity > 0.3 AND not reinforced in 90+ days.
|
|
* Wipes and repopulates — 'forgotten' is fully derived, not evidence-fed.
|
|
*/
|
|
async deriveForgottenProfile(): Promise<number> {
|
|
await this.pgClient.query(
|
|
`DELETE FROM listener_beliefs WHERE profile = 'forgotten'`
|
|
);
|
|
const res = await this.pgClient.query(`
|
|
INSERT INTO listener_beliefs
|
|
(user_id, profile, entity_type, entity_id, dimension, value,
|
|
confidence, evidence_count, last_reinforced_at, last_decayed_at)
|
|
SELECT user_id, 'forgotten', entity_type, entity_id, dimension,
|
|
value, confidence, evidence_count, last_reinforced_at, NOW()
|
|
FROM listener_beliefs
|
|
WHERE profile = 'longterm'
|
|
AND dimension = 'affinity'
|
|
AND value > 0.3
|
|
AND last_reinforced_at < NOW() - INTERVAL '90 days'
|
|
ON CONFLICT (user_id, profile, entity_type, entity_id, dimension)
|
|
DO UPDATE SET
|
|
value = EXCLUDED.value,
|
|
confidence = EXCLUDED.confidence,
|
|
evidence_count = EXCLUDED.evidence_count,
|
|
last_reinforced_at = EXCLUDED.last_reinforced_at
|
|
`);
|
|
return res.rowCount ?? 0;
|
|
}
|
|
}
|