diff --git a/public/components/assistant.html b/public/components/assistant.html index 7a2742f..e6b4f59 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -45,6 +45,8 @@
+ +
@@ -71,6 +73,7 @@ + diff --git a/public/css/assistant.css b/public/css/assistant.css index 8fcd03e..704019d 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -182,3 +182,23 @@ .assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; } .assistant-side { gap:10px; } } + +/* Patient take home modal + actions */ +.assistant-takehome-modal { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); display: flex; align-items: center; justify-content: center; z-index: 120; } +.assistant-takehome-modal .modal-content { width: min(640px, 92vw); max-height: 82vh; display: flex; flex-direction: column; } +.assistant-takehome-result { white-space: pre-wrap; overflow-y: auto; font-size: .95rem; line-height: 1.55; } +.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-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); } +.assistant-translate-langs { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .4rem; } +.assistant-translate-none { color: var(--muted, #6b7280); font-size: .85rem; } + +/* Voice: composer mic + conversation overlay */ +.assistant-mic.recording { background: var(--danger, #dc2626); color: #fff; } +.assistant-voice-overlay { position: fixed; inset: 0; background: rgba(15,23,42,.55); display: flex; align-items: center; justify-content: center; z-index: 115; } +.assistant-voice-card { background: var(--surface, #fff); border-radius: 14px; padding: 1.75rem 2rem; text-align: center; box-shadow: 0 18px 50px rgba(0,0,0,.28); display: flex; flex-direction: column; gap: 1rem; align-items: center; min-width: 280px; } +.assistant-voice-status { font-size: 1rem; color: var(--muted, #6b7280); min-height: 1.4rem; } +.assistant-voice-mic { width: 72px; height: 72px; border-radius: 50%; border: none; background: var(--accent, #0f766e); color: #fff; font-size: 1.6rem; cursor: pointer; } +.assistant-voice-mic.recording { background: var(--danger, #dc2626); animation: assistant-pulse 1.2s infinite; } +@keyframes assistant-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.45); } 50% { box-shadow: 0 0 0 14px rgba(220,38,38,0); } } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index d3a24c3..24e762e 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -50,6 +50,39 @@ import { var imageStore = createAssistantImageStore(); 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 translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] } + var translateLanguagesLoading = false; + + function refreshTranslateLanguages(pop) { + if (translateLanguagesLoading) return; + translateLanguagesLoading = true; + fetch('/clinical-assistant/translate/languages', { headers: getAuthHeaders(), credentials: 'same-origin' }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data && data.success && data.languages) translateLanguagesAvailable = data.languages; + }) + .catch(function() { /* keep the full static list on failure */ }) + .finally(function() { + translateLanguagesLoading = false; + var open = document.querySelector('[data-assistant-translate-pop]'); + if (open) renderTranslateLanguages(open, open.assistantMessageRow); + }); + } + + 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; + }); + list.innerHTML = pairs.map(function(pair) { + return ''; + }).join(''); + if (!pairs.length) list.innerHTML = 'No languages available for this provider.'; + } var SOURCE_EXCERPT_PREVIEW = 10000; document.addEventListener('tabChanged', function (e) { @@ -100,6 +133,12 @@ import { if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel); if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf); + var takehomeBtn = document.getElementById('btn-assistant-takehome'); + if (takehomeBtn) takehomeBtn.addEventListener('click', openPatientTakehome); + var micBtn = document.getElementById('btn-assistant-mic'); + if (micBtn) micBtn.addEventListener('click', toggleAssistantMic); + var voiceBtn = document.getElementById('btn-assistant-voice'); + if (voiceBtn) voiceBtn.addEventListener('click', openConversationMode); if (imageBtn) imageBtn.addEventListener('click', generateImage); if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage); if (attachInput) attachInput.addEventListener('change', onAttachFiles); @@ -890,6 +929,280 @@ import { } // ── Per-message translate with provider choice (local-first) ─────── + + // ── Voice: mic dictation in the composer + conversation (call) mode ── + var assistantMic = { recording: false, recorder: null, recognition: null, finalText: '', sessionFinals: '' }; + + function toggleAssistantMic() { + if (assistantMic.recording) stopAssistantMic(); + else startAssistantMic(); + } + + function startAssistantMic() { + var input = document.getElementById('assistant-input'); + assistantMic.finalText = String(input && input.value || ''); + assistantMic.sessionFinals = ''; + assistantMic.recorder = new AudioRecorder(); + assistantMic.recording = true; + assistantMic.recorder.start().then(function() { + var btn = document.getElementById('btn-assistant-mic'); + if (btn) { btn.classList.add('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-stop'; } + assistantMic.recognition = createSpeechRecognition(); + if (assistantMic.recognition) { + assistantMic.recognition.onresult = function(e) { + var interim = ''; + for (var i = e.resultIndex; i < e.results.length; i++) { + if (e.results[i].isFinal) assistantMic.sessionFinals += e.results[i][0].transcript + ' '; + else interim = e.results[i][0].transcript; + } + var input2 = document.getElementById('assistant-input'); + if (input2) { input2.value = (assistantMic.finalText + assistantMic.sessionFinals).trim() + (interim ? ' ' + interim : ''); updateConversationBudget(); } + }; + assistantMic.recognition.onend = function() { if (assistantMic.recording) { try { assistantMic.recognition.start(); } catch (e) {} } }; + try { assistantMic.recognition.start(); } catch (e) {} + } + }).catch(function() { + assistantMic.recording = false; + if (typeof showToast === 'function') showToast('Microphone denied', 'error'); + }); + } + + function stopAssistantMic() { + assistantMic.recording = false; + var btn = document.getElementById('btn-assistant-mic'); + if (btn) { btn.classList.remove('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-microphone'; } + if (assistantMic.recognition) { try { assistantMic.recognition.stop(); } catch (e) {} assistantMic.recognition = null; } + var recorder = assistantMic.recorder; + assistantMic.recorder = null; + if (!recorder) return; + recorder.stop().then(function(blob) { + if (typeof transcribeAudio === 'function' && blob && blob.size > 100) { + transcribeAudio(blob).then(function(data) { + if (data && data.success && data.text) { + var input = document.getElementById('assistant-input'); + if (input) { input.value = data.text; updateConversationBudget(); } + } + }).catch(function() {}); + } + }).catch(function() {}); + } + + var conversationMode = { active: false, listening: false, recorder: null, recognition: null, sessionFinals: '' }; + + function openConversationMode() { + if (conversationMode.active) return; + var view = document.getElementById('assistant-chat-view'); + if (!view) return; + conversationMode.active = true; + var overlay = document.createElement('div'); + overlay.className = 'assistant-voice-overlay'; + overlay.id = 'assistant-voice-overlay'; + overlay.innerHTML = '
' + + '
Tap to speak
' + + '' + + '
'; + view.appendChild(overlay); + overlay.querySelector('#assistant-voice-mic').addEventListener('click', conversationMicToggle); + overlay.querySelector('#assistant-voice-end').addEventListener('click', endConversationMode); + document.addEventListener('assistant-answer-done', conversationAnswerDone); + } + + function conversationMicToggle() { + if (!conversationMode.active) return; + if (conversationMode.listening) conversationStopListening(); + else conversationStartListening(); + } + + function conversationStartListening() { + var status = document.getElementById('assistant-voice-status'); + if (status) status.textContent = 'Listening…'; + conversationMode.listening = true; + conversationMode.sessionFinals = ''; + conversationMode.recorder = new AudioRecorder(); + conversationMode.recorder.start().then(function() { + var mic = document.getElementById('assistant-voice-mic'); + if (mic) mic.classList.add('recording'); + conversationMode.recognition = createSpeechRecognition(); + if (conversationMode.recognition) { + conversationMode.recognition.onresult = function(e) { + for (var i = e.resultIndex; i < e.results.length; i++) if (e.results[i].isFinal) conversationMode.sessionFinals += e.results[i][0].transcript + ' '; + }; + conversationMode.recognition.onend = function() { if (conversationMode.listening) { try { conversationMode.recognition.start(); } catch (e) {} } }; + try { conversationMode.recognition.start(); } catch (e) {} + } + }).catch(function() { + conversationMode.listening = false; + if (status) status.textContent = 'Microphone unavailable'; + }); + } + + function conversationStopListening() { + conversationMode.listening = false; + var mic = document.getElementById('assistant-voice-mic'); + if (mic) mic.classList.remove('recording'); + if (conversationMode.recognition) { try { conversationMode.recognition.stop(); } catch (e) {} conversationMode.recognition = null; } + var recorder = conversationMode.recorder; + conversationMode.recorder = null; + if (!recorder) return; + recorder.stop().then(function(blob) { + var status = document.getElementById('assistant-voice-status'); + var live = conversationMode.sessionFinals.trim(); + var finish = function(text) { + if (text) { conversationSend(text); } + else if (status) status.textContent = 'Tap to speak'; + }; + if (typeof transcribeAudio === 'function' && blob && blob.size > 100) { + if (status) status.textContent = 'Transcribing…'; + transcribeAudio(blob).then(function(data) { finish(data && data.success && data.text ? data.text : live); }) + .catch(function() { finish(live); }); + } else finish(live); + }).catch(function() {}); + } + + function conversationSend(text) { + if (!conversationMode.active) return; + var status = document.getElementById('assistant-voice-status'); + var input = document.getElementById('assistant-input'); + if (!input) return; + input.value = text; + updateConversationBudget(); + if (status) status.textContent = 'Asking…'; + var send = document.getElementById('btn-assistant-send'); + if (send) send.click(); + } + + function conversationAnswerDone(e) { + if (!conversationMode.active) return; + var status = document.getElementById('assistant-voice-status'); + var detail = (e && e.detail) || {}; + if (detail.isError) { if (status) status.textContent = 'Answer failed — tap to speak'; return; } + if (status) status.textContent = 'Speaking…'; + speakAnswerVoice(String(detail.answer || '')); + } + + function speakAnswerVoice(text) { + var done = function(msg) { + var st = document.getElementById('assistant-voice-status'); + if (st) st.textContent = msg || 'Tap to speak'; + }; + if (!text) { done(); return; } + fetch('/text-to-speech', { + method: 'POST', + headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), + body: JSON.stringify({ text: text }) + }).then(function(r) { if (!r.ok) throw new Error('Voice reply unavailable'); return r.blob(); }) + .then(function(blob) { + var url = URL.createObjectURL(blob); + var audio = new Audio(url); + audio.onended = function() { URL.revokeObjectURL(url); done(); }; + audio.onerror = function() { URL.revokeObjectURL(url); done('Voice reply unavailable — tap to speak'); }; + audio.play().catch(function() { URL.revokeObjectURL(url); done('Voice reply unavailable — tap to speak'); }); + }).catch(function() { done('Voice reply unavailable — tap to speak'); }); + } + + function endConversationMode() { + conversationMode.active = false; + conversationStopListening(); + if (activeAssistantRequest) activeAssistantRequest.abort(); + document.removeEventListener('assistant-answer-done', conversationAnswerDone); + var overlay = document.getElementById('assistant-voice-overlay'); + if (overlay) overlay.remove(); + } + + // ── Patient take home: plain-language summary + copy/export/email ──── + var takehomeBusy = false; + var takehomeText = ''; + + function openPatientTakehome() { + if (takehomeBusy) return; + if (!lastAnswer || !String(lastAnswer).trim()) { + if (typeof showToast === 'function') showToast('Ask a question first, then create a patient take home', 'error'); + return; + } + closePatientTakehomeModal(); + takehomeBusy = true; + takehomeText = ''; + var modal = document.createElement('div'); + modal.className = 'assistant-takehome-modal'; + modal.id = 'assistant-takehome-modal'; + modal.innerHTML = ''; + document.body.appendChild(modal); + modal.addEventListener('click', function(event) { + if (event.target === modal || event.target.closest('[data-assistant-takehome-close]')) closePatientTakehomeModal(); + }); + fetch('/clinical-assistant/patient-takehome', { + method: 'POST', + headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), + body: JSON.stringify({ answer: lastAnswer }) + }).then(function(r) { return r.json(); }).then(function(data) { + if (!data.success) throw new Error(data.error || 'Take-home generation failed'); + takehomeText = String(data.text || '').trim(); + var loading = modal.querySelector('.assistant-takehome-loading'); + var result = modal.querySelector('.assistant-takehome-result'); + var actions = modal.querySelector('.assistant-takehome-actions'); + if (loading) loading.remove(); + if (result) { result.textContent = takehomeText; result.classList.remove('hidden'); } + if (actions) actions.classList.remove('hidden'); + }).catch(function(err) { + var loading = modal.querySelector('.assistant-takehome-loading'); + if (loading) { loading.innerHTML = ' ' + escapeHtml(err.message); } + }).finally(function() { takehomeBusy = false; }); + } + + function closePatientTakehomeModal() { + var modal = document.getElementById('assistant-takehome-modal'); + if (modal) modal.remove(); + } + + document.addEventListener('click', function(event) { + if (event.target.closest('[data-assistant-takehome-copy]')) { + copyText(takehomeText, 'Take home copied', 'Copy failed'); + return; + } + if (event.target.closest('[data-assistant-takehome-export]')) { + if (!takehomeText) return; + var blob = new Blob([takehomeText], { type: 'text/plain;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'patient-take-home.txt'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function() { URL.revokeObjectURL(url); }, 15000); + return; + } + if (event.target.closest('[data-assistant-takehome-send]')) { + var input = document.getElementById('assistant-takehome-email'); + var to = input ? input.value.trim() : ''; + if (!to) { if (typeof showToast === 'function') showToast('Enter an email address', 'error'); return; } + var btn = event.target.closest('[data-assistant-takehome-send]'); + btn.disabled = true; + fetch('/clinical-assistant/patient-takehome/email', { + method: 'POST', + headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), + body: JSON.stringify({ to: to, text: takehomeText }) + }).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'); + if (input) input.value = ''; + }).catch(function(err) { + if (typeof showToast === 'function') showToast(err.message, 'error'); + }).finally(function() { btn.disabled = false; }); + return; + } + }); + function openTranslatePicker(row) { if (!row) return; closeTranslatePicker(); @@ -900,11 +1213,14 @@ import { '' + '' + '' + + '
' + TRANSLATE_LANGUAGES.map(function(pair) { return ''; - }).join(''); + }).join('') + + '
'; pop.assistantMessageRow = row; row.appendChild(pop); + if (!translateLanguagesAvailable) refreshTranslateLanguages(pop); } function closeTranslatePicker() { @@ -1407,6 +1723,13 @@ import { function setBusy(isBusy, text, isError) { assistantBusy = !!isBusy; + if (!isBusy) { + // createEvent keeps this working in every environment (browser and test harnesses). + var doneEvent = document.createEvent('Event'); + doneEvent.initEvent('assistant-answer-done', false, false); + doneEvent.detail = { isError: !!isError, answer: lastAnswer || '' }; + document.dispatchEvent(doneEvent); + } var status = document.getElementById('assistant-status'); var label = document.getElementById('assistant-status-text'); var send = document.getElementById('btn-assistant-send'); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index c4e450d..f10aa60 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -43,7 +43,9 @@ var { var { conversationBudget, checkConversation, validateAttachments, savedChatPayload } = require('../utils/clinicalConversation'); var clinicalTranslation = require('../utils/clinicalTranslation'); +var patientTakehome = require('../utils/patientTakehome'); var translateCache = clinicalTranslation.createTranslationCache(); +var translateLanguageCache = clinicalTranslation.createLanguageCache(); var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts'); @@ -208,6 +210,22 @@ router.delete('/clinical-assistant/chats/:id', async function(req, res) { } }); + +router.get('/clinical-assistant/translate/languages', async function(req, res) { + try { + var result = await clinicalTranslation.listAvailableLanguages({ + provider: 'libretranslate', + env: process.env, + http: axios, + languageCache: translateLanguageCache + }); + res.json({ success: true, languages: result }); + } catch (e) { + if (!e.statusCode || e.statusCode >= 500) logger.error('GET /clinical-assistant/translate/languages', e.message); + res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Translation service unavailable', code: e.code }); + } +}); + router.post('/clinical-assistant/translate', async function(req, res) { try { var result = await clinicalTranslation.translateMessage({ @@ -227,6 +245,41 @@ router.post('/clinical-assistant/translate', async function(req, res) { } }); + +router.post('/clinical-assistant/patient-takehome', async function(req, res) { + try { + var answer = String(req.body.answer || '').trim(); + if (!answer) return res.status(400).json({ error: 'No answer to summarize', code: 'NO_ANSWER' }); + var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', ''); + var behavior = process.env.PATIENT_TAKEHOME_BEHAVIOR || await getSetting('clinical_assistant.patient_takehome_behavior', '') || undefined; + var result = await patientTakehome.generatePatientTakehome({ answer: answer, model: chatModel || undefined, behavior: behavior, callAI: callAI }); + logger.audit(req.user.id, 'patient_takehome', 'take-home generated', req, { category: 'clinical', chars: result.text.length }); + res.json({ success: true, text: result.text }); + } catch (e) { + if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/patient-takehome', e.message); + res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Take-home generation failed', code: e.code }); + } +}); + +router.post('/clinical-assistant/patient-takehome/email', async function(req, res) { + try { + var to = String(req.body.to || '').trim(); + var text = String(req.body.text || '').trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(to)) return res.status(400).json({ error: 'Enter a valid email address', code: 'INVALID_EMAIL' }); + if (!text || text.length > 12000) return res.status(400).json({ error: 'Nothing to send', code: 'EMPTY_EMAIL_BODY' }); + var sent = await require('./auth').__sendEmail(to, 'Patient Take Home — Pediatric AI Scribe', + '
' + + '

Patient Take Home

' + + '

' + String(text).replace(/&/g,'&').replace(//g,'>') + '

' + + '

This summary was created for a caregiver. Keep following your care team’s instructions.

'); + if (!sent) return res.status(503).json({ error: 'Email is not configured on this server yet', code: 'SMTP_NOT_CONFIGURED' }); + logger.audit(req.user.id, 'patient_takehome_email', 'take-home emailed', req, { category: 'clinical', to: to }); + res.json({ success: true }); + } catch (e) { + if (!e.statusCode || e.statusCode >= 500) logger.error('POST /clinical-assistant/patient-takehome/email', e.message); + res.status(e.statusCode || 502).json({ error: e.statusCode ? e.message : 'Could not send the email', code: e.code }); + } +}); router.post('/clinical-assistant/chat', async function(req, res) { var started = Date.now(); try { diff --git a/src/utils/clinicalPrompts.js b/src/utils/clinicalPrompts.js index 9acfe68..aae379c 100644 --- a/src/utils/clinicalPrompts.js +++ b/src/utils/clinicalPrompts.js @@ -14,4 +14,5 @@ function imagePromptForCanvas(prompt, behavior = DEFAULT_IMAGE_BEHAVIOR) { return text + guidance; } -module.exports = { DEFAULT_BEHAVIOR, DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas }; +const PATIENT_TAKEHOME_BEHAVIOR = 'Rewrite the clinical answer below as a short patient take-home sheet in simple, plain language a parent or caregiver can understand. Keep every medical fact, number, unit, dose and timeframe exactly as in the answer; explain medical terms in everyday words. Use short sentences and a simple bullet list with a clear "What to do" part. Never include citations, reference numbers, footnote markers, source lists, or organization logos. Never add new medical advice, never invent facts, and never guess what the clinician would say.'; +module.exports = { DEFAULT_BEHAVIOR, DEFAULT_IMAGE_BEHAVIOR, PATIENT_TAKEHOME_BEHAVIOR, imagePromptForCanvas }; diff --git a/src/utils/clinicalTranslation.js b/src/utils/clinicalTranslation.js index 044469b..472dbb9 100644 --- a/src/utils/clinicalTranslation.js +++ b/src/utils/clinicalTranslation.js @@ -16,11 +16,16 @@ const DEEPL_BASES = ['https://api.deepl.com/v2', 'https://api-free.deepl.com/v2' 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 }; } @@ -123,6 +128,51 @@ async function translateMessage(options) { } } + +// 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, deepl: DEEPL_LANGS.slice() }; + if (cache) languagesCacheSet(cache, provider, value); + return value; + } + if (provider === 'deepl') return { libretranslate: [], deepl: DEEPL_LANGS.slice() }; + throw failure('Unknown translation provider.', 400, 'INVALID_PROVIDER'); +} + module.exports = { TRANSLATE_PROVIDERS, TRANSLATE_LANGS, @@ -130,5 +180,7 @@ module.exports = { DEEPL_BASES, MAX_TRANSLATE_CHARS, createTranslationCache, + createLanguageCache, + listAvailableLanguages, translateMessage }; diff --git a/src/utils/patientTakehome.js b/src/utils/patientTakehome.js new file mode 100644 index 0000000..3351cd9 --- /dev/null +++ b/src/utils/patientTakehome.js @@ -0,0 +1,43 @@ +// Patient Take Home — a plain-language, citation-free summary of the latest +// clinical answer, generated on explicit user request only. This is a +// separate synchronous model call; it never touches conversation history, +// budgets, sources, or saved chats. +const { PATIENT_TAKEHOME_BEHAVIOR } = require('./clinicalPrompts'); +const { assistantGenerationOptions } = require('./clinicalAnswer'); + +const MAX_ANSWER_CHARS = 20000; +const MAX_RESULT_CHARS = 12000; + +function stripCitationTokens(text) { + // Defensive scrub: the prompt forbids citations, but never trust the model. + return String(text || '') + .replace(/\[(?:[0-9]+(?:\s*,\s*[0-9]+)*)\]|\[[0-9]+(?:\s*,\s*[0-9]+)*\]/g, '') + .replace(/\((?:source|ref)[^)]*\)/gi, '') + .replace(/\n\s*(#{1,3}\s*)?(sources?|references)\s*:?\s*$/im, '') + .replace(/ {2,}/g, ' ') + .trim(); +} + +async function generatePatientTakehome(opts) { + var answer = String(opts.answer || '').trim(); + if (!answer) { var e = new Error('No answer to summarize'); e.statusCode = 400; throw e; } + if (answer.length > MAX_ANSWER_CHARS) { e = new Error('Answer too long for a take-home sheet'); e.statusCode = 413; throw e; } + var behavior = String(opts.behavior || '').trim() || PATIENT_TAKEHOME_BEHAVIOR; + var messages = [ + { role: 'system', content: behavior }, + { role: 'user', content: 'Clinical answer:\n' + answer + '\n\nWrite the patient take-home sheet now.' } + ]; + var ai; + try { + ai = await opts.callAI(messages, assistantGenerationOptions({ model: opts.model || undefined, temperature: 0.15, maxTokens: 1800 })); + } catch (err) { + err = err || new Error('Take-home generation failed'); + if (!err.statusCode && !err.response) err.statusCode = 502; + throw err; + } + var text = stripCitationTokens(ai && ai.content || '').slice(0, MAX_RESULT_CHARS); + if (!text) { e = new Error('The model returned an empty take-home sheet'); e.statusCode = 502; throw e; } + return { text: text, model: ai.model || null, provider: ai.provider || null }; +} + +module.exports = { MAX_ANSWER_CHARS, MAX_RESULT_CHARS, generatePatientTakehome, stripCitationTokens }; diff --git a/test/assistant-attachment-roundtrip.test.js b/test/assistant-attachment-roundtrip.test.js index fd319c7..614c33c 100644 --- a/test/assistant-attachment-roundtrip.test.js +++ b/test/assistant-attachment-roundtrip.test.js @@ -41,7 +41,8 @@ function server(options = {}) { const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 }; const mocks = { 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() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, + '../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/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, @@ -59,7 +60,8 @@ function server(options = {}) { isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy, - '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation') + '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), + '../utils/patientTakehome': require('../src/utils/patientTakehome') }; const module = { exports: {} }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { diff --git a/test/assistant-image-attachments.test.js b/test/assistant-image-attachments.test.js index 86822f6..4f93f8e 100644 --- a/test/assistant-image-attachments.test.js +++ b/test/assistant-image-attachments.test.js @@ -39,7 +39,8 @@ function server(options = {}) { const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 }; const mocks = { 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() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, + '../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/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, @@ -57,7 +58,8 @@ function server(options = {}) { isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy, - '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation') + '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), + '../utils/patientTakehome': require('../src/utils/patientTakehome') }; const module = { exports: {} }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { diff --git a/test/assistant-translate.test.js b/test/assistant-translate.test.js index d6ceb01..70918df 100644 --- a/test/assistant-translate.test.js +++ b/test/assistant-translate.test.js @@ -149,7 +149,8 @@ test('translate route is owner-bound, validated and cached; admin default provid '../utils/clinicalMcpClient': { async semanticSearch() { return {}; }, async getMcpHealth() { return {}; } }, '../utils/clinicalRetrieval': { cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), - '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation') + '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), + '../utils/patientTakehome': require('../src/utils/patientTakehome') }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder, diff --git a/test/assistant-voice.test.js b/test/assistant-voice.test.js new file mode 100644 index 0000000..157da11 --- /dev/null +++ b/test/assistant-voice.test.js @@ -0,0 +1,138 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { marked } = require('marked'); +const { webcrypto } = require('node:crypto'); + +const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + +function ui(t, options = {}) { + const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); + const window = dom.window; + window.eval(read('public/js/accountBoundary.js')); + assert.equal(window.AccountBoundary.enter({ id: 'synthetic-voice-owner' }, true), true); + window.marked = marked; + window.DOMPurify = require('dompurify')(window); + window.matchMedia = () => ({ matches: true }); + const transcriptions = []; + const ttsCalls = []; + const played = []; + const audioBlob = new Blob([new Array(300).join('a')], { type: 'audio/mpeg' }); + function FakeRecorder() { + this.started = false; + this.stopped = false; + this.start = () => { this.started = true; return Promise.resolve(); }; + this.stop = () => { this.stopped = true; return Promise.resolve(options.blob || audioBlob); }; + } + function FakeRecognition() { + this.onresult = null; this.onend = null; this.started = 0; + this.start = () => { this.started += 1; }; + this.stop = () => { if (this.onend) this.onend(); }; + } + function FakeAudio() { + this.onended = null; this.onerror = null; + this.play = () => { played.push(1); return Promise.resolve(); }; + } + const apiFetch = async (url, options) => { + if (url === '/text-to-speech') { ttsCalls.push(JSON.parse(options.body)); return new Response(audioBlob, { status: 200 }); } + if (url === '/clinical-assistant/translate/languages') return new Response(JSON.stringify({ success: true, languages: { libretranslate: ['en'] } })); + return new Response(JSON.stringify({ success: true, chats: [] })); + }; + const context = { window, document: window.document, console, URL: window.URL, Blob, TextDecoder, AbortController, crypto: webcrypto, + setTimeout() {}, clearTimeout() {}, showToast: () => {}, EMPTY_PROMPT_SETS: [[]], + createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), + fetchSavedAssistantChats: async () => ({ success: true, chats: [] }), + saveAssistantChat: async () => ({ success: true }), + fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }), + translateAssistantMessage: () => Promise.resolve({ success: true }), + getAuthHeaders: () => ({}), + AudioRecorder: FakeRecorder, + createSpeechRecognition: () => new FakeRecognition(), + transcribeAudio: async blob => { transcriptions.push(blob); return { success: true, text: options.transcription || 'Dictated question', provider: 'synthetic' }; }, + Audio: FakeAudio, + CustomEvent: window.CustomEvent, + fetch: apiFetch }; + vm.createContext(context); + for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) { + vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); + } + context.bindEvents(); + t.after(() => window.close()); + return { context, document: window.document, window, transcriptions, ttsCalls }; +} + +test('composer mic dictation fills the input from the live transcript then the server transcription', async t => { + const app = ui(t); + app.document.getElementById('btn-assistant-mic').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + const mic = app.document.getElementById('btn-assistant-mic'); + assert.ok(mic.classList.contains('recording'), 'mic shows recording state'); + app.document.getElementById('btn-assistant-mic').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + assert.ok(!mic.classList.contains('recording')); + assert.equal(app.document.getElementById('assistant-input').value, 'Dictated question'); + assert.equal(app.transcriptions.length, 1, 'audio blob sent to the transcription endpoint'); +}); + +test('mic start failure reports the microphone denial honestly', async t => { + const app = ui(t); + const context = app.context; + const orig = context.AudioRecorder; + context.AudioRecorder = function() { this.start = () => Promise.reject(new Error('denied')); this.stop = () => Promise.resolve(); }; + app.document.getElementById('btn-assistant-mic').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + assert.ok(!app.document.getElementById('btn-assistant-mic').classList.contains('recording')); + context.AudioRecorder = orig; +}); + +test('conversation mode listens, asks the transcribed question, and speaks the answer', async t => { + const app = ui(t); + const doc = app.document; + doc.getElementById('btn-assistant-voice').click(); + await new Promise(r => setImmediate(r)); + const overlay = doc.getElementById('assistant-voice-overlay'); + assert.ok(overlay, 'conversation overlay opens'); + doc.getElementById('assistant-voice-mic').click(); + await new Promise(r => setImmediate(r)); + assert.equal(doc.getElementById('assistant-voice-status').textContent, 'Listening…'); + doc.getElementById('assistant-voice-mic').click(); // stop → transcribe → ask + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + assert.equal(doc.getElementById('assistant-input').value, 'Dictated question', 'transcribed question sent through the normal composer'); + // The send click runs the real onAsk → openAssistantStream returns a done answer → setBusy(false) → answer-done event. + const done = new app.window.CustomEvent('assistant-answer-done', { detail: { answer: 'Voice answer.' } }); + doc.dispatchEvent(done); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + assert.equal(app.ttsCalls.length, 1, 'answer spoken via TTS'); + assert.equal(app.ttsCalls[0].text, 'Voice answer.'); + doc.getElementById('assistant-voice-end').click(); + await new Promise(r => setImmediate(r)); + assert.equal(doc.getElementById('assistant-voice-overlay'), null, 'End closes the conversation'); +}); + +test('conversation mode survives a failed answer and reports it honestly', async t => { + const app = ui(t); + const doc = app.document; + doc.getElementById('btn-assistant-voice').click(); + await new Promise(r => setImmediate(r)); + doc.dispatchEvent(new app.window.CustomEvent('assistant-answer-done', { detail: { isError: true, answer: '' } })); + assert.equal(doc.getElementById('assistant-voice-status').textContent, 'Answer failed — tap to speak'); + assert.equal(app.ttsCalls.length, 0, 'no TTS after an error'); +}); + +test('conversation mode falls back to the live transcript when server transcription fails', async t => { + const app = ui(t, { transcription: '', failTranscribe: true }); + const doc = app.document; + app.context.transcribeAudio = async () => { throw new Error('down'); }; + doc.getElementById('btn-assistant-voice').click(); + await new Promise(r => setImmediate(r)); + doc.getElementById('assistant-voice-mic').click(); + await new Promise(r => setImmediate(r)); + // feed a live final through the recognition callback before stopping + const rec = app.context.createSpeechRecognition(); + doc.getElementById('assistant-voice-mic').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + assert.equal(doc.getElementById('assistant-input').value, '', 'no text without a transcript and without live speech'); +}); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 505dc8d..9c30b24 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -59,7 +59,7 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back assert.ok(topbar.querySelector('#btn-assistant-goback'), 'Go back control present'); const actions = app.document.querySelector('.assistant-toolbar-actions'); const buttons = [...actions.querySelectorAll('button')].map(b => b.id); - assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat'], 'top bar keeps only Export PDF and Download transcript'); + assert.deepEqual(buttons, ['btn-assistant-export-pdf', 'btn-assistant-download-chat', 'btn-assistant-takehome', 'btn-assistant-voice'], 'top bar keeps Export PDF, Download transcript, Patient take home and Voice'); const examples = app.document.querySelectorAll('.assistant-empty .assistant-examples button, [data-assistant-example]'); assert.ok(examples.length >= 3, 'the generated example questions stay on the empty chat screen'); }); diff --git a/test/clinical-conversation.test.js b/test/clinical-conversation.test.js index b2455c9..a4056ca 100644 --- a/test/clinical-conversation.test.js +++ b/test/clinical-conversation.test.js @@ -36,7 +36,8 @@ function server(options = {}) { const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 }; const mocks = { 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() {} }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai }, + '../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/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, @@ -57,7 +58,8 @@ function server(options = {}) { isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, '../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy, - '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation') + '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), + '../utils/patientTakehome': require('../src/utils/patientTakehome') }; const module = { exports: {} }; vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { diff --git a/test/generated-image-tools.test.js b/test/generated-image-tools.test.js index 473d3d1..b142cf5 100644 --- a/test/generated-image-tools.test.js +++ b/test/generated-image-tools.test.js @@ -82,6 +82,8 @@ function route(file, ai, jobs) { '../utils/clinicalConversation':require('../src/utils/clinicalConversation'), '../utils/clinicalAnswer':require('../src/utils/clinicalAnswer'), '../utils/clinicalTranslation':require('../src/utils/clinicalTranslation'), + '../utils/patientTakehome':require('../src/utils/patientTakehome'), + './auth':{__sendEmail:async()=>false}, '../utils/generatedImages':realImages, '../utils/generatedImageLinks':require('../src/utils/generatedImageLinks'), '../utils/imageTool':{tools:imageTool.tools,dispatch:(value,options)=>imageTool.dispatch(value,{...options,images:{enqueue:async(...args)=>{jobs.push(args);return {jobId:id,status:'pending'};}}})}, '../utils/clinicalMcpClient':{semanticSearch:async()=>({})}, diff --git a/test/patient-takehome.test.js b/test/patient-takehome.test.js new file mode 100644 index 0000000..cc59f05 --- /dev/null +++ b/test/patient-takehome.test.js @@ -0,0 +1,223 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { marked } = require('marked'); +const { webcrypto } = require('node:crypto'); + +const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + +// ── Server: generate + email routes ──────────────────────────────────────── +function server(t, overrides = {}) { + const module = { exports: {} }; + const emailCalls = []; + const aiCalls = []; + const settings = Object.assign({ + 'clinical_assistant.chat_model': 'synthetic-chat', + 'models.default': '', + 'clinical_assistant.system_behavior': '', + 'clinical_assistant.search_limit': '8', + 'clinical_assistant.context_chars': '1400', + 'clinical_assistant.image_model': 'openai-gpt-image-1', + 'clinical_assistant.patient_takehome_behavior': '' + }, overrides.settings || {}); + const quiet = { log() {}, error() {}, warn() {}, audit() {} }; + const mocks = { + express: require('express'), + axios: { get: async () => { throw new Error('unexpected axios call'); }, post: async () => { throw new Error('unexpected axios call'); } }, + '../db/database': { + get: async () => null, + getSetting: async key => settings[key], + query: async () => ({ rows: [] }) + }, + '../middleware/auth': { authMiddleware: (req, res, next) => next() }, + '../utils/ai': { callAI: async (messages, options) => { aiCalls.push({ messages, options }); return { content: 'Take home [1] text.', model: 'synthetic-chat' }; }, callAIStream: async () => { throw new Error('unexpected'); }, activeProvider: 'synthetic', discoverModels: async () => [], vertexClient: null, litellmClient: null, applyImageAttachments: x => x }, + '../utils/generatedImages': { workflows: ['clinical_assistant'], snapshot: async () => ({}), enqueue: async () => ({}), tick: async () => {}, ready: async () => {} }, + '../utils/imageTool': { tools: [], dispatch: async x => x }, + '../utils/generatedImageLinks': { validateChat: () => null }, + '../utils/logger': quiet, + '../utils/crypto': { randomUUID: () => 'synthetic-uuid', encryptString: s => 'enc:' + s, decryptString: s => s.replace(/^enc:/, ''), encryptBuffer: b => b, decryptBuffer: b => b }, + '../utils/redis': { get: async () => null, set: async () => {} }, + '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({ ready: async () => {} }) }, + '../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} }, + '../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] }, + '../utils/clinicalAnswer': require('../src/utils/clinicalAnswer'), + '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), + '../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'), + '../utils/patientTakehome': require('../src/utils/patientTakehome'), + '../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), + './auth': { __sendEmail: async (to, subject, html) => { emailCalls.push({ to, subject, html }); return overrides.smtpConfigured !== false; } }, + '../utils/litellm': { getLiteLLMHeaders: () => ({}) } + }; + vm.runInNewContext(read('src/routes/clinicalAssistant.js'), { + module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder, + process: { env: Object.assign({ CLINICAL_ASSISTANT_MCP_WARMUP: 'false' }, overrides.env || {}) }, + setTimeout() {}, + require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; } + }); + async function request(method, routePath, body) { + const layer = module.exports.stack.find(layer => layer.route && layer.route.path === routePath && layer.route.methods[method]); + assert.ok(layer, 'route missing: ' + method + ' ' + routePath); + const handler = layer.route.stack.find(layer => layer.method === method).handle; + const res = { statusCode: 200, headers: {}, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader() {}, write() {}, end() {}, flushHeaders() {} }; + await handler({ body, user: { id: 7 }, ip: 'synthetic' }, res); + return res; + } + return { request, aiCalls, emailCalls, quiet }; +} + +test('POST /clinical-assistant/patient-takehome rewrites the answer in plain language without citations', async t => { + const s = server(t); + const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: 'Dose: 10 mg/kg [1].' }); + assert.equal(res.statusCode, 200); + assert.equal(res.body.success, true); + assert.equal(res.body.text, 'Take home text.', 'citation tokens are scrubbed from the result'); + assert.equal(s.aiCalls.length, 1); + assert.match(s.aiCalls[0].messages[0].content, /plain language/, 'default patient-language behavior'); + assert.match(s.aiCalls[0].messages[1].content, /Dose: 10 mg\/kg/); + assert.equal(s.aiCalls[0].options.model, 'synthetic-chat', 'chat model resolved from settings'); +}); + +test('patient take-home refuses empty answers without contacting the model', async t => { + const s = server(t); + const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: ' ' }); + assert.equal(res.statusCode, 400); + assert.equal(s.aiCalls.length, 0); +}); + +test('patient take-home honors an admin behavior override', async t => { + const s = server(t, { settings: { 'clinical_assistant.patient_takehome_behavior': 'Custom parent-friendly rewrite.' } }); + const res = await s.request('post', '/clinical-assistant/patient-takehome', { answer: 'Anything.' }); + assert.equal(res.statusCode, 200); + assert.match(s.aiCalls[0].messages[0].content, /Custom parent-friendly rewrite/); +}); + +test('POST /clinical-assistant/patient-takehome/email validates the address and the body', async t => { + const s = server(t); + const badEmail = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'not-an-email', text: 'Take home' }); + assert.equal(badEmail.statusCode, 400); + assert.equal(s.emailCalls.length, 0); + const badBody = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: ' ' }); + assert.equal(badBody.statusCode, 400); + assert.equal(s.emailCalls.length, 0); +}); + +test('patient take-home email reports honestly when SMTP is not configured', async t => { + const s = server(t, { smtpConfigured: false }); + const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Take home' }); + assert.equal(res.statusCode, 503); + assert.equal(res.body.code, 'SMTP_NOT_CONFIGURED'); +}); + +test('patient take-home email sends plain text wrapped in a simple caregiver note', async t => { + const s = server(t); + const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Give fluids & rest.' }); + assert.equal(res.statusCode, 200); + assert.equal(s.emailCalls.length, 1); + assert.equal(s.emailCalls[0].to, 'parent@example.com'); + assert.match(s.emailCalls[0].subject, /Patient Take Home/); + assert.match(s.emailCalls[0].html, /Give fluids & rest\./, 'HTML-escaped plain text'); +}); + +// ── Frontend: button → modal → copy/export/email ─────────────────────────── +function client(t, options = {}) { + const dom = new JSDOM('
' + read('public/components/assistant.html') + '
', { url: 'https://example.test', runScripts: 'outside-only' }); + const window = dom.window; + window.eval(read('public/js/accountBoundary.js')); + assert.equal(window.AccountBoundary.enter({ id: 'synthetic-takehome-owner' }, true), true); + window.marked = marked; + window.DOMPurify = require('dompurify')(window); + window.matchMedia = () => ({ matches: true }); + const calls = []; + const toasts = []; + const copies = []; + const downloads = []; + window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:synthetic'; }; + window.URL.revokeObjectURL = () => {}; + const apiFetch = async (url, options) => { + calls.push({ url, options }); + if (url === '/clinical-assistant/patient-takehome') { + return new Response(JSON.stringify({ success: true, text: options.takehomeText || 'Patient take home sheet.' })); + } + if (url === '/clinical-assistant/patient-takehome/email') { + if (options.failEmail) return new Response(JSON.stringify({ error: 'Email is not configured on this server yet' }), { status: 503 }); + return new Response(JSON.stringify({ success: true })); + } + if (url === '/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, chats: [] })); + }; + const context = { window, document: window.document, console, URL: window.URL, Blob, TextDecoder, AbortController, crypto: webcrypto, + setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]], + createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }), + 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()), + getAuthHeaders: () => ({ 'X-Synthetic': '1' }), + fetch: apiFetch }; + vm.createContext(context); + for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) { + vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); + } + context.bindEvents(); + t.after(() => window.close()); + return { context, document: window.document, window, calls, toasts, copies, downloads }; +} + +test('Patient take home button opens the modal, generates, and offers Copy/Export/Send', async t => { + const app = client(t); + const c = app.context; + c.appendMessage('assistant', 'Answer [1].', []); + c.lastAnswer = 'Answer [1].'; + app.document.getElementById('btn-assistant-takehome').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + const modal = app.document.getElementById('assistant-takehome-modal'); + assert.ok(modal, 'modal opened'); + assert.equal(modal.querySelector('.assistant-takehome-result').textContent, 'Patient take home sheet.'); + assert.ok(modal.querySelector('[data-assistant-takehome-copy]')); + assert.ok(modal.querySelector('[data-assistant-takehome-export]')); + assert.ok(modal.querySelector('[data-assistant-takehome-send]')); + const call = app.calls.find(c => c.url === '/clinical-assistant/patient-takehome'); + assert.equal(JSON.parse(call.options.body).answer, 'Answer [1].'); +}); + +test('take home refuses honestly when there is no answer yet', async t => { + const app = client(t); + app.document.getElementById('btn-assistant-takehome').click(); + await new Promise(r => setImmediate(r)); + assert.equal(app.document.getElementById('assistant-takehome-modal'), null); + assert.equal(app.calls.filter(c => c.url === '/clinical-assistant/patient-takehome').length, 0); + assert.match(app.toasts[0][0], /Ask a question first/); +}); + +test('email send succeeds and clears the field; failure shows the server message', async t => { + const app = client(t); + 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)); + const email = app.document.getElementById('assistant-takehome-email'); + email.value = 'parent@example.com'; + app.document.querySelector('[data-assistant-takehome-send]').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + assert.equal(email.value, '', 'field cleared after success'); + assert.ok(app.toasts.some(t => /sent to parent@example\.com/.test(t[0]))); +}); + +test('translate picker filters languages by the local provider availability', async t => { + const app = client(t); + const c = app.context; + c.appendMessage('assistant', 'Answer.', []); + const row = app.document.querySelector('.assistant-msg'); + row.querySelector('[data-assistant-msg-translate]').click(); + await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r)); + const pop = app.document.querySelector('[data-assistant-translate-pop]'); + assert.ok(pop, 'picker opened'); + 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'); +});