fix: resolve Live catalog projection failure during Analyze again in Laya mode (#17)

Fix the regression introduced by the weighted Laya signal (#16):
rebuild now merges exact fingerprints in every engine mode after
evaluating hard blockers, compares candidates against all variants
of a group, falls back to deterministic scoring when Laya is
unavailable, and reports transaction errors safely. Non-regression
tests cover the Laya-mode rebuild path; verified against a real
catalog copy (39,341 streams rebuilt successfully).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-02 16:57:20 +02:00
parent 325b505df9
commit 6b16d2527d
3 changed files with 56 additions and 11 deletions

View File

@ -82,10 +82,10 @@ function projectCatalog(items, options = {}) {
: fingerprint;
if (!candidateBuckets.has(candidateKey)) candidateBuckets.set(candidateKey, []);
const groups = candidateBuckets.get(candidateKey);
const matchingGroup = groups.find(group => scoreCandidate(group[0], projected, {
const matchingGroup = groups.find(group => group.some(member => scoreCandidate(member, projected, {
mode: options.mode || 'algo',
layaHint: options.identityHints?.get(pairKey(group[0], projected))
}).matched);
layaHint: options.identityHints?.get(pairKey(member, projected))
}).matched));
if (matchingGroup) matchingGroup.push(projected);
else groups.push([projected]);
}
@ -152,6 +152,16 @@ async function collectIdentityHints(items, mode, layaClient) {
return hints;
}
async function collectOptionalIdentityHints(items, mode, layaClient) {
if (mode === 'algo') return new Map();
try {
return await collectIdentityHints(items, mode, layaClient);
} catch (error) {
console.warn('[LiveProjection] Laya identity hints unavailable; continuing deterministically');
return new Map();
}
}
function catalogInputHash(items) {
return stableHash(items
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
@ -241,10 +251,12 @@ function rebuild(database = null, options = {}) {
apply();
return projectionState(database);
} catch (error) {
const diagnosticCode = /^SQLITE_[A-Z_]+$/.test(error.code || '') ? error.code : 'INTERNAL';
console.error(`[LiveProjection] Rebuild transaction failed (${diagnosticCode}, ${error.name || 'Error'})`);
database.prepare(`
UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ?
WHERE projection = 'live'
`).run(Date.now(), 'Live catalog projection failed');
`).run(Date.now(), `Live catalog projection failed [${diagnosticCode}]`);
throw error;
}
}
@ -256,12 +268,8 @@ async function rebuildWithSignals(database = null, options = {}) {
const mode = ['algo', 'hybrid', 'laya'].includes(configured) ? configured : 'algo';
let identityHints = new Map();
if (mode !== 'algo') {
try {
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
identityHints = await collectIdentityHints(items, mode, layaClient);
} catch (error) {
console.warn('[LiveProjection] Laya identity hints unavailable; continuing deterministically');
}
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
identityHints = await collectOptionalIdentityHints(items, mode, layaClient);
}
return rebuild(database, { ...options, items, mode, identityHints });
}
@ -375,6 +383,7 @@ function serializeChannels(rows, includeHidden = false) {
module.exports = {
catalogInputHash,
collectIdentityHints,
collectOptionalIdentityHints,
displayNameForFingerprint,
getChannels,
identityCandidatePairs,