// Clinical Assistant message translation — local-only via the user's own // LibreTranslate container. Patient text never leaves the local network. // Validation failures never retry against anything else. const crypto = require('crypto'); const axios = require('axios'); const TRANSLATE_PROVIDERS = ['libretranslate']; const TRANSLATE_LANGS = ['en', 'es', 'fr', 'de', 'it', 'pt', 'zh', 'ar', 'ru', 'hi', 'nl', 'pl', 'tr', 'uk', 'fa', 'sw']; const MAX_TRANSLATE_CHARS = 20000; const PROVIDER_TIMEOUT_MS = 15000; const DEFAULT_CACHE_MAX = 100; const LANGUAGE_CACHE_MAX = 1; function failure(message, statusCode, code) { return Object.assign(new Error(message), { statusCode, code }); } function createLanguageCache() { return { map: new Map() }; } function createTranslationCache(max = DEFAULT_CACHE_MAX) { return { map: new Map(), max: max }; } function cacheKey(provider, userId, target, message) { const hash = crypto.createHash('sha256').update(String(message)).digest('hex'); return provider + '\u0000' + String(userId || '') + '\u0000' + target + '\u0000' + hash; } function cacheGet(cache, key) { return cache.map.get(key); } function cacheSet(cache, key, value) { if (cache.map.size >= cache.max) cache.map.delete(cache.map.keys().next().value); // ponytail: FIFO eviction is enough here cache.map.set(key, value); } function isTransientError(err) { const status = err && err.response && err.response.status; return !status || status >= 500 || status === 429; } async function callLibreTranslate(message, target, env, http, format) { const base = String(env.LIBRETRANSLATE_URL || 'http://libretranslate:5000'); if (!/^https?:\/\//.test(base)) throw failure('Local translation service is misconfigured.', 503, 'LIBRETRANSLATE_UNREACHABLE'); const response = await http.post(base + '/translate', { q: message, source: 'auto', target: target, format: format === 'html' ? 'html' : 'text' }, { timeout: PROVIDER_TIMEOUT_MS }); const translated = response && response.data && response.data.translatedText; if (!translated) throw failure('Local translation service returned no translation.', 502, 'LIBRETRANSLATE_EMPTY'); return String(translated); } async function translateMessage(options) { const opts = options || {}; const message = opts.message; const target = String(opts.target || '').toLowerCase(); const http = opts.axios || axios; const env = opts.env || process.env || {}; var cache = opts.cache || createTranslationCache(); if (!cache.map) cache = { map: cache, max: DEFAULT_CACHE_MAX }; // accept a raw Map if (typeof message !== 'string' || !message.trim()) throw failure('Message to translate is required.', 400, 'INVALID_TRANSLATE'); if (message.length > MAX_TRANSLATE_CHARS) throw failure('Message exceeds the 20,000 character translation limit.', 400, 'INVALID_TRANSLATE'); if (!TRANSLATE_LANGS.includes(target)) throw failure('Unsupported translation language.', 400, 'INVALID_TRANSLATE'); var provider = opts.provider; if (opts.provider != null && opts.provider !== '' && !TRANSLATE_PROVIDERS.includes(String(opts.provider).toLowerCase())) { throw failure('Unsupported translation provider.', 400, 'INVALID_TRANSLATE_PROVIDER'); } provider = 'libretranslate'; // the only provider — local translation always const format = opts.format === 'html' ? 'html' : 'text'; const key = cacheKey(provider, opts.userId, target, message + '\u0000' + format); const hit = cacheGet(cache, key); if (hit) return { translated: hit, provider: provider }; async function run(p) { try { const translated = await callLibreTranslate(message, target, env, http, format); cacheSet(cache, key, translated); return { translated, provider: p }; } catch (err) { const error = failure(err && err.message ? err.message : 'Translation service unavailable.', (err && err.statusCode) || (isTransientError(err) ? 502 : ((err && err.response && err.response.status) || 502)), err && err.code ? err.code : 'TRANSLATION_FAILED'); error.transient = (isTransientError(err) && !err.statusCode) || err.code === 'LIBRETRANSLATE_EMPTY'; throw error; } } return run(provider); } // Language availability: the local LibreTranslate container only loads a // subset of models (LT_LOAD_ONLY), so the UI must offer exactly what the // local instance can actually translate. Cached briefly in-process. function languagesCacheGet(cache, provider) { const hit = cache.map.get(provider); if (hit && Date.now() - hit.at < 5 * 60 * 1000) return hit.value; return null; } function languagesCacheSet(cache, provider, value) { cache.map.set(provider, { at: Date.now(), value: value }); } async function listAvailableLanguages(opts) { const provider = String(opts.provider || 'libretranslate').toLowerCase(); const cache = opts.languageCache || null; if (cache) { const hit = languagesCacheGet(cache, provider); if (hit) return hit; } const env = opts.env || process.env; const http = opts.http || axios; if (provider === 'libretranslate') { const base = String(env.LIBRETRANSLATE_URL || 'http://libretranslate:5000'); if (!/^https?:\/\//.test(base)) throw failure('Local translation service is misconfigured.', 503, 'LIBRETRANSLATE_UNREACHABLE'); let codes; try { const response = await http.get(base + '/languages', { timeout: 10000 }); const list = Array.isArray(response && response.data) ? response.data : []; codes = list.filter(function(item) { return item && Array.isArray(item.targets) && item.targets.length; }).map(function(item) { return String(item.code).toLowerCase(); }); } catch (e) { if (isTransientError(e)) throw failure('Local translation service is unreachable.', 502, 'LIBRETRANSLATE_UNREACHABLE'); throw failure('Local translation service rejected the language query.', 502, 'LIBRETRANSLATE_UNREACHABLE'); } if (!codes.length) throw failure('Local translation service returned no languages.', 502, 'LIBRETRANSLATE_EMPTY'); const value = { libretranslate: codes }; if (cache) languagesCacheSet(cache, provider, value); return value; } throw failure('Unknown translation provider.', 400, 'INVALID_PROVIDER'); } module.exports = { TRANSLATE_PROVIDERS, TRANSLATE_LANGS, MAX_TRANSLATE_CHARS, createTranslationCache, createLanguageCache, listAvailableLanguages, translateMessage };