initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { DbService } from './db.service.js';
|
||||
|
||||
export interface DiscoveryCandidate {
|
||||
id: string;
|
||||
source: string;
|
||||
externalId: string;
|
||||
title: string | null;
|
||||
artistCredit: unknown;
|
||||
notes: unknown;
|
||||
status: string;
|
||||
relevance: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export class DiscoveryService {
|
||||
constructor(private db: DbService) {}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// E.1 — Graph exploration: walk the graph beyond the library
|
||||
// ---------------------------------------------------------------
|
||||
async walkGraphForDiscovery(userId: string): Promise<number> {
|
||||
const beliefs = await this.db.getListenerBeliefs({
|
||||
userId,
|
||||
profile: 'longterm',
|
||||
entityType: 'artist',
|
||||
dimension: 'affinity',
|
||||
limit: 100,
|
||||
orderBy: 'value',
|
||||
order: 'DESC',
|
||||
});
|
||||
|
||||
const highAffinity = beliefs.filter((b) => b.value > 0.3);
|
||||
let newCount = 0;
|
||||
|
||||
for (const belief of highAffinity) {
|
||||
const candidates = await this.db.pgClient.query<{ candidate_artist_id: string }>(
|
||||
`SELECT cf.object_id AS candidate_artist_id
|
||||
FROM claim_fusion cf
|
||||
WHERE cf.subject_id = $1::uuid
|
||||
AND cf.predicate IN ('same_scene_as', 'featured_on')
|
||||
AND cf.object_type = 'artist'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tracks t
|
||||
JOIN claim_fusion cf2 ON cf2.subject_id = t.id
|
||||
WHERE cf2.object_id = cf.object_id
|
||||
AND cf2.predicate = 'credited_main_on'
|
||||
)
|
||||
LIMIT 20`,
|
||||
[belief.entity_id]
|
||||
);
|
||||
|
||||
for (const row of candidates.rows) {
|
||||
const dcRes = await this.db.pgClient.query<{ id: string }>(
|
||||
`INSERT INTO discovery_candidates (source, external_id, artist_credit, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (source, external_id) DO NOTHING
|
||||
RETURNING id`,
|
||||
[
|
||||
'graph_exploration',
|
||||
row.candidate_artist_id,
|
||||
JSON.stringify([{ artist_id: row.candidate_artist_id }]),
|
||||
JSON.stringify({
|
||||
discovery_source: 'graph_exploration',
|
||||
path: [
|
||||
{
|
||||
entity_id: belief.entity_id,
|
||||
predicate: 'affinity_source',
|
||||
profile: 'longterm',
|
||||
affinity: belief.value,
|
||||
},
|
||||
{
|
||||
entity_id: row.candidate_artist_id,
|
||||
predicate: 'same_scene_as',
|
||||
},
|
||||
],
|
||||
source_artist_belief_id: belief.entity_id,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
if (dcRes.rows.length === 0) continue;
|
||||
|
||||
const dcId = dcRes.rows[0].id;
|
||||
const relevance = Math.min(belief.value, 0.8);
|
||||
|
||||
await this.db.upsertClaim({
|
||||
subject_type: 'track',
|
||||
subject_id: dcId,
|
||||
predicate: 'discovery_candidate',
|
||||
object_type: 'artist',
|
||||
object_id: row.candidate_artist_id,
|
||||
source: 'graph_exploration',
|
||||
confidence: relevance,
|
||||
raw: {
|
||||
discovery_source: 'graph_exploration',
|
||||
path: [
|
||||
{ entity_id: belief.entity_id, relationship: 'affinity_source', belief_value: belief.value },
|
||||
{ entity_id: row.candidate_artist_id, relationship: 'same_scene_as' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return newCount;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// E.3 — Evaluate discovery candidates for acquisition
|
||||
// ---------------------------------------------------------------
|
||||
async evalCandidates(
|
||||
userId: string,
|
||||
limit?: number
|
||||
): Promise<{ candidateId: string; shouldAcquire: boolean; reason: string }[]> {
|
||||
const cap = limit ?? 20;
|
||||
const results: { candidateId: string; shouldAcquire: boolean; reason: string }[] = [];
|
||||
|
||||
const candidates = await this.db.pgClient.query(
|
||||
`SELECT * FROM discovery_candidates
|
||||
WHERE status = 'candidate'
|
||||
ORDER BY first_seen_at ASC
|
||||
LIMIT $1`,
|
||||
[cap]
|
||||
);
|
||||
|
||||
const backlogRes = await this.db.pgClient.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM discovery_candidates WHERE status = 'acquiring'`
|
||||
);
|
||||
let backlog = backlogRes.rows[0]?.cnt as number ?? 0;
|
||||
|
||||
for (const row of candidates.rows) {
|
||||
const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>(
|
||||
`SELECT object_id, fused_value
|
||||
FROM claim_fusion
|
||||
WHERE subject_type = 'track' AND subject_id = $1::uuid
|
||||
AND predicate = 'discovery_candidate'
|
||||
LIMIT 1`,
|
||||
[row.id]
|
||||
);
|
||||
|
||||
const relevance = claimRes.rows[0]?.fused_value ?? 0;
|
||||
const candidateArtistId = claimRes.rows[0]?.object_id;
|
||||
|
||||
const noveltyBeliefs = await this.db.getListenerBeliefs({
|
||||
userId,
|
||||
profile: 'discovery',
|
||||
entityType: 'artist',
|
||||
entityId: candidateArtistId,
|
||||
dimension: 'tolerance',
|
||||
limit: 1,
|
||||
});
|
||||
const tolerance = noveltyBeliefs.length > 0 ? noveltyBeliefs[0].value : 0.5;
|
||||
|
||||
let artistCount = 0;
|
||||
if (candidateArtistId) {
|
||||
const acRes = await this.db.pgClient.query(
|
||||
`SELECT COUNT(*)::int AS cnt
|
||||
FROM discovery_candidates dc
|
||||
JOIN claims c ON c.subject_id = dc.id
|
||||
WHERE dc.status = 'acquiring'
|
||||
AND c.predicate = 'discovery_candidate'
|
||||
AND c.object_id = $1::uuid`,
|
||||
[candidateArtistId]
|
||||
);
|
||||
artistCount = acRes.rows[0]?.cnt as number ?? 0;
|
||||
}
|
||||
|
||||
const shouldAcquire = relevance > 0.3 && tolerance > 0.2 && backlog < 20 && artistCount < 3;
|
||||
let reason: string;
|
||||
|
||||
if (shouldAcquire) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE discovery_candidates SET status = 'acquiring', last_eval_at = NOW() WHERE id = $1`,
|
||||
[row.id]
|
||||
);
|
||||
backlog++;
|
||||
reason = 'meets criteria';
|
||||
} else {
|
||||
if (relevance <= 0.3) reason = 'relevance too low';
|
||||
else if (tolerance <= 0.2) reason = 'novelty tolerance exceeded';
|
||||
else if (backlog >= 20) reason = 'backlog full';
|
||||
else if (artistCount >= 3) reason = 'artist diversity limit';
|
||||
else reason = 'unknown';
|
||||
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE discovery_candidates SET status = 'retired', last_eval_at = NOW() WHERE id = $1`,
|
||||
[row.id]
|
||||
);
|
||||
}
|
||||
|
||||
results.push({
|
||||
candidateId: row.id,
|
||||
shouldAcquire,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// E.4 — Probation lifecycle
|
||||
// ---------------------------------------------------------------
|
||||
async evalProbation(trackId: string): Promise<'retained' | 'retired' | 'probation'> {
|
||||
const completedRes = await this.db.pgClient.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM evidence
|
||||
WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'playback_completed'`,
|
||||
[trackId]
|
||||
);
|
||||
const completedPlays = completedRes.rows[0]?.cnt as number ?? 0;
|
||||
|
||||
const skipRes = await this.db.pgClient.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM evidence
|
||||
WHERE entity_type = 'track' AND entity_id = $1 AND signal = 'skip_quick'`,
|
||||
[trackId]
|
||||
);
|
||||
const skips = skipRes.rows[0]?.cnt as number ?? 0;
|
||||
|
||||
const trackRes = await this.db.pgClient.query<{ probation_entered_at: Date | null }>(
|
||||
`SELECT probation_entered_at FROM tracks WHERE id = $1`,
|
||||
[trackId]
|
||||
);
|
||||
const probTrack = trackRes.rows[0];
|
||||
|
||||
if (completedPlays >= 3) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE tracks SET probation_status = 'retained' WHERE id = $1`,
|
||||
[trackId]
|
||||
);
|
||||
|
||||
const claimRes = await this.db.pgClient.query<{ source: string }>(
|
||||
`SELECT source FROM claims
|
||||
WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate'
|
||||
LIMIT 1`,
|
||||
[trackId]
|
||||
);
|
||||
if (claimRes.rows[0]) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE source_trust SET trust = LEAST(1.0, trust + 0.05) WHERE key = $1`,
|
||||
[claimRes.rows[0].source]
|
||||
);
|
||||
}
|
||||
|
||||
return 'retained';
|
||||
}
|
||||
|
||||
const daysSinceProbation = probTrack?.probation_entered_at
|
||||
? (Date.now() - new Date(probTrack.probation_entered_at).getTime()) / (1000 * 86400)
|
||||
: 0;
|
||||
|
||||
if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE tracks SET probation_status = 'retired' WHERE id = $1`,
|
||||
[trackId]
|
||||
);
|
||||
return 'retired';
|
||||
}
|
||||
|
||||
return 'probation';
|
||||
}
|
||||
|
||||
async sweepProbation(): Promise<{ retained: number; retired: number }> {
|
||||
const res = await this.db.pgClient.query(
|
||||
`SELECT id FROM tracks WHERE probation_status = 'probation'`
|
||||
);
|
||||
|
||||
let retained = 0;
|
||||
let retired = 0;
|
||||
|
||||
for (const row of res.rows) {
|
||||
const result = await this.evalProbation(row.id as string);
|
||||
if (result === 'retained') retained++;
|
||||
else if (result === 'retired') retired++;
|
||||
}
|
||||
|
||||
return { retained, retired };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// E.5 — Meta-learning stub
|
||||
// ---------------------------------------------------------------
|
||||
async runMetaLearning(): Promise<void> {
|
||||
const res = await this.db.pgClient.query(
|
||||
`SELECT c.source, COUNT(*)::int AS cnt
|
||||
FROM claims c
|
||||
JOIN tracks t ON t.id = c.subject_id
|
||||
WHERE c.predicate = 'discovery_candidate'
|
||||
AND t.probation_status = 'retained'
|
||||
GROUP BY c.source
|
||||
ORDER BY cnt DESC`
|
||||
);
|
||||
|
||||
console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user