Files
muzick/backend/src/services/discovery.service.ts
T

298 lines
9.6 KiB
TypeScript

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));
}
}