298 lines
9.6 KiB
TypeScript
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));
|
|
}
|
|
}
|