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
This commit is contained in:
parent
e22eb8dbdf
commit
4d92488f0c
16 changed files with 386 additions and 98 deletions
|
|
@ -330,8 +330,7 @@
|
|||
<div class="admin-row">
|
||||
<label for="assistant-translate-provider" class="admin-row-label">Translation provider</label>
|
||||
<select id="assistant-translate-provider" class="admin-control">
|
||||
<option value="libretranslate">Local LibreTranslate (default)</option>
|
||||
<option value="deepl">DeepL (requires DEEPL_API_KEY)</option>
|
||||
<option value="libretranslate">Local LibreTranslate (patient text stays on this network)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@
|
|||
.assistant-translate-providers button.active { background:var(--purple); color:white; border-color:var(--purple); }
|
||||
.assistant-translate-pop [data-assistant-translate-lang] { border:0; background:transparent; text-align:left; padding:4px 6px; font-size:12px; color:var(--g700); border-radius:6px; cursor:pointer; }
|
||||
.assistant-translate-pop [data-assistant-translate-lang]:hover { background:var(--g50); }
|
||||
.assistant-translated-text { white-space:pre-wrap; margin:0; }
|
||||
.assistant-translated-sources { margin-top:10px; padding-top:8px; border-top:1px solid var(--g200); font-size:12px; color:var(--g500); }
|
||||
.assistant-translated-sources strong { display:block; font-weight:600; margin-bottom:4px; }
|
||||
.assistant-message-attachments { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.assistant-message-attachment { margin:0; display:grid; gap:3px; justify-items:center; }
|
||||
.assistant-message-attachment img { width:64px; height:64px; object-fit:cover; border-radius:8px; border:1px solid var(--g200); background:var(--g50); display:block; }
|
||||
|
|
@ -245,6 +246,9 @@
|
|||
.assistant-takehome-result th, .assistant-takehome-result td { border:1px solid var(--g200); padding:5px 8px; text-align:left; }
|
||||
.assistant-takehome-result code { background:var(--g100, #f3f4f6); border-radius:4px; padding:1px 5px; font-size:12px; }
|
||||
.assistant-takehome-actions { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; border-top: 1px solid var(--border, #e5e7eb); padding-top: .75rem; }
|
||||
.assistant-takehome-lang { display: flex; align-items: center; }
|
||||
.assistant-takehome-lang select { font-size: 12px; padding: 4px 6px; border: 1px solid var(--g200); border-radius: 6px; background: white; color: var(--g900); max-width: 150px; }
|
||||
.assistant-takehome-lang select:disabled { opacity: .6; cursor: progress; }
|
||||
.assistant-takehome-email { display: flex; gap: .5rem; margin-left: auto; }
|
||||
.assistant-takehome-email input { width: 220px; }
|
||||
.assistant-takehome-loading { padding: 1.25rem 0; color: var(--muted, #6b7280); }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,17 @@
|
|||
function assistantBudgetMeta(budget, savedOverride) {
|
||||
var limit = budget && Number.isInteger(budget.limit) ? budget.limit.toLocaleString() : null;
|
||||
if (savedOverride) {
|
||||
return 'Saved override in use. Clear this field to fall back to CLINICAL_ASSISTANT_CONVERSATION_CHARS' +
|
||||
(limit ? ' (' + limit + ' UTF-16 code units).' : '.');
|
||||
}
|
||||
if (!limit) return 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.';
|
||||
// conversationBudget() reports 'default' when the env var is unset, so naming
|
||||
// the env var alone would be wrong in the most common deployment.
|
||||
return budget.source === 'environment'
|
||||
? 'Currently ' + limit + ' UTF-16 code units from CLINICAL_ASSISTANT_CONVERSATION_CHARS. Set a value here to override it.'
|
||||
: 'Currently the built-in default of ' + limit + ' UTF-16 code units (CLINICAL_ASSISTANT_CONVERSATION_CHARS is unset). Set a value here to override it.';
|
||||
}
|
||||
|
||||
export function initClinicalAssistantAdmin(adminEscapeHtml) {
|
||||
let configState = 'idle';
|
||||
const IMAGE_MODEL_FALLBACKS = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
|
||||
|
|
@ -135,16 +149,18 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
|
|||
renderAssistantImageModels([], window._assistantImageModelValue);
|
||||
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
||||
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
||||
setValue('assistant-translate-provider', ['libretranslate', 'deepl'].includes(cfg['clinical_assistant.translate_provider']) ? cfg['clinical_assistant.translate_provider'] : 'libretranslate');
|
||||
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
|
||||
var budgetInput = document.getElementById('assistant-conversation-budget');
|
||||
if (budgetInput && budget && Number.isInteger(budget.limit)) {
|
||||
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
|
||||
budgetInput.value = cfgBudget ? String(cfgBudget) : String(budget.limit);
|
||||
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
|
||||
if (budgetInput) {
|
||||
// Empty means "no override". Prefilling the environment value here would
|
||||
// turn the next Save into an accidental override, making the documented
|
||||
// "leave empty to use the environment" path unreachable.
|
||||
budgetInput.value = cfgBudget ? String(cfgBudget) : '';
|
||||
if (budget && Number.isInteger(budget.limit)) budgetInput.placeholder = String(budget.limit);
|
||||
}
|
||||
var budgetMeta = document.getElementById('assistant-conversation-budget-meta');
|
||||
if (budgetMeta) budgetMeta.textContent = budget && budget.source === 'environment'
|
||||
? 'Currently from CLINICAL_ASSISTANT_CONVERSATION_CHARS (' + budget.limit.toLocaleString() + '). Set a value here to override it; clear it to use the environment again.'
|
||||
: 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.';
|
||||
if (budgetMeta) budgetMeta.textContent = assistantBudgetMeta(budget, cfgBudget);
|
||||
configState = 'ready';
|
||||
updateAssistantLoadState();
|
||||
loadAssistantImageModels();
|
||||
|
|
@ -152,8 +168,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
|
|||
}).catch(function() {
|
||||
configState = 'failed';
|
||||
updateAssistantLoadState();
|
||||
var budgetLabel = document.getElementById('assistant-conversation-budget');
|
||||
if (budgetLabel) budgetLabel.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.';
|
||||
var budgetMetaFailed = document.getElementById('assistant-conversation-budget-meta');
|
||||
if (budgetMetaFailed) budgetMetaFailed.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,8 +124,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
} else {
|
||||
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
|
||||
}
|
||||
// Persist last tab in localStorage (restored after login in auth.js)
|
||||
try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {}
|
||||
// The assistant's location is the URL (/assistant), not ped_last_tab —
|
||||
// storing it there made "/" reopen the assistant and defeated its own page.
|
||||
if (tabName !== 'assistant') {
|
||||
try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {}
|
||||
}
|
||||
syncTabLocation(tabName);
|
||||
// Close sidebar on mobile after tab click
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
if (sidebar && window.innerWidth <= 768) {
|
||||
|
|
@ -134,6 +138,26 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
return true;
|
||||
}
|
||||
|
||||
// The assistant is served from its own path; every other tab lives at "/".
|
||||
// replaceState keeps the URL honest without stacking a history entry per tab.
|
||||
function syncTabLocation(tabName) {
|
||||
if (typeof history === 'undefined' || typeof history.replaceState !== 'function') return;
|
||||
var target = tabName === 'assistant' ? '/assistant' : '/';
|
||||
if (window.location.pathname === target) return;
|
||||
try { history.replaceState({ tab: tabName }, '', target + window.location.search + window.location.hash); } catch (e) {}
|
||||
}
|
||||
|
||||
// Browser Back out of /assistant must actually leave the assistant.
|
||||
window.addEventListener('popstate', function() {
|
||||
var wanted = window.location.pathname === '/assistant' ? 'assistant' : null;
|
||||
if (wanted) { activateTab(wanted); return; }
|
||||
var active = document.querySelector('.tab-btn.active');
|
||||
if (!active || active.getAttribute('data-tab') !== 'assistant') return;
|
||||
var fallback = null;
|
||||
try { fallback = localStorage.getItem('ped_last_tab'); } catch (e) {}
|
||||
activateTab(fallback && fallback !== 'assistant' ? fallback : 'encounter');
|
||||
});
|
||||
|
||||
// Expose activateTab globally so auth.js can call it after login
|
||||
window.activateTab = activateTab;
|
||||
|
||||
|
|
|
|||
|
|
@ -463,6 +463,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// Restore last visited tab (saved by activateTab)
|
||||
var lastTab = null;
|
||||
try { lastTab = localStorage.getItem('ped_last_tab'); } catch(e) {}
|
||||
// The URL is authoritative for the assistant. A stored 'assistant' is a
|
||||
// legacy value from before the split and must not hijack "/".
|
||||
if (lastTab === 'assistant') lastTab = null;
|
||||
if (typeof window !== 'undefined' && window.location && window.location.pathname === '/assistant') lastTab = 'assistant';
|
||||
if (typeof window.activateTab === 'function') {
|
||||
if (!lastTab || !window.activateTab(lastTab)) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// server can call native MCP directly without routing through mcpo.
|
||||
// ============================================================
|
||||
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
|
||||
import { escapeAttr, escapeHtml, renderAssistantMarkdown, safeImageUrl } from './assistant/citations.js';
|
||||
import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl } from './assistant/citations.js';
|
||||
import { renderSourcesList } from './assistant/sources.js';
|
||||
import { createAssistantExporter } from './assistant/export.js';
|
||||
import { createAssistantImageStore } from './assistant/images.js';
|
||||
|
|
@ -54,7 +54,7 @@ import {
|
|||
|
||||
var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']];
|
||||
var savedChatCache = [];
|
||||
var translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] }
|
||||
var translateLanguagesAvailable = null; // { libretranslate: [...] }
|
||||
var translateLanguagesLoading = false;
|
||||
|
||||
function refreshTranslateLanguages(pop) {
|
||||
|
|
@ -71,18 +71,23 @@ import {
|
|||
if (typeof document === 'undefined') return;
|
||||
var open = document.querySelector('[data-assistant-translate-pop]');
|
||||
if (open) renderTranslateLanguages(open, open.assistantMessageRow);
|
||||
renderTakehomeLanguageOptions();
|
||||
});
|
||||
}
|
||||
|
||||
function availableTranslateLanguages() {
|
||||
var available = translateLanguagesAvailable;
|
||||
return TRANSLATE_LANGUAGES.filter(function(pair) {
|
||||
if (!available) return true;
|
||||
var codes = available[translateProvider] || available.libretranslate || [];
|
||||
return codes.indexOf(pair[0]) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTranslateLanguages(pop, row) {
|
||||
var list = pop.querySelector('.assistant-translate-langs');
|
||||
if (!list) return;
|
||||
var available = translateLanguagesAvailable;
|
||||
var pairs = TRANSLATE_LANGUAGES.filter(function(pair) {
|
||||
if (!available) return true;
|
||||
var codes = available[translateProvider] || available.libretranslate || [];
|
||||
return codes.indexOf(pair[0]) !== -1;
|
||||
});
|
||||
var pairs = availableTranslateLanguages();
|
||||
list.innerHTML = pairs.map(function(pair) {
|
||||
return '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
|
||||
}).join('');
|
||||
|
|
@ -217,7 +222,7 @@ import {
|
|||
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
|
||||
}
|
||||
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
|
||||
if (data.success && ['libretranslate', 'deepl'].includes(data.translateProvider)) translateProvider = data.translateProvider;
|
||||
if (data.success && data.translateProvider === 'libretranslate') translateProvider = data.translateProvider;
|
||||
if (data.success) {
|
||||
statusChoices = {
|
||||
allowedChatModels: data.allowedChatModels || [],
|
||||
|
|
@ -818,7 +823,9 @@ import {
|
|||
if (showOriginal) {
|
||||
var translatedBubble = showOriginal.closest('.assistant-bubble');
|
||||
if (translatedBubble && translatedBubble.assistantOriginalHtml) {
|
||||
var keptCards = detachImageCards(translatedBubble);
|
||||
translatedBubble.innerHTML = translatedBubble.assistantOriginalHtml;
|
||||
reattachImageCards(translatedBubble, keptCards);
|
||||
renderEmbeddedBlocks(translatedBubble);
|
||||
}
|
||||
return;
|
||||
|
|
@ -1499,7 +1506,14 @@ import {
|
|||
|
||||
// ── Patient take home: plain-language summary + copy/export/email ────
|
||||
var takehomeBusy = false;
|
||||
var takehomeText = '';
|
||||
var takehomeText = ''; // canonical original, never overwritten
|
||||
var takehomeTranslated = ''; // current translation, '' when showing the original
|
||||
var takehomeLang = '';
|
||||
|
||||
// Copy / Export / Email must hand over what the parent is actually reading.
|
||||
function takehomeVisibleText() {
|
||||
return takehomeTranslated || takehomeText;
|
||||
}
|
||||
|
||||
function openPatientTakehome() {
|
||||
if (takehomeBusy) return;
|
||||
|
|
@ -1510,6 +1524,8 @@ import {
|
|||
closePatientTakehomeModal();
|
||||
takehomeBusy = true;
|
||||
takehomeText = '';
|
||||
takehomeTranslated = '';
|
||||
takehomeLang = '';
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'assistant-takehome-modal';
|
||||
modal.id = 'assistant-takehome-modal';
|
||||
|
|
@ -1521,6 +1537,8 @@ import {
|
|||
'<div class="assistant-takehome-actions hidden">' +
|
||||
'<button type="button" class="btn-sm" data-assistant-takehome-copy><i class="fas fa-copy"></i> Copy</button>' +
|
||||
'<button type="button" class="btn-sm" data-assistant-takehome-export><i class="fas fa-download"></i> Export</button>' +
|
||||
'<label class="assistant-takehome-lang">' +
|
||||
'<select id="assistant-takehome-lang" data-assistant-takehome-lang aria-label="Translate take home"></select></label>' +
|
||||
'<div class="assistant-takehome-email"><input type="email" id="assistant-takehome-email" placeholder="Email address" autocomplete="off">' +
|
||||
'<button type="button" class="btn-sm btn-primary" data-assistant-takehome-send>Send</button></div>' +
|
||||
'</div></div></div>';
|
||||
|
|
@ -1546,12 +1564,53 @@ import {
|
|||
result.classList.remove('hidden');
|
||||
}
|
||||
if (actions) actions.classList.remove('hidden');
|
||||
renderTakehomeLanguageOptions();
|
||||
if (!translateLanguagesAvailable) refreshTranslateLanguages();
|
||||
}).catch(function(err) {
|
||||
var loading = modal.querySelector('.assistant-takehome-loading');
|
||||
if (loading) { loading.innerHTML = '<i class="fas fa-triangle-exclamation"></i> ' + escapeHtml(err.message); }
|
||||
}).finally(function() { takehomeBusy = false; });
|
||||
}
|
||||
|
||||
function renderTakehomeLanguageOptions() {
|
||||
var select = document.getElementById('assistant-takehome-lang');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Original</option>' +
|
||||
availableTranslateLanguages().map(function(pair) {
|
||||
return '<option value="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</option>';
|
||||
}).join('');
|
||||
select.value = takehomeLang;
|
||||
}
|
||||
|
||||
function renderTakehomeBody() {
|
||||
var result = document.querySelector('#assistant-takehome-modal .assistant-takehome-result');
|
||||
if (result) result.innerHTML = renderMarkdown(takehomeVisibleText(), [], {});
|
||||
}
|
||||
|
||||
function translateTakehome(target) {
|
||||
var select = document.getElementById('assistant-takehome-lang');
|
||||
if (!takehomeText) return;
|
||||
if (!target) { // back to the original
|
||||
takehomeLang = '';
|
||||
takehomeTranslated = '';
|
||||
renderTakehomeBody();
|
||||
return;
|
||||
}
|
||||
if (select) select.disabled = true;
|
||||
translateAssistantMessage(takehomeText, target, translateProvider, 'text')
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Translation failed');
|
||||
takehomeLang = target;
|
||||
takehomeTranslated = String(data.translated || '');
|
||||
renderTakehomeBody();
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (select) select.value = takehomeLang; // keep the select honest about what is shown
|
||||
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
|
||||
})
|
||||
.finally(function() { if (select) select.disabled = false; });
|
||||
}
|
||||
|
||||
function exportTakehomePdf() {
|
||||
var owner;
|
||||
try { owner = captureSharingOwner(); } catch (_) { return; }
|
||||
|
|
@ -1562,7 +1621,7 @@ import {
|
|||
return;
|
||||
}
|
||||
assertSharingOwner(owner);
|
||||
var html = renderMarkdown(takehomeText, [], {});
|
||||
var html = renderMarkdown(takehomeVisibleText(), [], {});
|
||||
doc.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Patient Take Home</title>' +
|
||||
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.6;margin:32px;max-width:720px}' +
|
||||
'h1{color:#0f766e;font-size:24px;margin:0 0 4px;border-bottom:2px solid #0f766e;padding-bottom:8px}' +
|
||||
|
|
@ -1576,17 +1635,24 @@ import {
|
|||
function closePatientTakehomeModal() {
|
||||
var modal = document.getElementById('assistant-takehome-modal');
|
||||
if (modal) modal.remove();
|
||||
takehomeTranslated = '';
|
||||
takehomeLang = '';
|
||||
}
|
||||
|
||||
document.addEventListener('change', function(event) {
|
||||
var langSelect = event.target.closest && event.target.closest('[data-assistant-takehome-lang]');
|
||||
if (langSelect) translateTakehome(langSelect.value);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
var galleryItem = event.target.closest('[data-gallery-image]');
|
||||
if (galleryItem) { openImagePreview(galleryItem.getAttribute('data-gallery-image')); return; }
|
||||
if (event.target.closest('[data-assistant-takehome-copy]')) {
|
||||
copyText(takehomeText, 'Take home copied', 'Copy failed');
|
||||
copyText(takehomeVisibleText(), 'Take home copied', 'Copy failed');
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-assistant-takehome-export]')) {
|
||||
if (!takehomeText) return;
|
||||
if (!takehomeVisibleText()) return;
|
||||
exportTakehomePdf();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1599,7 +1665,7 @@ import {
|
|||
fetch('/api/clinical-assistant/patient-takehome/email', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })),
|
||||
body: JSON.stringify({ to: to, text: takehomeText })
|
||||
body: JSON.stringify({ to: to, text: takehomeVisibleText() })
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not send the email');
|
||||
if (typeof showToast === 'function') showToast('Take home sent to ' + to, 'success');
|
||||
|
|
@ -1617,8 +1683,8 @@ import {
|
|||
var pop = document.createElement('div');
|
||||
pop.className = 'assistant-translate-pop';
|
||||
pop.setAttribute('data-assistant-translate-pop', '');
|
||||
// Simple for users: pick a language, the server picks the provider
|
||||
// (local LibreTranslate by default; DeepL only when the admin configured it).
|
||||
// Simple for users: pick a language. Translation is always the local
|
||||
// LibreTranslate container, so patient text never leaves this network.
|
||||
pop.innerHTML = '<div class="assistant-translate-langs">' +
|
||||
TRANSLATE_LANGUAGES.map(function(pair) {
|
||||
return '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
|
||||
|
|
@ -1633,39 +1699,71 @@ import {
|
|||
document.querySelectorAll('[data-assistant-translate-pop]').forEach(function(pop) { pop.remove(); });
|
||||
}
|
||||
|
||||
function plainTextForTranslation(markdown) {
|
||||
return String(markdown || '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/[*_~`]{1,3}/g, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '• ')
|
||||
.replace(/^\s*\d+\.\s+/gm, '')
|
||||
.replace(/^\s*[|>]\s*/gm, '')
|
||||
.replace(/\|/g, ' ')
|
||||
.trim();
|
||||
function detachImageCards(bubble) {
|
||||
var cards = Array.prototype.slice.call(bubble.querySelectorAll('.assistant-image-card'));
|
||||
cards.forEach(function(card) { card.remove(); });
|
||||
return cards;
|
||||
}
|
||||
|
||||
function reattachImageCards(bubble, cards) {
|
||||
cards.forEach(function(card) { bubble.appendChild(card); });
|
||||
}
|
||||
|
||||
// Citation markers must survive the round trip so the translated answer keeps
|
||||
// its clickable [n] chips. Markdown itself is sent unchanged: the old scrub
|
||||
// deleted ordered-list numbers, flattened tables and ate underscores.
|
||||
function citationNumbersIn(text) {
|
||||
var found = [];
|
||||
String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
|
||||
cluster.split(',').forEach(function(n) {
|
||||
var num = Number(n.trim());
|
||||
if (Number.isInteger(num) && num > 0 && found.indexOf(num) === -1) found.push(num);
|
||||
});
|
||||
return match;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function requestMessageTranslation(row, target, provider) {
|
||||
var bubble = row && row.querySelector('.assistant-bubble');
|
||||
if (!bubble) return;
|
||||
// Keep the live image card attached through translate and Show original.
|
||||
var imageCards = Array.prototype.slice.call(bubble.querySelectorAll('.assistant-image-card'));
|
||||
imageCards.forEach(function(card) { card.remove(); });
|
||||
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
|
||||
// Reliable path: translate plain text (no markdown syntax to mangle) and
|
||||
// show it as clean paragraphs; the original keeps its full formatting.
|
||||
var raw = bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : String(bubble.textContent || '').trim();
|
||||
if (!String(raw).trim()) return;
|
||||
var plain = plainTextForTranslation(raw);
|
||||
if (!String(plain).trim()) return;
|
||||
translateAssistantMessage(plain, target, provider, 'text')
|
||||
// Image cards are live nodes whose status polling must survive; they are
|
||||
// detached, kept out of the stored original HTML, and re-attached on every
|
||||
// path out of here (success, failure and Show original).
|
||||
var imageCards = detachImageCards(bubble);
|
||||
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
|
||||
var sources = Array.isArray(bubble.assistantSources) ? bubble.assistantSources : [];
|
||||
var expected = citationNumbersIn(raw);
|
||||
translateAssistantMessage(raw, target, provider, 'text')
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Translation failed');
|
||||
bubble.innerHTML = '<p class="assistant-translated-text">' + escapeHtml(String(data.translated || '')) + '</p>' +
|
||||
var translated = String(data.translated || '');
|
||||
// Same renderer as the original bubble: headings, lists and tables come
|
||||
// back, and [n] markers become the usual clickable .assistant-cite chips
|
||||
// bound to this message's sources.
|
||||
var kept = citationNumbersIn(translated);
|
||||
var lost = expected.filter(function(n) { return kept.indexOf(n) === -1; });
|
||||
var html = renderMarkdown(translated, sources, {});
|
||||
if (lost.length) {
|
||||
// The translator dropped markers; keep the evidence reachable rather
|
||||
// than letting the citations disappear with the formatting.
|
||||
html += '<div class="assistant-translated-sources"><strong>' +
|
||||
escapeHtml('Sources not carried into the translation') + '</strong>' +
|
||||
renderCitationLinks(lost.map(function(n) { return '[' + n + ']'; }).join(' '), sources, {}) +
|
||||
'</div>';
|
||||
}
|
||||
bubble.innerHTML = html +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
|
||||
imageCards.forEach(function(card) { bubble.appendChild(card); }); // same node — its status polling continues
|
||||
reattachImageCards(bubble, imageCards); // same nodes — status polling continues
|
||||
renderEmbeddedBlocks(bubble);
|
||||
})
|
||||
.catch(function(err) {
|
||||
// Put the message back exactly as it was, images included.
|
||||
bubble.innerHTML = bubble.assistantOriginalHtml;
|
||||
reattachImageCards(bubble, imageCards);
|
||||
renderEmbeddedBlocks(bubble);
|
||||
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
|||
var PROMPTS = require('../utils/prompts');
|
||||
var promptCatalog = require('../utils/promptCatalog');
|
||||
var promptRevisions = require('../utils/promptRevisions');
|
||||
var { conversationBudget } = require('../utils/clinicalConversation');
|
||||
var { conversationBudget, conversationLimit } = require('../utils/clinicalConversation');
|
||||
var logger = require('../utils/logger');
|
||||
var { gatewayUrl } = require('../utils/errors');
|
||||
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
|
||||
|
|
@ -851,12 +851,13 @@ router.put('/config/:key(*)', async function(req, res) {
|
|||
// Model policy mutations must use the validated model endpoints.
|
||||
if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' });
|
||||
if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' });
|
||||
if (key === 'clinical_assistant.conversation_chars') {
|
||||
if (value !== '' && value != null) {
|
||||
var budgetParsed = parseInt(value, 10);
|
||||
if (!Number.isInteger(budgetParsed) || budgetParsed < 1000 || budgetParsed > 1000000) {
|
||||
return res.status(400).json({ error: 'Conversation budget must be an integer between 1000 and 1000000 UTF-16 code units, or empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS' });
|
||||
}
|
||||
if (key === 'clinical_assistant.conversation_chars' && value !== '' && value != null) {
|
||||
// One validator for the whole budget: parseInt would have accepted
|
||||
// "120000abc". Empty stays legal and means "use the environment".
|
||||
try {
|
||||
conversationLimit(value);
|
||||
} catch (_) {
|
||||
return res.status(400).json({ error: 'Conversation budget must be an integer between 1000 and 1000000 UTF-16 code units, or empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS' });
|
||||
}
|
||||
}
|
||||
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ var {
|
|||
finalizeAssistantAnswer
|
||||
} = require('../utils/clinicalAnswer');
|
||||
|
||||
var { conversationBudget, checkConversation, validateAttachments, savedChatPayload } = require('../utils/clinicalConversation');
|
||||
var { conversationBudget, conversationLimit, checkConversation, validateAttachments, savedChatPayload } = require('../utils/clinicalConversation');
|
||||
var clinicalTranslation = require('../utils/clinicalTranslation');
|
||||
var patientTakehome = require('../utils/patientTakehome');
|
||||
var translateCache = clinicalTranslation.createTranslationCache();
|
||||
|
|
@ -758,11 +758,15 @@ function isUsefulIndexedTopicExample(item) {
|
|||
|
||||
async function getConversationLimit() {
|
||||
// Admin-set value wins over the environment so the administrator can test
|
||||
// the warning/refusal behavior with a lower limit.
|
||||
// the warning/refusal behavior with a lower limit. Same validator the admin
|
||||
// endpoint uses, so a value that saved cleanly is a value that applies.
|
||||
var override = await getSetting('clinical_assistant.conversation_chars', '');
|
||||
if (override !== '') {
|
||||
var parsed = parseInt(override, 10);
|
||||
if (Number.isInteger(parsed) && parsed >= 1000 && parsed <= 1000000) return parsed;
|
||||
if (override !== '' && override != null) {
|
||||
try {
|
||||
return conversationLimit(override);
|
||||
} catch (_) {
|
||||
logger.warn('Ignoring unusable clinical_assistant.conversation_chars override: ' + String(override));
|
||||
}
|
||||
}
|
||||
return conversationBudget(process.env).limit;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function createTranslationCache(max = DEFAULT_CACHE_MAX) {
|
|||
return { map: new Map(), max: max };
|
||||
}
|
||||
|
||||
function cacheKey(cache, provider, userId, target, message) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -49,24 +49,6 @@ async function callLibreTranslate(message, target, env, http, format) {
|
|||
return String(translated);
|
||||
}
|
||||
|
||||
async function callDeepL(message, target, env, http) {
|
||||
const base = String(env.DEEPL_API_BASE || 'https://api.deepl.com/v2');
|
||||
const key = String(env.DEEPL_API_KEY || '');
|
||||
if (!key || !DEEPL_BASES.includes(base)) throw failure('DeepL is not configured for this server.', 503, 'DEEPL_NOT_CONFIGURED');
|
||||
const body = 'text=' + encodeURIComponent(message) + '&target_lang=' + target.toUpperCase();
|
||||
const response = await http.post(base + '/translate', body, {
|
||||
headers: { Authorization: 'DeepL-Auth-Key ' + key, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: PROVIDER_TIMEOUT_MS
|
||||
});
|
||||
const translated = response && response.data && Array.isArray(response.data.translations) && response.data.translations[0] && response.data.translations[0].text;
|
||||
if (!translated) throw failure('DeepL returned no translation.', 502, 'DEEPL_EMPTY');
|
||||
return String(translated);
|
||||
}
|
||||
|
||||
async function attemptProvider(provider, message, target, env, http, format) {
|
||||
return callLibreTranslate(message, target, env, http, format);
|
||||
}
|
||||
|
||||
async function translateMessage(options) {
|
||||
const opts = options || {};
|
||||
const message = opts.message;
|
||||
|
|
@ -86,20 +68,20 @@ async function translateMessage(options) {
|
|||
provider = 'libretranslate'; // the only provider — local translation always
|
||||
|
||||
const format = opts.format === 'html' ? 'html' : 'text';
|
||||
const key = cacheKey(cache, provider, opts.userId, target, message + '\u0000' + format);
|
||||
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 attemptProvider(p, message, target, env, http, format);
|
||||
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' || err.code === 'DEEPL_EMPTY';
|
||||
error.transient = (isTransientError(err) && !err.statusCode) || err.code === 'LIBRETRANSLATE_EMPTY';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,8 +57,10 @@ test('native admin initializer preserves lazy navigation, assistant actions and
|
|||
assert.equal(budget.type, 'number', 'the conversation budget is an editable admin input');
|
||||
assert.equal(budget.readOnly, false, 'the budget is admin-editable');
|
||||
assert.equal(budget.value, '999999', 'prefilled with the saved override');
|
||||
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/);
|
||||
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /override it/);
|
||||
const budgetMeta = document.getElementById('assistant-conversation-budget-meta').textContent;
|
||||
assert.match(budgetMeta, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/);
|
||||
assert.match(budgetMeta, /Clear this field/, 'the admin is told how to get back to the environment value');
|
||||
assert.match(budgetMeta, /240,000/, 'and what that environment value currently is');
|
||||
const initialConfigLoads = calls.filter(c => c.url === '/api/admin/config').length;
|
||||
document.querySelector('[data-tab="home"]').click(); await tick();
|
||||
document.querySelector('[data-tab="admin"]').click(); await tick();
|
||||
|
|
@ -97,7 +99,10 @@ test('real extracted initializer never invents a cap when metadata is missing, i
|
|||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.equal(document.getElementById('assistant-conversation-budget').value, '', 'failed load leaves the budget input empty');
|
||||
assert.equal(document.getElementById('assistant-conversation-budget-meta').textContent, 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.');
|
||||
// The failure notice must land on the <p>; writing it to the <input>
|
||||
// rendered nothing, so admins saw a stale message on a broken load.
|
||||
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /Conversation budget unavailable/);
|
||||
assert.doesNotMatch(document.getElementById('assistant-conversation-budget-meta').textContent, /Leave empty/, 'no misleading guidance when the load failed');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -111,6 +116,12 @@ test('real extracted initializer displays the server default only when returned
|
|||
initClinicalAssistantAdmin(value => value);
|
||||
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
|
||||
await tick();
|
||||
assert.equal(document.getElementById('assistant-conversation-budget').value, '120000', 'editable budget prefilled from the server metadata');
|
||||
assert.equal(document.getElementById('assistant-conversation-budget').readOnly, false);
|
||||
// No saved override: the field stays EMPTY so Save cannot silently promote the
|
||||
// environment value into a stored override. The number is shown as a placeholder.
|
||||
const noOverride = document.getElementById('assistant-conversation-budget');
|
||||
assert.equal(noOverride.value, '', 'no override means an empty field, not a prefilled one');
|
||||
assert.equal(noOverride.placeholder, '120000', 'the effective limit is shown as a placeholder');
|
||||
assert.equal(noOverride.readOnly, false);
|
||||
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /built-in default/,
|
||||
"source 'default' must not be reported as coming from the environment variable");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ function server(options = {}) {
|
|||
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
|
||||
'../middleware/auth': { authMiddleware() {} },
|
||||
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
|
||||
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
|
||||
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
||||
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue() { return { success: true, jobId: 'synthetic', status: 'pending' }; } }) },
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function server(options = {}) {
|
|||
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
|
||||
'../middleware/auth': { authMiddleware() {} },
|
||||
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
|
||||
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
|
||||
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
||||
'../utils/generatedImages': require('../src/utils/generatedImages'),
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ test('translate route is owner-bound, validated and cached; admin default provid
|
|||
const mocks = {
|
||||
express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translatedText: 'Traducción sintética.' } }; } }, crypto: require('node:crypto'),
|
||||
'../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) },
|
||||
'../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
|
||||
'../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
|
||||
'../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') },
|
||||
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
||||
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return {}; }, async enqueue() { return {}; } }) },
|
||||
|
|
@ -133,6 +133,9 @@ function client(t, options = {}) {
|
|||
if (url === '/api/clinical-assistant/translate') {
|
||||
const body = JSON.parse(options.body);
|
||||
if (options.failEs || body.target === 'bad') return new Response(JSON.stringify({ error: 'Unsupported translation language.' }), { status: 400 });
|
||||
// Stands in for a translator that returns the same structure it was given.
|
||||
if (options.stripCitations) return new Response(JSON.stringify({ success: true, translated: String(body.message).replace(/\[\d+\]/g, ''), provider: body.provider }));
|
||||
if (options.echo) return new Response(JSON.stringify({ success: true, translated: body.message, provider: body.provider }));
|
||||
return new Response(JSON.stringify({ success: true, translated: 'Traducción de "' + body.message + '"', provider: body.provider }));
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true, chats: [] }));
|
||||
|
|
@ -145,7 +148,8 @@ function client(t, options = {}) {
|
|||
saveAssistantChat: async () => ({ success: true }),
|
||||
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
|
||||
translateAssistantMessage: (message, target, provider, format) => apiFetch('/api/clinical-assistant/translate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider, format: format || 'text' }), failEs: options.failEs
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider, format: format || 'text' }),
|
||||
failEs: options.failEs, stripCitations: options.stripCitations, echo: options.echo
|
||||
}).then(function(r) { return r.json(); }),
|
||||
fetch: apiFetch };
|
||||
vm.createContext(context);
|
||||
|
|
@ -203,3 +207,72 @@ test('translation failures toast once and keep the original rendering', async t
|
|||
assert.match(row.querySelector('.assistant-bubble').innerHTML, /Original content\./);
|
||||
assert.equal(c.messages[0].content, 'Original content.');
|
||||
});
|
||||
|
||||
test('a translated answer keeps its citation chips, headings, lists and tables', async t => {
|
||||
// echo: a translator that returns the same structure it was handed, which is
|
||||
// what LibreTranslate does in text mode for markdown it does not understand.
|
||||
const app = client(t, { echo: true });
|
||||
const c = app.context;
|
||||
const raw = [
|
||||
'## Management [1]',
|
||||
'',
|
||||
'| Drug | Dose |',
|
||||
'| --- | --- |',
|
||||
'| Amoxicillin | 90 mg/kg/day |',
|
||||
'',
|
||||
'1. Give amoxicillin **90 mg/kg/day** [1]',
|
||||
'2. Reassess in 48 h [2]'
|
||||
].join('\n');
|
||||
const sources = [{ title: 'AAP otitis media', page: 4 }, { title: 'Local protocol' }];
|
||||
c.appendMessage('assistant', raw, sources);
|
||||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||||
row.querySelector('[data-assistant-msg-translate]').click();
|
||||
app.document.querySelector('[data-assistant-translate-lang="es"]').click();
|
||||
await new Promise(r => setImmediate(r));
|
||||
|
||||
const sent = JSON.parse(app.calls.filter(x => x.url === '/api/clinical-assistant/translate')[0].options.body);
|
||||
assert.equal(sent.message, raw, 'raw markdown goes to the translator — no lossy pre-scrub');
|
||||
assert.match(sent.message, /^1\. Give amoxicillin/m, 'ordered-list numbering survives to the wire');
|
||||
assert.match(sent.message, /\| Amoxicillin \| 90 mg\/kg\/day \|/, 'table pipes survive to the wire');
|
||||
|
||||
const bubble = row.querySelector('.assistant-bubble');
|
||||
const chips = bubble.querySelectorAll('.assistant-cite');
|
||||
assert.equal(chips.length, 3, 'every [n] came back as a clickable source chip');
|
||||
assert.equal(chips[0].getAttribute('data-source-number'), '1');
|
||||
assert.match(chips[0].getAttribute('title'), /AAP otitis media/, 'chips stay bound to this message’s sources');
|
||||
assert.ok(bubble.querySelector('table'), 'the table is still a table');
|
||||
assert.ok(bubble.querySelector('ol'), 'the numbered steps are still numbered');
|
||||
assert.ok(bubble.querySelector('h2'), 'headings survive');
|
||||
assert.equal(bubble.querySelector('.assistant-translated-sources'), null, 'nothing was lost, so no recovery block');
|
||||
});
|
||||
|
||||
test('citations the translator drops are still reachable instead of vanishing', async t => {
|
||||
const app = client(t, { stripCitations: true });
|
||||
const c = app.context;
|
||||
c.appendMessage('assistant', 'Amoxicillin first line [1][2].', [{ title: 'AAP otitis media' }, { title: 'Local protocol' }]);
|
||||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||||
row.querySelector('[data-assistant-msg-translate]').click();
|
||||
app.document.querySelector('[data-assistant-translate-lang="es"]').click();
|
||||
await new Promise(r => setImmediate(r));
|
||||
const recovery = row.querySelector('.assistant-bubble .assistant-translated-sources');
|
||||
assert.ok(recovery, 'dropped markers surface a recovery block rather than disappearing');
|
||||
assert.equal(recovery.querySelectorAll('.assistant-cite').length, 2, 'both lost sources stay clickable');
|
||||
});
|
||||
|
||||
test('a failed translation puts the live image card back', async t => {
|
||||
const app = client(t, { failEs: true });
|
||||
const c = app.context;
|
||||
c.appendMessage('assistant', 'Here is the diagram.', []);
|
||||
const bubble = app.document.querySelector('.assistant-msg.assistant .assistant-bubble');
|
||||
const card = app.document.createElement('div');
|
||||
card.className = 'assistant-image-card';
|
||||
card.setAttribute('data-job-id', 'job-1');
|
||||
bubble.appendChild(card);
|
||||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||||
row.querySelector('[data-assistant-msg-translate]').click();
|
||||
app.document.querySelector('[data-assistant-translate-lang="es"]').click();
|
||||
await new Promise(r => setImmediate(r));
|
||||
const stillThere = bubble.querySelector('.assistant-image-card');
|
||||
assert.ok(stillThere, 'the image card is not lost when translation fails');
|
||||
assert.equal(stillThere, card, 'it is the same live node, so its status polling continues');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function server(options = {}) {
|
|||
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
|
||||
'../middleware/auth': { authMiddleware() {} },
|
||||
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
|
||||
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
|
||||
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
|
||||
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
||||
'../utils/generatedImages': options.images || { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue(owner, workflow, input) {
|
||||
|
|
|
|||
|
|
@ -310,7 +310,10 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
|
|||
assert.equal(setting(ui, 'chat-model').value, 'saved-chat');
|
||||
assert.equal(setting(ui, 'search-limit').value, '17');
|
||||
assert.equal(setting(ui, 'context-chars').value, '2300');
|
||||
assert.equal(setting(ui, 'conversation-budget').value, '240000', 'editable budget prefilled from environment metadata');
|
||||
// Empty means "no saved override" — see admin-clinical-assistant-wiring.
|
||||
// The environment value is the placeholder so Save cannot promote it silently.
|
||||
assert.equal(setting(ui, 'conversation-budget').value, '', 'no saved override, so the field stays empty');
|
||||
assert.equal(setting(ui, 'conversation-budget').placeholder, '240000', 'environment metadata shows as the placeholder');
|
||||
assert.equal(setting(ui, 'conversation-budget').readOnly, false);
|
||||
assert.match(setting(ui, 'admin-status').textContent, /ready/i);
|
||||
assert.equal(retry.hidden, true);
|
||||
|
|
@ -320,7 +323,9 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
|
|||
ui.document.getElementById('btn-save-assistant-config').click(); await tick();
|
||||
assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [
|
||||
['clinical_assistant.chat_model', 'saved-chat'],
|
||||
['clinical_assistant.conversation_chars', '240000'],
|
||||
// Saving an untouched form must NOT turn the environment value into a
|
||||
// stored override — empty is the "use the environment" signal.
|
||||
['clinical_assistant.conversation_chars', ''],
|
||||
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
|
||||
['clinical_assistant.translate_provider', 'libretranslate'],
|
||||
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', '']
|
||||
|
|
|
|||
|
|
@ -190,7 +190,12 @@ function client(t, options = {}) {
|
|||
return new Response(JSON.stringify({ success: true }));
|
||||
}
|
||||
if (url === '/api/clinical-assistant/translate/languages') {
|
||||
return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en', 'es', 'de'], deepl: ['en', 'es', 'fr'] } }));
|
||||
return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en', 'es', 'de'] } }));
|
||||
}
|
||||
if (url === '/api/clinical-assistant/translate') {
|
||||
const body = JSON.parse(options.body);
|
||||
if (options.failTranslate) return new Response(JSON.stringify({ error: 'Local translation service is unreachable.' }), { status: 502 });
|
||||
return new Response(JSON.stringify({ success: true, translated: '[' + body.target + '] ' + body.message, provider: body.provider }));
|
||||
}
|
||||
return new Response(JSON.stringify({ success: true, chats: [] }));
|
||||
};
|
||||
|
|
@ -200,7 +205,7 @@ function client(t, options = {}) {
|
|||
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
||||
saveAssistantChat: async () => ({ success: true }),
|
||||
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
|
||||
translateAssistantMessage: (message, target, provider) => apiFetch('/api/clinical-assistant/translate', { method: 'POST', headers: {}, body: JSON.stringify({ message, target, provider }) }).then(r => r.json()),
|
||||
translateAssistantMessage: (message, target, provider) => apiFetch('/api/clinical-assistant/translate', { method: 'POST', headers: {}, body: JSON.stringify({ message, target, provider }), failTranslate: options.failTranslate }).then(r => r.json()),
|
||||
getAuthHeaders: () => ({ 'X-Synthetic': '1' }),
|
||||
fetchSavedAssistantChat: async id => ({ success: true, chat: { id, payload: { version: 2, messages: [{ role: 'user', content: 'Old question' }] } } }),
|
||||
openAssistantStream: async (payload) => {
|
||||
|
|
@ -321,3 +326,66 @@ test('translate picker filters languages by the local provider availability', as
|
|||
const langs = [...pop.querySelectorAll('[data-assistant-translate-lang]')].map(b => b.getAttribute('data-assistant-translate-lang'));
|
||||
assert.deepEqual(langs, ['en', 'es', 'de'], 'only languages the local LibreTranslate supports are offered');
|
||||
});
|
||||
|
||||
// ── Take home translation: the artifact the caregiver actually leaves with ──
|
||||
async function openTakehome(app) {
|
||||
const c = app.context;
|
||||
c.appendMessage('assistant', 'Answer.', []);
|
||||
c.lastAnswer = 'Answer.';
|
||||
app.document.getElementById('btn-assistant-takehome').click();
|
||||
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
||||
return app.document.getElementById('assistant-takehome-modal');
|
||||
}
|
||||
|
||||
test('take home offers only the languages the local translator actually has', async t => {
|
||||
const app = client(t);
|
||||
const modal = await openTakehome(app);
|
||||
await new Promise(r => setImmediate(r));
|
||||
const select = modal.querySelector('[data-assistant-takehome-lang]');
|
||||
assert.ok(select, 'the caregiver-facing artifact can be translated');
|
||||
const values = Array.from(select.options).map(o => o.value);
|
||||
assert.deepEqual(values, ['', 'en', 'es', 'de'], 'original plus exactly what LibreTranslate reports');
|
||||
assert.equal(select.value, '', 'starts on the original');
|
||||
});
|
||||
|
||||
test('translating the take home rerenders it and carries into Copy, Export and Email', async t => {
|
||||
const app = client(t);
|
||||
const modal = await openTakehome(app);
|
||||
await new Promise(r => setImmediate(r));
|
||||
const select = modal.querySelector('[data-assistant-takehome-lang]');
|
||||
select.value = 'es';
|
||||
select.dispatchEvent(new app.window.Event('change', { bubbles: true }));
|
||||
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
||||
|
||||
const sent = JSON.parse(app.calls.find(c => c.url === '/api/clinical-assistant/translate').options.body);
|
||||
assert.equal(sent.target, 'es');
|
||||
assert.match(sent.message, /Rest\*\* and drink fluids/, 'the generated take-home text is what gets translated');
|
||||
const result = modal.querySelector('.assistant-takehome-result');
|
||||
assert.match(result.textContent, /\[es\]/, 'the modal shows the translation');
|
||||
assert.ok(result.querySelector('strong'), 'still rendered as markdown, not escaped text');
|
||||
|
||||
modal.querySelector('[data-assistant-takehome-send]').closest('.assistant-takehome-email')
|
||||
.querySelector('input').value = 'parent@example.com';
|
||||
modal.querySelector('[data-assistant-takehome-send]').click();
|
||||
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
||||
const emailed = JSON.parse(app.calls.find(c => c.url === '/api/clinical-assistant/patient-takehome/email').options.body);
|
||||
assert.match(emailed.text, /^\[es\] /, 'the caregiver is emailed the language they were shown');
|
||||
|
||||
select.value = '';
|
||||
select.dispatchEvent(new app.window.Event('change', { bubbles: true }));
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.doesNotMatch(modal.querySelector('.assistant-takehome-result').textContent, /\[es\]/, 'Original restores the source text');
|
||||
});
|
||||
|
||||
test('a failed take-home translation keeps the original on screen and says so', async t => {
|
||||
const app = client(t, { failTranslate: true });
|
||||
const modal = await openTakehome(app);
|
||||
await new Promise(r => setImmediate(r));
|
||||
const select = modal.querySelector('[data-assistant-takehome-lang]');
|
||||
select.value = 'es';
|
||||
select.dispatchEvent(new app.window.Event('change', { bubbles: true }));
|
||||
await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));
|
||||
assert.match(modal.querySelector('.assistant-takehome-result').textContent, /Rest and drink fluids/);
|
||||
assert.equal(select.value, '', 'the select does not claim a translation that never arrived');
|
||||
assert.match(app.toasts[app.toasts.length - 1][0], /unreachable/i);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue