feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 14:40:48 +04:00
parent a0c9f42a89
commit 4c48d11e9d
54 changed files with 4136 additions and 521 deletions
+251 -27
View File
@@ -34,7 +34,7 @@ import type {
DiversityBudget,
RepetitionRule,
} from '../db/types.js';
import { FEEDBACK_ACTIONS } from '../db/types.js';
import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js';
export * from '../db/types.js';
export class DbService {
@@ -372,11 +372,11 @@ export class DbService {
);
});
// Write evidence: hidden → negative profile (only on success)
await this.recordEvidence({
// 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,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'hidden',
profile: 'negative',
weight: -0.60,
@@ -462,10 +462,9 @@ export class DbService {
);
// 3. Write evidence: playback_completed → longterm affinity
await this.recordEvidence({
await this.recordTrackEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'playback_completed',
profile: 'longterm',
weight: 0.10,
@@ -480,18 +479,16 @@ export class DbService {
[userId, trackId]
);
if ((recentPlays.rows[0]?.cnt as number) > 1) {
await this.recordEvidence({
await this.recordTrackEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'replay_within_24h',
profile: 'longterm',
weight: 0.25,
}, client);
await this.recordEvidence({
await this.recordTrackEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'replay_within_24h',
profile: 'obsession',
weight: 0.40,
@@ -591,10 +588,9 @@ export class DbService {
[userId, trackId]
);
// Write evidence: skip_quick → negative profile
await this.recordEvidence({
await this.recordTrackEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'skip_quick',
profile: 'negative',
weight: -0.20,
@@ -613,19 +609,17 @@ export class DbService {
// Also write evidence for promoted/disliked signals
if (action === 'promoted') {
await this.recordEvidence({
await this.recordTrackEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'add_to_favorites',
profile: 'longterm',
weight: 0.60,
});
} else if (action === 'disliked') {
await this.recordEvidence({
await this.recordTrackEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
track_id: trackId,
signal: 'hidden',
profile: 'negative',
weight: -0.60,
@@ -688,8 +682,9 @@ export class DbService {
async createAlbum(data: Album): Promise<Album> {
const res = await this.pgClient.query(
'INSERT INTO albums (artist_id, title, year, artwork_id) VALUES ($1, $2, $3, $4) RETURNING *',
[data.artist_id, data.title, data.year, data.artwork_id]
`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];
}
@@ -1102,6 +1097,123 @@ export class DbService {
// 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;
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,
context: { ...event.context, ...target.context },
}, client);
}
return id;
}
/**
* Record evidence (append-only). Writes a signal into the evidence stream.
*/
@@ -1134,7 +1246,7 @@ export class DbService {
// which feeds 'novelty_tolerance'. Each new evidence row must also
// upsert the matching listener_belief (spec §B.4) — otherwise evidence
// accumulates but beliefs never materialise.
const dimension = evidence.signal === 'play_of_never_seen' ? 'novelty_tolerance' : 'affinity';
const dimension = this.beliefDimensionForSignal(evidence.signal);
await this.updateListenerBelief({
user_id: evidence.user_id,
profile: evidence.profile,
@@ -1163,6 +1275,119 @@ export class DbService {
});
}
/**
* 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.
*/
@@ -1395,4 +1620,3 @@ export class DbService {
return res.rowCount ?? 0;
}
}