c41316ee99
Backend: - app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205) - v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206) - session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor) - db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232) - app.ts: drop test enqueue-job endpoint (#234) Frontend: - AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
534 lines
19 KiB
TypeScript
534 lines
19 KiB
TypeScript
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);
|
|
|
|
if (topArtists.length === 0) return [];
|
|
|
|
const artistValueMap = new Map(topArtists.map(b => [b.entity_id, b.value]));
|
|
const artistIds = topArtists.map(b => b.entity_id);
|
|
|
|
const res = await db.pgClient.query(
|
|
`SELECT sub.id, sub.artist_id
|
|
FROM (
|
|
SELECT t.id, cf.object_id AS artist_id,
|
|
ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn
|
|
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
|
|
WHERE sub.rn <= 2
|
|
ORDER BY sub.artist_id, sub.rn`,
|
|
[artistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
|
|
);
|
|
|
|
return (res.rows as { id: string; artist_id: string }[]).map(row => {
|
|
const value = artistValueMap.get(row.artist_id) ?? 0.5;
|
|
return {
|
|
trackId: row.id,
|
|
generatorId: 'comfort',
|
|
explanation: [{
|
|
subjectType: 'artist',
|
|
subjectId: row.artist_id,
|
|
predicate: 'credited_main_on',
|
|
objectType: 'track',
|
|
objectId: row.id,
|
|
fusedValue: value,
|
|
}],
|
|
relevance: value,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 cf.object_id AS artist_id, MAX(cf.fused_value) AS edge_strength
|
|
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')
|
|
)
|
|
GROUP BY cf.object_id
|
|
LIMIT 30`,
|
|
[trustedIds, ctx.userId]
|
|
);
|
|
|
|
const unfamiliarRows = unfamiliarRes.rows as { artist_id: string; edge_strength: number }[];
|
|
const unfamiliarArtistIds = unfamiliarRows.map(r => r.artist_id);
|
|
const edgeStrengthMap = new Map(unfamiliarRows.map(r => [r.artist_id, r.edge_strength]));
|
|
if (unfamiliarArtistIds.length === 0) return [];
|
|
|
|
const trackRes = await db.pgClient.query(
|
|
`SELECT id, artist_id FROM (
|
|
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_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[]))
|
|
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
|
) sub
|
|
ORDER BY RANDOM()
|
|
LIMIT $3`,
|
|
[unfamiliarArtistIds, ctx.recentExclusions, maxCandidates]
|
|
);
|
|
|
|
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
|
|
const relevance = edgeStrengthMap.get(row.artist_id) ?? 0.4;
|
|
return {
|
|
trackId: row.id,
|
|
generatorId: 'discovery',
|
|
explanation: [{
|
|
subjectType: 'artist',
|
|
subjectId: row.artist_id,
|
|
predicate: 'credited_main_on',
|
|
objectType: 'track',
|
|
objectId: row.id,
|
|
fusedValue: relevance,
|
|
}],
|
|
relevance,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 albumRows = albumRes.rows as { album_id: string; artist_id: string }[];
|
|
if (albumRows.length === 0) return [];
|
|
|
|
const albumIds = albumRows.map(a => a.album_id);
|
|
const albumArtistMap = new Map(albumRows.map(a => [a.album_id, a.artist_id]));
|
|
const obsessionValueMap = new Map(
|
|
ctx.beliefs
|
|
.filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
|
|
.map(b => [b.entity_id, b.value])
|
|
);
|
|
|
|
const trackRes = await db.pgClient.query(
|
|
`SELECT sub.id, sub.album_id
|
|
FROM (
|
|
SELECT t.id, t.album_id,
|
|
ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn
|
|
FROM tracks t
|
|
WHERE t.album_id = ANY($1::uuid[])
|
|
AND t.state = 'LIBRARY'
|
|
AND NOT (t.id = ANY($2::uuid[]))
|
|
) sub
|
|
WHERE sub.rn <= 5
|
|
ORDER BY sub.album_id, sub.rn`,
|
|
[albumIds, ctx.recentExclusions]
|
|
);
|
|
|
|
return (trackRes.rows as { id: string; album_id: string }[]).map(row => {
|
|
const artistId = albumArtistMap.get(row.album_id) ?? 'unknown';
|
|
const relevance = obsessionValueMap.get(artistId) ?? 0.7;
|
|
return {
|
|
trackId: row.id,
|
|
generatorId: 'deep-dive',
|
|
explanation: [{
|
|
subjectType: 'artist',
|
|
subjectId: artistId,
|
|
predicate: 'credited_main_on',
|
|
objectType: 'track',
|
|
objectId: row.id,
|
|
fusedValue: relevance,
|
|
}],
|
|
relevance,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 affinityMap = new Map(staleArtists.map(a => [a.artist_id, a.affinity]));
|
|
|
|
const trackRes = await db.pgClient.query(
|
|
`SELECT id, artist_id FROM (
|
|
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_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[]))
|
|
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
|
) sub
|
|
ORDER BY RANDOM()
|
|
LIMIT 20`,
|
|
[staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
|
|
);
|
|
|
|
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
|
|
const relevance = affinityMap.get(row.artist_id) ?? 0.6;
|
|
return {
|
|
trackId: row.id,
|
|
generatorId: 'revival',
|
|
explanation: [{
|
|
subjectType: 'artist',
|
|
subjectId: row.artist_id,
|
|
predicate: 'credited_main_on',
|
|
objectType: 'track',
|
|
objectId: row.id,
|
|
fusedValue: relevance,
|
|
}],
|
|
relevance,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2);
|
|
const targetArtistIds = targetBeliefs.map(b => b.entity_id);
|
|
const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value]));
|
|
|
|
if (targetArtistIds.length === 0) return [];
|
|
|
|
const trackRes = await db.pgClient.query(
|
|
`SELECT id, artist_id FROM (
|
|
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_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[]))
|
|
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
|
) sub
|
|
ORDER BY RANDOM()
|
|
LIMIT 15`,
|
|
[targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
|
|
);
|
|
|
|
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
|
|
const relevance = targetValueMap.get(row.artist_id) ?? 0.5;
|
|
return {
|
|
trackId: row.id,
|
|
generatorId: 'contextual',
|
|
explanation: [{
|
|
subjectType: 'artist',
|
|
subjectId: row.artist_id,
|
|
predicate: 'credited_main_on',
|
|
objectType: 'track',
|
|
objectId: row.id,
|
|
fusedValue: relevance,
|
|
}],
|
|
relevance,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// All generators, ordered by priority (comfort first, experimental last)
|
|
// ---------------------------------------------------------------------------
|
|
export const ALL_GENERATORS: Generator[] = [
|
|
comfortGenerator,
|
|
adjacentGenerator,
|
|
deepDiveGenerator,
|
|
revivalGenerator,
|
|
discoveryGenerator,
|
|
noveltyGenerator,
|
|
contextualGenerator,
|
|
experimentalGenerator,
|
|
];
|