feat: add optional Laya engine mode for grouping and language detection (#12)
Add a languageDetectionMode setting (algo | laya | laya+algo) exposed in Settings with three engine choices. When Laya is enabled, a laya service (Dockerfile.laya, optional compose profile, models stored outside the repo under ./data/models/laya) can be started, with model download and deletion from the UI via /laya routes to manage disk space. Add a channelLanguageFusion service combining Laya results with the existing algorithm, and wire detection mode through grouping, channel list and player. Cover engine selection, fusion and laya service behavior with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
195
server/services/layaService.js
Normal file
195
server/services/layaService.js
Normal file
@ -0,0 +1,195 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const MODEL_DIR = path.resolve(__dirname, '..', '..', 'data', 'models', 'laya');
|
||||
const MAX_BATCH_ITEMS = 64;
|
||||
const MAX_BATCH_BYTES = 64 * 1024;
|
||||
const MAX_TEXT_LENGTH = 4096;
|
||||
const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']);
|
||||
|
||||
class LayaRequestError extends Error {
|
||||
constructor(message, statusCode = 503) {
|
||||
super(message);
|
||||
this.name = 'LayaRequestError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBatchPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new LayaRequestError('Request body must be an object', 400);
|
||||
}
|
||||
if (!Array.isArray(payload.items) || payload.items.length === 0) {
|
||||
throw new LayaRequestError('items must be a non-empty array', 400);
|
||||
}
|
||||
if (Object.keys(payload).some(key => !['items', 'model'].includes(key))) {
|
||||
throw new LayaRequestError('Request body contains unsupported fields', 400);
|
||||
}
|
||||
if (payload.items.length > MAX_BATCH_ITEMS || Buffer.byteLength(JSON.stringify(payload)) > MAX_BATCH_BYTES) {
|
||||
throw new LayaRequestError('Batch payload is too large', 413);
|
||||
}
|
||||
payload.items.forEach((item, index) => {
|
||||
if (!item || typeof item !== 'object' || typeof item.text !== 'string' || !item.text.trim()) {
|
||||
throw new LayaRequestError(`items[${index}].text must be a non-empty string`, 400);
|
||||
}
|
||||
if (item.text.length > MAX_TEXT_LENGTH) {
|
||||
throw new LayaRequestError(`items[${index}].text is too long`, 413);
|
||||
}
|
||||
if (Object.keys(item).some(key => !['id', 'text'].includes(key))) {
|
||||
throw new LayaRequestError(`items[${index}] contains unsupported fields`, 400);
|
||||
}
|
||||
if (item.id !== undefined && !['string', 'number'].includes(typeof item.id)) {
|
||||
throw new LayaRequestError(`items[${index}].id must be a string or number`, 400);
|
||||
}
|
||||
});
|
||||
if (payload.model !== undefined && !ALLOWED_MODELS.has(payload.model)) {
|
||||
throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function clearDirectoryContents(directory) {
|
||||
const root = path.resolve(directory);
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
const entries = await fs.readdir(root);
|
||||
for (const entry of entries) {
|
||||
const target = path.resolve(root, entry);
|
||||
if (path.dirname(target) !== root) {
|
||||
throw new Error('Refusing to delete outside the Laya model directory');
|
||||
}
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
class LayaService {
|
||||
constructor(options = {}) {
|
||||
this.baseUrl = options.baseUrl || process.env.LAYA_BASE_URL || 'http://laya:8000';
|
||||
this.modelDir = path.resolve(options.modelDir || MODEL_DIR);
|
||||
this.fetch = options.fetch || globalThis.fetch;
|
||||
this.cache = options.cache === undefined
|
||||
? require('../db/sqlite').layaLanguageCache
|
||||
: options.cache;
|
||||
this.timeoutMs = options.timeoutMs || 3000;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
if (typeof this.fetch !== 'function') throw new LayaRequestError('Laya runtime client is unavailable');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
if (process.env.LAYA_API_KEY) headers.authorization = `Bearer ${process.env.LAYA_API_KEY}`;
|
||||
try {
|
||||
const response = await this.fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: { ...headers, ...options.headers },
|
||||
signal: controller.signal
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new LayaRequestError(`Laya runtime returned HTTP ${response.status}`, 502);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof LayaRequestError) throw error;
|
||||
throw new LayaRequestError('Laya runtime is unavailable');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async status() {
|
||||
await fs.mkdir(this.modelDir, { recursive: true });
|
||||
const modelPresent = (await fs.readdir(this.modelDir)).length > 0;
|
||||
try {
|
||||
const runtime = await this.request('/health', { method: 'GET' });
|
||||
return { runtimeAvailable: true, modelPresent, runtime };
|
||||
} catch (error) {
|
||||
return { runtimeAvailable: false, modelPresent, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async downloadModel(model = 'multilingual') {
|
||||
if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
await fs.mkdir(this.modelDir, { recursive: true });
|
||||
await this.request('/v1/systemone', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
state: 'Bonjour',
|
||||
model,
|
||||
questions: {
|
||||
language: {
|
||||
type: 'choice',
|
||||
instructions: 'Identify the language of this text.',
|
||||
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async deleteModel() {
|
||||
await clearDirectoryContents(this.modelDir);
|
||||
if (this.cache) this.cache.clear();
|
||||
return { deleted: true, modelPresent: false };
|
||||
}
|
||||
|
||||
cacheKey(text, mode, model) {
|
||||
return crypto.createHash('sha256').update(`${mode}\0${model}\0${text}`).digest('hex');
|
||||
}
|
||||
|
||||
async detectBatch(payload) {
|
||||
validateBatchPayload(payload);
|
||||
const model = payload.model || 'multilingual';
|
||||
const mode = 'laya';
|
||||
const results = new Array(payload.items.length);
|
||||
const misses = [];
|
||||
|
||||
payload.items.forEach((item, index) => {
|
||||
const key = this.cacheKey(item.text, mode, model);
|
||||
const cached = this.cache ? this.cache.get(key) : null;
|
||||
if (cached) results[index] = { ...cached, cached: true };
|
||||
else misses.push({ item, index, key });
|
||||
});
|
||||
|
||||
if (misses.length) {
|
||||
const response = await this.request('/v1/systemone/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
states: misses.map(({ item }) => item.text),
|
||||
model,
|
||||
questions: {
|
||||
language: {
|
||||
type: 'choice',
|
||||
instructions: 'Identify the language of this text.',
|
||||
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!response || !Array.isArray(response.results) || response.results.length !== misses.length) {
|
||||
throw new LayaRequestError('Laya runtime returned an invalid batch response', 502);
|
||||
}
|
||||
misses.forEach(({ item, index, key }, responseIndex) => {
|
||||
const answer = response.results[responseIndex]?.answers?.language;
|
||||
const result = {
|
||||
id: item.id ?? index,
|
||||
language: answer?.choice || 'unknown',
|
||||
confidence: answer?.answer_confidence ?? answer?.confidence ?? 0,
|
||||
cached: false
|
||||
};
|
||||
results[index] = result;
|
||||
if (this.cache) this.cache.set(key, mode, model, { ...result, cached: undefined });
|
||||
});
|
||||
}
|
||||
return { runtimeAvailable: true, model, results };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_MODELS,
|
||||
LayaRequestError,
|
||||
LayaService,
|
||||
MODEL_DIR,
|
||||
clearDirectoryContents,
|
||||
validateBatchPayload
|
||||
};
|
||||
Reference in New Issue
Block a user