pediatric-ai-scribe-v3/src/utils/clinicalTranslation.js
Daniel 4d92488f0c fix: translated answers keep their source chips; the patient take home can be translated
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
2026-09-09 18:40:23 +02:00

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
};