feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user