initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+500
View File
@@ -0,0 +1,500 @@
import { DbService, ListenerBelief } from './db.service.js';
// ---------------------------------------------------------------------------
// System C — Candidate Generators
// Each generator returns candidates with graph-path explanations.
// No scoring — the session director handles ranking.
// ---------------------------------------------------------------------------
export interface ClaimEdge {
subjectType: string;
subjectId: string;
predicate: string;
objectType: string;
objectId: string;
fusedValue: number;
}
export interface Candidate {
trackId: string;
generatorId: string;
explanation: ClaimEdge[];
relevance: number;
}
export interface GeneratorContext {
userId: string;
seedTrackId: string | null;
seedArtistId: string | null;
beliefs: ListenerBelief[];
recentExclusions: string[];
toleranceMap: Record<string, number>;
state: {
energy: number;
lastArtistIds: string[];
lastGenreIds: string[];
context: string | null;
noveltyHunger: number;
sessionAgeMin: number;
};
}
export type Generator = (db: DbService, ctx: GeneratorContext) => Promise<Candidate[]>;
const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000';
// ---------------------------------------------------------------------------
// 1. COMFORT — Top artists by longterm affinity > 0.5
// ---------------------------------------------------------------------------
async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const topArtists = ctx.beliefs
.filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5)
.sort((a, b) => b.value - a.value)
.slice(0, 20);
const candidates: Candidate[] = [];
for (const belief of topArtists) {
const res = await db.pgClient.query(
`SELECT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist' AND cf.object_id = $1
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
ORDER BY cf.fused_value DESC
LIMIT 2`,
[belief.entity_id, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
for (const row of res.rows as { id: string }[]) {
candidates.push({
trackId: row.id,
generatorId: 'comfort',
explanation: [{
subjectType: 'artist',
subjectId: belief.entity_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: belief.value,
}],
relevance: belief.value,
});
}
}
return candidates;
}
// ---------------------------------------------------------------------------
// 2. ADJACENT — Walk graph from seed artist, exclude comfort pool
// ---------------------------------------------------------------------------
async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
if (!ctx.seedArtistId) return [];
const comfortArtistIds = new Set(
ctx.beliefs
.filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.5)
.map(b => b.entity_id)
);
// Walk: seedArtist -> (credited_main_on|featured_on) -> track -> (credited_main_on|featured_on) -> reachedArtist
// cf1 finds tracks where seed artist appears; cf2 finds OTHER artists on those same tracks
const reachedRes = await db.pgClient.query(
`SELECT DISTINCT cf2.object_id AS artist_id
FROM claim_fusion cf1
JOIN claim_fusion cf2 ON cf2.subject_type = 'track'
AND cf2.subject_id = cf1.subject_id
AND cf2.predicate IN ('credited_main_on', 'featured_on')
AND cf2.object_type = 'artist'
AND cf2.object_id != $1
AND (cf2.user_id = $2 OR cf2.user_id = $3)
WHERE cf1.subject_type = 'track'
AND cf1.predicate IN ('credited_main_on', 'featured_on')
AND cf1.object_type = 'artist'
AND cf1.object_id = $1
AND (cf1.user_id = $2 OR cf1.user_id = $3)
LIMIT 30`,
[ctx.seedArtistId, OBJECTIVE_USER, ctx.userId]
);
const reachedArtistIds = (reachedRes.rows as { artist_id: string }[])
.map(r => r.artist_id)
.filter(id => !comfortArtistIds.has(id));
if (reachedArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT 20`,
[reachedArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'adjacent',
explanation: [{
subjectType: 'artist',
subjectId: ctx.seedArtistId!,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.6,
}],
relevance: 0.6,
}));
}
// ---------------------------------------------------------------------------
// 3. DISCOVERY — Unfamiliar artists via graph edges from trusted artists
// ---------------------------------------------------------------------------
async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const trustedIds = ctx.beliefs
.filter(b => b.profile === 'longterm' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
.map(b => b.entity_id);
if (trustedIds.length === 0) return [];
const noveltyTolerance = ctx.toleranceMap.novelty_tolerance ?? 0.3;
const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance));
const unfamiliarRes = await db.pgClient.query(
`SELECT DISTINCT cf.object_id AS artist_id
FROM claim_fusion cf
WHERE cf.subject_type = 'artist'
AND cf.subject_id = ANY($1::uuid[])
AND cf.predicate IN ('same_scene_as', 'same_label_as', 'produced')
AND cf.object_type = 'artist'
AND NOT EXISTS (
SELECT 1 FROM listener_beliefs lb
WHERE lb.user_id = $2
AND lb.entity_type = 'artist'
AND lb.entity_id = cf.object_id
AND lb.profile IN ('longterm', 'obsession')
)
LIMIT 30`,
[trustedIds, ctx.userId]
);
const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id);
if (unfamiliarArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT $3`,
[unfamiliarArtistIds, ctx.recentExclusions, maxCandidates]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'discovery',
explanation: [{
subjectType: 'artist',
subjectId: unfamiliarArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.4,
}],
relevance: 0.4,
}));
}
// ---------------------------------------------------------------------------
// 4. DEEP-DIVE — Albums from obsession artists, unplayed tracks first
// ---------------------------------------------------------------------------
async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const obsessedIds = ctx.beliefs
.filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
.map(b => b.entity_id);
if (obsessedIds.length === 0) return [];
const albumRes = await db.pgClient.query(
`SELECT al.id AS album_id, al.artist_id
FROM albums al
WHERE al.artist_id = ANY($1::uuid[])
ORDER BY al.year ASC NULLS LAST, al.title ASC
LIMIT 20`,
[obsessedIds]
);
const candidates: Candidate[] = [];
for (const album of albumRes.rows as { album_id: string; artist_id: string }[]) {
const trackRes = await db.pgClient.query(
`SELECT t.id
FROM tracks t
WHERE t.album_id = $1 AND t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
ORDER BY t.title ASC
LIMIT 5`,
[album.album_id, ctx.recentExclusions]
);
for (const row of trackRes.rows as { id: string }[]) {
candidates.push({
trackId: row.id,
generatorId: 'deep-dive',
explanation: [{
subjectType: 'artist',
subjectId: album.artist_id,
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.7,
}],
relevance: 0.7,
});
}
}
return candidates;
}
// ---------------------------------------------------------------------------
// 5. REVIVAL — Stale longterm affinity (last_reinforced > 90 days ago)
// ---------------------------------------------------------------------------
async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const staleRes = await db.pgClient.query(
`SELECT lb.entity_id AS artist_id, lb.value AS affinity
FROM listener_beliefs lb
WHERE lb.user_id = $1
AND lb.profile = 'longterm'
AND lb.entity_type = 'artist'
AND lb.dimension = 'affinity'
AND lb.value > 0.3
AND lb.last_reinforced_at < NOW() - INTERVAL '90 days'
ORDER BY lb.value DESC
LIMIT 20`,
[ctx.userId]
);
const staleArtists = staleRes.rows as { artist_id: string; affinity: number }[];
if (staleArtists.length === 0) return [];
const staleArtistIds = staleArtists.map(a => a.artist_id);
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT 20`,
[staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'revival',
explanation: [{
subjectType: 'artist',
subjectId: staleArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.6,
}],
relevance: 0.6,
}));
}
// ---------------------------------------------------------------------------
// 6. NOVELTY — Recently released tracks by graph-adjacent artists
// ---------------------------------------------------------------------------
async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const trustedIds = ctx.beliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.3)
.map(b => b.entity_id);
if (trustedIds.length === 0) return [];
const res = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id, t.release_date
FROM tracks t
JOIN claim_fusion cf_edge ON cf_edge.subject_type = 'artist'
AND cf_edge.subject_id = ANY($2::uuid[])
AND cf_edge.predicate IN ('same_scene_as', 'same_label_as', 'produced')
AND cf_edge.object_type = 'artist'
JOIN claim_fusion cf_track ON cf_track.subject_type = 'track'
AND cf_track.subject_id = t.id
AND cf_track.predicate IN ('credited_main_on', 'featured_on')
AND cf_track.object_type = 'artist'
AND cf_track.object_id = cf_edge.object_id
WHERE t.release_date IS NOT NULL
AND t.release_date >= NOW() - INTERVAL '60 days'
AND t.state = 'LIBRARY'
AND NOT (t.id = ANY($1::uuid[]))
) sub
ORDER BY release_date DESC
LIMIT 20`,
[ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'], trustedIds]
);
return res.rows.map((row: { id: string }) => ({
trackId: row.id,
generatorId: 'novelty',
relevance: 0.5,
explanation: [{
subjectType: 'track', subjectId: row.id,
predicate: 'release_date',
objectType: 'date', objectId: 'recent',
fusedValue: 0.5,
}],
}));
}
// ---------------------------------------------------------------------------
// 7. EXPERIMENTAL — Genres with high network distance from favourites
// ---------------------------------------------------------------------------
async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const favArtistIds = ctx.beliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.4)
.map(b => b.entity_id);
if (favArtistIds.length === 0) return [];
const favGenreIds = ctx.beliefs
.filter(b => b.entity_type === 'genre' && b.value > 0.2)
.map(b => b.entity_id);
const result = await db.pgClient.query(
`WITH unfamiliar_genres AS (
SELECT g.id, g.name,
(SELECT COUNT(*) FROM track_genre tg2 WHERE tg2.genre_id = g.id) AS track_count
FROM genre g
WHERE NOT (g.id = ANY($1::uuid[]))
AND EXISTS (SELECT 1 FROM track_genre tg WHERE tg.genre_id = g.id)
ORDER BY RANDOM()
LIMIT 3
),
candidate_tracks AS (
SELECT DISTINCT t.id, tg.genre_id
FROM tracks t
JOIN track_genre tg ON tg.track_id = t.id
JOIN unfamiliar_genres ug ON ug.id = tg.genre_id
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($2::uuid[]))
LIMIT 30
)
SELECT ct.id, ct.genre_id
FROM candidate_tracks ct
ORDER BY RANDOM()
LIMIT 6`,
[
favGenreIds.length > 0 ? favGenreIds : ['00000000-0000-0000-0000-000000000000'],
ctx.recentExclusions.length > 0 ? ctx.recentExclusions : ['00000000-0000-0000-0000-000000000000'],
]
);
return result.rows.map((row: { id: string; genre_id: string }) => ({
trackId: row.id,
generatorId: 'experimental',
relevance: 0.2,
explanation: [{
subjectType: 'genre', subjectId: row.genre_id,
predicate: 'belongs_to_genre',
objectType: 'track', objectId: row.id,
fusedValue: 0.2,
}],
}));
}
// ---------------------------------------------------------------------------
// 8. CONTEXTUAL — Contextual profile beliefs
// ---------------------------------------------------------------------------
async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
if (!ctx.state.context) return [];
const contextualBeliefs = await db.getListenerBeliefs({
userId: ctx.userId,
profile: 'contextual',
limit: 30,
orderBy: 'value',
order: 'DESC',
});
const targetArtistIds = contextualBeliefs
.filter(b => b.entity_type === 'artist' && b.value > 0.2)
.map(b => b.entity_id);
if (targetArtistIds.length === 0) return [];
const trackRes = await db.pgClient.query(
`SELECT id FROM (
SELECT DISTINCT t.id
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
LIMIT 15`,
[targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
return (trackRes.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'contextual',
explanation: [{
subjectType: 'artist',
subjectId: targetArtistIds[0],
predicate: 'credited_main_on',
objectType: 'track',
objectId: row.id,
fusedValue: 0.5,
}],
relevance: 0.5,
}));
}
// ---------------------------------------------------------------------------
// All generators, ordered by priority (comfort first, experimental last)
// ---------------------------------------------------------------------------
export const ALL_GENERATORS: Generator[] = [
comfortGenerator,
adjacentGenerator,
deepDiveGenerator,
revivalGenerator,
discoveryGenerator,
noveltyGenerator,
contextualGenerator,
experimentalGenerator,
];