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:
@ -90,7 +90,7 @@ function scoreCandidate(left, right, options = {}) {
|
|||||||
const laya = exact ? 0 : layaSignal(options.layaHint, leftIdentity, rightIdentity);
|
const laya = exact ? 0 : layaSignal(options.layaHint, leftIdentity, rightIdentity);
|
||||||
const total = algorithm * weights.algorithm + structure * weights.structure +
|
const total = algorithm * weights.algorithm + structure * weights.structure +
|
||||||
metadata * weights.metadata + laya * weights.laya;
|
metadata * weights.metadata + laya * weights.laya;
|
||||||
return { matched: total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } };
|
return { matched: exact || total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@ -82,10 +82,10 @@ function projectCatalog(items, options = {}) {
|
|||||||
: fingerprint;
|
: fingerprint;
|
||||||
if (!candidateBuckets.has(candidateKey)) candidateBuckets.set(candidateKey, []);
|
if (!candidateBuckets.has(candidateKey)) candidateBuckets.set(candidateKey, []);
|
||||||
const groups = candidateBuckets.get(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',
|
mode: options.mode || 'algo',
|
||||||
layaHint: options.identityHints?.get(pairKey(group[0], projected))
|
layaHint: options.identityHints?.get(pairKey(member, projected))
|
||||||
}).matched);
|
}).matched));
|
||||||
if (matchingGroup) matchingGroup.push(projected);
|
if (matchingGroup) matchingGroup.push(projected);
|
||||||
else groups.push([projected]);
|
else groups.push([projected]);
|
||||||
}
|
}
|
||||||
@ -152,6 +152,16 @@ async function collectIdentityHints(items, mode, layaClient) {
|
|||||||
return hints;
|
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) {
|
function catalogInputHash(items) {
|
||||||
return stableHash(items
|
return stableHash(items
|
||||||
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
|
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
|
||||||
@ -241,10 +251,12 @@ function rebuild(database = null, options = {}) {
|
|||||||
apply();
|
apply();
|
||||||
return projectionState(database);
|
return projectionState(database);
|
||||||
} catch (error) {
|
} 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(`
|
database.prepare(`
|
||||||
UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ?
|
UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ?
|
||||||
WHERE projection = 'live'
|
WHERE projection = 'live'
|
||||||
`).run(Date.now(), 'Live catalog projection failed');
|
`).run(Date.now(), `Live catalog projection failed [${diagnosticCode}]`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -256,12 +268,8 @@ async function rebuildWithSignals(database = null, options = {}) {
|
|||||||
const mode = ['algo', 'hybrid', 'laya'].includes(configured) ? configured : 'algo';
|
const mode = ['algo', 'hybrid', 'laya'].includes(configured) ? configured : 'algo';
|
||||||
let identityHints = new Map();
|
let identityHints = new Map();
|
||||||
if (mode !== 'algo') {
|
if (mode !== 'algo') {
|
||||||
try {
|
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
|
||||||
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
|
identityHints = await collectOptionalIdentityHints(items, mode, layaClient);
|
||||||
identityHints = await collectIdentityHints(items, mode, layaClient);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[LiveProjection] Laya identity hints unavailable; continuing deterministically');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return rebuild(database, { ...options, items, mode, identityHints });
|
return rebuild(database, { ...options, items, mode, identityHints });
|
||||||
}
|
}
|
||||||
@ -375,6 +383,7 @@ function serializeChannels(rows, includeHidden = false) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
catalogInputHash,
|
catalogInputHash,
|
||||||
collectIdentityHints,
|
collectIdentityHints,
|
||||||
|
collectOptionalIdentityHints,
|
||||||
displayNameForFingerprint,
|
displayNameForFingerprint,
|
||||||
getChannels,
|
getChannels,
|
||||||
identityCandidatePairs,
|
identityCandidatePairs,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const {
|
|||||||
} = require('../server/services/liveGroupingScoring');
|
} = require('../server/services/liveGroupingScoring');
|
||||||
const {
|
const {
|
||||||
collectIdentityHints,
|
collectIdentityHints,
|
||||||
|
collectOptionalIdentityHints,
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
projectCatalog
|
projectCatalog
|
||||||
} = require('../server/services/liveProjectionService');
|
} = require('../server/services/liveProjectionService');
|
||||||
@ -44,6 +45,16 @@ test('exact algorithm match survives a contradictory Laya opinion', () => {
|
|||||||
assert.equal(score.signals.laya, 0);
|
assert.equal(score.signals.laya, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exact generic fingerprints merge in Laya-only mode without family metadata', () => {
|
||||||
|
const left = channel('a', '1000 x Hora TV', { category_id: 'one' });
|
||||||
|
const right = channel('b', '|UY| 1000 x Hora TV', { category_id: 'two' });
|
||||||
|
const score = scoreCandidate(left, right, { mode: 'laya' });
|
||||||
|
assert.equal(score.total, 55);
|
||||||
|
assert.equal(score.matched, true);
|
||||||
|
const groups = projectCatalog([left, right], { mode: 'laya' });
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('different channel numbers block even when Laya proposes a merge', () => {
|
test('different channel numbers block even when Laya proposes a merge', () => {
|
||||||
const left = channel('a', 'beIN Sports 1');
|
const left = channel('a', 'beIN Sports 1');
|
||||||
const right = channel('b', 'beIN Sports 2');
|
const right = channel('b', 'beIN Sports 2');
|
||||||
@ -110,3 +121,28 @@ test('hybrid mode caps the positive Laya contribution at weight 10', () => {
|
|||||||
assert.equal(score.total, 72);
|
assert.equal(score.total, 72);
|
||||||
assert.equal(score.matched, true);
|
assert.equal(score.matched, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Laya bridge followed by an exact alias stays in one unique fingerprint group', () => {
|
||||||
|
const items = [
|
||||||
|
channel('a', 'beIN Sports 1 Event'),
|
||||||
|
channel('b', 'beIN Sports 1'),
|
||||||
|
channel('c', 'beIN Sports 1')
|
||||||
|
];
|
||||||
|
const hints = new Map([[
|
||||||
|
['a', 'b'].sort().join('\0'),
|
||||||
|
{ sameIdentityProbability: 1, confidence: 1 }
|
||||||
|
]]);
|
||||||
|
const groups = projectCatalog(items, { mode: 'hybrid', identityHints: hints });
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.equal(groups[0].fingerprint, 'bein sports 1');
|
||||||
|
assert.equal(groups[0].variants.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optional Laya failure degrades to an empty signal set', async () => {
|
||||||
|
const hints = await collectOptionalIdentityHints([
|
||||||
|
channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1')
|
||||||
|
], 'hybrid', {
|
||||||
|
identityHintBatch: async () => { throw new Error('runtime unavailable at https://private.invalid'); }
|
||||||
|
});
|
||||||
|
assert.equal(hints.size, 0);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user