Translation
- Stop scrubbing markdown before sending it to LibreTranslate. The scrub
deleted ordered-list numbering ("1. Give amoxicillin" -> "Give amoxicillin"),
flattened tables into ambiguous whitespace and ate underscores inside
identifiers. Raw markdown now goes to the translator unchanged.
- Render the translation through the same markdown pipeline as the original
bubble, with the message's own sources, so [n] markers come back as the usual
clickable .assistant-cite chips instead of escaped literal text. Headings,
lists and tables survive with them.
- When the translator drops citation markers, surface the affected sources in a
recovery block rather than letting the evidence disappear.
- Image cards are live nodes: they are now re-attached on every path out of a
translation (success, failure and Show original), so a failed translation no
longer silently removes a generating image from the message.
Patient take home
- Add a language selector to the take-home modal, reusing the existing
/translate endpoint and offering only what the local LibreTranslate reports.
- Copy, Export and Email carry what the caregiver is actually reading; the
original stays canonical behind "Original".
Conversation budget
- The admin field no longer prefills with the environment value, which turned
the next Save into an accidental override and made the documented "leave
empty to use the environment" path unreachable. The effective limit is shown
as a placeholder instead.
- Report source 'default' honestly instead of naming an unset env var.
- The load-failure notice now lands on the <p> instead of an <input>'s
textContent, where it rendered nothing.
- One validator for the budget everywhere: conversationLimit() replaces a
parseInt that accepted "120000abc".
Other
- /assistant is addressed by its URL, not by ped_last_tab, so "/" no longer
reopens the assistant; the URL follows tab changes and Back leaves it.
- Remove the dead DeepL path (it referenced an undefined DEEPL_BASES) and stop
offering admins a provider the server silently ignores.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
144 lines
6.3 KiB
JavaScript
144 lines
6.3 KiB
JavaScript
// 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
|
|
};
|