Files
Nodecast/server/services/liveGroupingScoring.js
blomios 6b16d2527d 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>
2026-10-02 16:57:20 +02:00

106 lines
4.3 KiB
JavaScript

const MODE_WEIGHTS = Object.freeze({
algo: Object.freeze({ algorithm: 80, structure: 15, metadata: 5, laya: 0 }),
hybrid: Object.freeze({ algorithm: 70, structure: 15, metadata: 5, laya: 10 }),
laya: Object.freeze({ algorithm: 55, structure: 15, metadata: 5, laya: 25 })
});
const PROTECTED_TOKENS = new Set(['max', 'news', 'premium', 'extra', 'xtra', 'kids', 'movies']);
const STRONG_FAMILIES = ['bein sports', 'sky sports', 'canal plus', 'eurosport', 'espn', 'dazn'];
const LAYA_CONFIDENCE_FLOOR = 0.65;
const MERGE_THRESHOLD = 70;
function tokens(value) {
return String(value || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
}
function strongFamily(value) {
const text = tokens(value).join(' ');
return STRONG_FAMILIES.find(family => text.includes(family)) || null;
}
function identityNumber(value, family = strongFamily(value)) {
const list = tokens(value);
let start = 0;
if (family) {
const familyTokens = family.split(' ');
for (let index = 0; index <= list.length - familyTokens.length; index++) {
if (familyTokens.every((token, offset) => list[index + offset] === token)) {
start = index + familyTokens.length;
break;
}
}
}
return list.slice(start).find(token => /^\d+$/.test(token)) || null;
}
function identity(value) {
const list = tokens(value);
const family = strongFamily(value);
return {
family,
number: identityNumber(value, family),
protectedTokens: new Set(list.filter(token => PROTECTED_TOKENS.has(token)))
};
}
function sameSet(left, right) {
return left.size === right.size && [...left].every(value => right.has(value));
}
function hardBlock(left, right) {
const a = identity(left.fingerprint || left.name);
const b = identity(right.fingerprint || right.name);
if (!sameSet(a.protectedTokens, b.protectedTokens)) return 'protected_token_mismatch';
if (a.number && b.number && a.number !== b.number) return 'number_mismatch';
if (a.family && b.family && a.family !== b.family) return 'strong_family_mismatch';
return null;
}
function pairKey(left, right) {
return [String(left.id), String(right.id)].sort().join('\0');
}
function layaSignal(hint, leftIdentity, rightIdentity) {
if (!hint || Number(hint.confidence) < LAYA_CONFIDENCE_FLOOR) return 0;
if (hint.number && leftIdentity.number && hint.number !== leftIdentity.number) return 0;
if (hint.number && rightIdentity.number && hint.number !== rightIdentity.number) return 0;
if (hint.family && leftIdentity.family && hint.family !== leftIdentity.family) return 0;
if (hint.family && rightIdentity.family && hint.family !== rightIdentity.family) return 0;
return Number(hint.sameIdentityProbability) >= 0.5
? Math.min(1, Math.max(0, Number(hint.confidence)))
: 0;
}
function scoreCandidate(left, right, options = {}) {
const mode = MODE_WEIGHTS[options.mode] ? options.mode : 'algo';
const weights = MODE_WEIGHTS[mode];
const blocker = hardBlock(left, right);
if (blocker) return { matched: false, blocker, total: 0, signals: { algorithm: 0, structure: 0, metadata: 0, laya: 0 } };
const leftIdentity = identity(left.fingerprint || left.name);
const rightIdentity = identity(right.fingerprint || right.name);
const exact = left.fingerprint === right.fingerprint;
const sameStructure = Boolean(leftIdentity.family && leftIdentity.family === rightIdentity.family &&
leftIdentity.number && leftIdentity.number === rightIdentity.number);
const algorithm = exact ? 1 : (mode !== 'algo' && sameStructure ? 0.6 : 0);
const structure = sameStructure ? 1 : 0;
const metadata = Boolean(
(left.category_id && left.category_id === right.category_id) ||
(left.tvg_id && left.tvg_id === right.tvg_id)
) ? 1 : 0;
const laya = exact ? 0 : layaSignal(options.layaHint, leftIdentity, rightIdentity);
const total = algorithm * weights.algorithm + structure * weights.structure +
metadata * weights.metadata + laya * weights.laya;
return { matched: exact || total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } };
}
module.exports = {
LAYA_CONFIDENCE_FLOOR,
MERGE_THRESHOLD,
MODE_WEIGHTS,
PROTECTED_TOKENS,
hardBlock,
identity,
pairKey,
scoreCandidate
};