feat: use Laya as a weighted signal in live stream grouping (#16)

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>
This commit is contained in:
blomios
2026-10-02 16:41:47 +02:00
parent a5ac3788a4
commit 925465ba39
5 changed files with 393 additions and 11 deletions

View File

@ -119,3 +119,24 @@ test('batch validation rejects oversized item counts', () => {
error => error instanceof LayaRequestError && error.statusCode === 413
);
});
test('identity hints send only sanitized channel metadata to Laya', async () => {
let requestBody;
const service = new LayaService({
cache: null,
fetch: async (url, options) => {
requestBody = JSON.parse(options.body);
return {
ok: true,
json: async () => ({ results: [{ answers: { sameIdentity: { noul: 0.9, confidence: 0.8 } } }] })
};
}
});
await service.identityHintBatch([{
id: 'pair',
left: { name: 'Sports http://user:pass@example.test/live', group: 'token=secret', source: 'Provider', tvgId: 'one', streamUrl: 'forbidden' },
right: { name: 'Sports', group: 'Group', source: 'Provider', tvgId: 'two', password: 'forbidden' }
}]);
assert.deepEqual(Object.keys(requestBody.states[0].left), ['name', 'group', 'source', 'tvgId']);
assert.doesNotMatch(JSON.stringify(requestBody), /example\.test|secret|forbidden|user:pass/);
});

View File

@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
MODE_WEIGHTS,
hardBlock,
scoreCandidate
} = require('../server/services/liveGroupingScoring');
const {
collectIdentityHints,
normalizeChannelName,
projectCatalog
} = require('../server/services/liveProjectionService');
const channel = (id, name, extra = {}) => ({
id,
source_id: 1,
item_id: id,
name,
category_id: 'sports',
fingerprint: normalizeChannelName(name),
...extra
});
test('weights match the validated architecture and remain bounded', () => {
assert.deepEqual(MODE_WEIGHTS.algo, { algorithm: 80, structure: 15, metadata: 5, laya: 0 });
assert.deepEqual(MODE_WEIGHTS.hybrid, { algorithm: 70, structure: 15, metadata: 5, laya: 10 });
assert.deepEqual(MODE_WEIGHTS.laya, { algorithm: 55, structure: 15, metadata: 5, laya: 25 });
});
test('exact algorithm match merges without Laya', () => {
const left = channel('a', 'beIN Sports 1 HD');
const right = channel('b', 'beIN Sports 1 4K');
assert.equal(scoreCandidate(left, right, { mode: 'algo' }).matched, true);
});
test('exact algorithm match survives a contradictory Laya opinion', () => {
const left = channel('a', 'beIN Sports 1 HD');
const right = channel('b', 'beIN Sports 1 4K');
const score = scoreCandidate(left, right, {
mode: 'hybrid',
layaHint: { sameIdentityProbability: 0, confidence: 1, number: '2' }
});
assert.equal(score.matched, true);
assert.equal(score.signals.laya, 0);
});
test('different channel numbers block even when Laya proposes a merge', () => {
const left = channel('a', 'beIN Sports 1');
const right = channel('b', 'beIN Sports 2');
const score = scoreCandidate(left, right, {
mode: 'laya', layaHint: { sameIdentityProbability: 1, confidence: 1 }
});
assert.equal(score.matched, false);
assert.equal(score.blocker, 'number_mismatch');
});
test('protected max token blocks beIN Sports Max 10 from beIN Sports 1', () => {
assert.equal(hardBlock(channel('a', 'beIN Sports Max 10'), channel('b', 'beIN Sports 1')), 'protected_token_mismatch');
assert.equal(projectCatalog([
channel('a', 'beIN Sports Max 10'), channel('b', 'beIN Sports 1')
], { mode: 'laya' }).length, 2);
});
test('different strong families are blocked before weighted scoring', () => {
assert.equal(hardBlock(channel('a', 'beIN Sports 1'), channel('b', 'Sky Sports 1')), 'strong_family_mismatch');
});
test('low-confidence Laya hint contributes zero', () => {
const score = scoreCandidate(
channel('a', 'beIN Sports 1 Event'),
channel('b', 'beIN Sports 1'),
{ mode: 'hybrid', layaHint: { sameIdentityProbability: 1, confidence: 0.64 } }
);
assert.equal(score.signals.laya, 0);
assert.equal(score.matched, false);
});
test('No Laya mode never calls the identity client', async () => {
let calls = 0;
const hints = await collectIdentityHints([
channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1')
], 'algo', { identityHintBatch: async () => { calls++; return []; } });
assert.equal(calls, 0);
assert.equal(hints.size, 0);
});
test('Laya only calls Laya but still respects blockers', async () => {
let calls = 0;
const items = [channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1')];
const hints = await collectIdentityHints(items, 'laya', {
identityHintBatch: async pairs => {
calls++;
return pairs.map(pair => ({ id: pair.id, sameIdentityProbability: 1, confidence: 1 }));
}
});
assert.equal(calls, 1);
assert.equal(projectCatalog(items, { mode: 'laya', identityHints: hints }).length, 1);
assert.equal(projectCatalog([
channel('1', 'beIN Sports 1'), channel('2', 'beIN Sports 2')
], { mode: 'laya', identityHints: hints }).length, 2);
});
test('hybrid mode caps the positive Laya contribution at weight 10', () => {
const score = scoreCandidate(
channel('a', 'beIN Sports 1 Event'),
channel('b', 'beIN Sports 1'),
{ mode: 'hybrid', layaHint: { sameIdentityProbability: 1, confidence: 5 } }
);
assert.equal(score.signals.laya, 1);
assert.equal(score.total, 72);
assert.equal(score.matched, true);
});