fix: enable Laya language detection and harden live analysis (#18 #19 #20)

Ensure Docker images keep their container-built native dependencies, wait for the Laya sidecar healthcheck, expose accurate Laya readiness, and treat empty AI language batches as fallback failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-05 23:10:31 +02:00
parent 9cc86485cb
commit 1abc56c90e
7 changed files with 258 additions and 5 deletions

View File

@ -19,6 +19,7 @@ test('status reports an unavailable runtime without throwing', async () => {
});
const status = await service.status();
assert.equal(status.runtimeAvailable, false);
assert.equal(status.ready, false);
assert.equal(status.modelPresent, false);
assert.equal(status.phase, 'idle');
assert.equal(status.downloading, false);
@ -27,6 +28,24 @@ test('status reports an unavailable runtime without throwing', async () => {
assert.equal(status.totalBytes, null);
assert.equal(status.sizeBytes, 0);
assert.equal(status.error, null);
assert.match(status.runtimeError.detail, /Start the Laya sidecar/);
});
test('status does not report ready when a model exists but the runtime is unavailable', async () => {
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-offline-model-'));
await fs.writeFile(path.join(modelDir, 'model.bin'), 'model');
const service = new LayaService({
modelDir,
cache: null,
fetch: async () => { throw new Error('connection refused'); }
});
const status = await service.status();
assert.equal(status.modelPresent, true);
assert.equal(status.runtimeAvailable, false);
assert.equal(status.ready, false);
assert.equal(status.runtimeError.message, 'Laya runtime is unavailable');
});
test('model download exposes an indeterminate transition and finishes ready', async () => {
@ -120,6 +139,48 @@ test('batch validation rejects oversized item counts', () => {
);
});
test('language batch sends the Laya contract and parses language confidence without fallback', async () => {
let requestedUrl;
let requestBody;
const service = new LayaService({
cache: null,
batchTimeoutMs: 120000,
fetch: async (url, options) => {
requestedUrl = url;
requestBody = JSON.parse(options.body);
return {
ok: true,
json: async () => ({
results: [
{ answers: { language: { choice: 'fr', answer_confidence: 0.93 } } },
{ answers: { language: { choice: 'en', confidence: 0.81 } } }
]
})
};
}
});
const response = await service.detectBatch({
model: 'multilingual',
items: [
{ id: 'fr-channel', text: 'France 2 FR' },
{ id: 'en-channel', text: 'BBC News UK' }
]
});
assert.equal(requestedUrl, 'http://laya:8000/v1/systemone/batch');
assert.deepEqual(requestBody.states, ['France 2 FR', 'BBC News UK']);
assert.equal(requestBody.model, 'multilingual');
assert.deepEqual(response, {
runtimeAvailable: true,
model: 'multilingual',
results: [
{ id: 'fr-channel', language: 'fr', confidence: 0.93, cached: false },
{ id: 'en-channel', language: 'en', confidence: 0.81, cached: false }
]
});
});
test('identity hints send only sanitized channel metadata to Laya', async () => {
let requestBody;
const service = new LayaService({

View File

@ -178,6 +178,139 @@ test('user analysis click keeps visible channels after Local AI language failure
assert.ok(consoleWarnings.some(line => line.includes('Local AI language detection unavailable')));
});
test('successful Standard fallback after Local AI failure keeps channels and reports the AI failure', async () => {
const { window } = createDom();
let rebuildClicked = 0;
let layaBatchCalls = 0;
window.API = {
live: {
getChannels: async () => projectedChannels(),
rebuildProjection: async () => {
rebuildClicked += 1;
return { projection: { status: 'building', processed: 0, total: 2 } };
},
getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 })
},
laya: {
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
detectLanguages: async () => {
layaBatchCalls += 1;
throw new Error('simulated local AI language failure');
}
}
};
const app = {
player: {
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' },
saveSettings() {}
},
epgGuide: null,
navigateTo() {},
pages: { settings: { switchTab() {} } }
};
window.app = app;
const channelList = new window.ChannelList();
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
app.channelList = channelList;
await channelList.loadChannels();
channelList.expandAll();
assert.deepEqual(
[...window.document.querySelectorAll('.channel-name')].map(item => item.textContent),
['Sports One', 'Sports One']
);
app.player.settings.languageDetectionMode = 'hybrid';
const settingsPage = Object.create(window.SettingsPage.prototype);
settingsPage.app = app;
settingsPage.initLiveAnalysis();
await Promise.resolve();
window.document.getElementById('live-analysis-button').click();
assert.equal(rebuildClicked, 1);
await new Promise(resolve => setTimeout(resolve, 1100));
channelList.expandAll();
window.clearInterval(channelList._epgRefreshTimer);
window.clearInterval(settingsPage.liveAnalysisPollTimer);
const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
const notice = window.document.querySelector('.laya-language-notice');
assert.equal(layaBatchCalls, 1);
assert.deepEqual(afterNames, ['Sports One', 'Sports One']);
assert.equal(window.document.querySelector('.empty-state'), null);
assert.equal(window.document.querySelector('#channel-list > .loading'), null);
assert.ok(notice);
assert.match(notice.textContent, /Local AI language detection failed/);
assert.match(notice.textContent, /Standard matching/);
assert.equal(channelList.languageDetectionFallback, true);
});
test('successful Laya language batch updates visible variant languages without Standard fallback', async () => {
const { window } = createDom();
let layaBatchCalls = 0;
let batchPayload = null;
window.API = {
live: {
getChannels: async () => projectedChannels(),
rebuildProjection: async () => ({ projection: { status: 'success', processed: 2, total: 2, projectedCount: 1 } }),
getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 })
},
laya: {
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
detectLanguages: async items => {
layaBatchCalls += 1;
batchPayload = items;
return {
runtimeAvailable: true,
model: 'multilingual',
results: items.map(item => ({
id: item.id,
language: item.id.endsWith('fr-sd') ? 'en' : 'fr',
confidence: item.id.endsWith('fr-sd') ? 0.91 : 0.87
}))
};
}
}
};
const app = {
player: {
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' },
saveSettings() {}
},
epgGuide: null,
navigateTo() {},
pages: { settings: { switchTab() {} } }
};
window.app = app;
const channelList = new window.ChannelList();
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
app.channelList = channelList;
await channelList.loadChannels();
app.player.settings.languageDetectionMode = 'laya';
await channelList.refreshLayaLanguages('laya');
channelList.expandedVariants.add('sports one');
channelList.expandAll();
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
const evidenceTypes = channelList.logicalChannels
.flatMap(channel => channel.variants)
.flatMap(variant => variant.languageEvidence.map(item => item.type));
window.clearInterval(channelList._epgRefreshTimer);
assert.equal(layaBatchCalls, 1);
assert.equal(JSON.stringify(batchPayload.map(item => item.id).sort()), JSON.stringify(['1:en-4k', '1:fr-sd']));
assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 87%']);
assert.ok(evidenceTypes.every(type => type === 'laya'));
assert.equal(channelList.languageDetectionFallback, false);
assert.equal(channelList.languageDetectionNotice, '');
assert.equal(window.document.querySelector('.laya-language-notice'), null);
});
test('analysis click does not replace visible channels with an empty successful projection', async () => {
const { window } = createDom();
let rebuildClicked = 0;