From 4d92488f0c572176c59f6aa2c2a6e41160af5b49 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 18:40:23 +0200 Subject: [PATCH] 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

instead of an '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 Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq --- public/components/admin.html | 3 +- public/css/assistant.css | 6 +- public/js/admin/clinicalAssistant.js | 34 +++- public/js/app.js | 28 ++- public/js/auth.js | 3 + public/js/clinicalAssistant.js | 170 +++++++++++++++---- src/routes/adminConfig.js | 15 +- src/routes/clinicalAssistant.js | 14 +- src/utils/clinicalTranslation.js | 26 +-- test/admin-clinical-assistant-wiring.test.js | 21 ++- test/assistant-attachment-roundtrip.test.js | 2 +- test/assistant-image-attachments.test.js | 2 +- test/assistant-translate.test.js | 77 ++++++++- test/clinical-conversation.test.js | 2 +- test/frontend-prompt-env.test.js | 9 +- test/patient-takehome.test.js | 72 +++++++- 16 files changed, 386 insertions(+), 98 deletions(-) diff --git a/public/components/admin.html b/public/components/admin.html index 99ef15d4..8a24174f 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -330,8 +330,7 @@

diff --git a/public/css/assistant.css b/public/css/assistant.css index 2d8b3bfc..7f630b8a 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -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); } diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index ff8280be..f04c84e4 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -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.'; }); } diff --git a/public/js/app.js b/public/js/app.js index d6859a25..d9e80f77 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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; diff --git a/public/js/auth.js b/public/js/auth.js index 3f08091a..fcc0516d 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -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)) { diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index d1a7f968..846c131d 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -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 ''; }).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 { '
'; @@ -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 = ' ' + escapeHtml(err.message); } }).finally(function() { takehomeBusy = false; }); } + function renderTakehomeLanguageOptions() { + var select = document.getElementById('assistant-takehome-lang'); + if (!select) return; + select.innerHTML = '' + + availableTranslateLanguages().map(function(pair) { + return ''; + }).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('Patient Take Home' + '