Introduce a liveGroupingScoring engine that combines existing membership signals (normalized name, brand, number, metadata tokens, language, category/source when relevant) with a bounded Laya contribution: Laya raises or lowers confidence instead of acting as a final judge, with guard-rails against obvious contradictions. Sanitize Laya batch identity inputs in layaService and wire the weighted scoring into the live projection service. Weights are not exposed in the Nodecast UI. Cover signal combination, bounds and contradiction guard-rails with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
4.3 KiB
JavaScript
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: 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
|
|
};
|