feat: patient take home (copy/export/email), composer mic dictation, voice conversation mode, provider-aware translate languages
This commit is contained in:
parent
5d764bb3ff
commit
09a4e02e39
15 changed files with 875 additions and 10 deletions
|
|
@ -45,6 +45,8 @@
|
|||
<div class="assistant-toolbar-actions">
|
||||
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
|
||||
<button id="btn-assistant-download-chat" class="btn-sm btn-ghost" type="button">Download transcript</button>
|
||||
<button id="btn-assistant-takehome" class="btn-sm btn-ghost" type="button"><i class="fas fa-heart"></i> Patient take home</button>
|
||||
<button id="btn-assistant-voice" class="btn-sm btn-ghost" type="button"><i class="fas fa-microphone"></i> Voice</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -71,6 +73,7 @@
|
|||
<label class="assistant-attach" for="assistant-attach-input" title="Attach up to 4 images (PNG, JPEG, WebP)"><i class="fas fa-paperclip"></i> Attach images</label>
|
||||
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
|
||||
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
|
||||
<button id="btn-assistant-mic" class="btn-sm btn-ghost assistant-mic" type="button" title="Dictate your question"><i class="fas fa-microphone"></i></button>
|
||||
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
|
||||
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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); } }
|
||||
|
|
|
|||
|
|
@ -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 '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
|
||||
}).join('');
|
||||
if (!pairs.length) list.innerHTML = '<span class="assistant-translate-none">No languages available for this provider.</span>';
|
||||
}
|
||||
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 = '<div class="assistant-voice-card">' +
|
||||
'<div class="assistant-voice-status" id="assistant-voice-status">Tap to speak</div>' +
|
||||
'<button type="button" class="assistant-voice-mic" id="assistant-voice-mic"><i class="fas fa-microphone"></i></button>' +
|
||||
'<div class="assistant-voice-actions"><button type="button" id="assistant-voice-end" class="btn-sm btn-ghost"><i class="fas fa-phone-slash"></i> End</button></div></div>';
|
||||
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 = '<div class="modal-content">' +
|
||||
'<div class="modal-header"><h2>Patient take home</h2>' +
|
||||
'<button type="button" class="modal-close" data-assistant-takehome-close aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
|
||||
'<div class="modal-body"><div class="assistant-takehome-loading"><i class="fas fa-spinner fa-spin"></i> Writing a plain-language summary…</div>' +
|
||||
'<div class="assistant-takehome-result hidden"></div>' +
|
||||
'<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>' +
|
||||
'<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>';
|
||||
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 = '<i class="fas fa-triangle-exclamation"></i> ' + 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 {
|
|||
'<button type="button" data-assistant-translate-provider="libretranslate" class="' + (translateProvider === 'libretranslate' ? 'active' : '') + '">Local</button>' +
|
||||
'<button type="button" data-assistant-translate-provider="deepl" class="' + (translateProvider === 'deepl' ? 'active' : '') + '">DeepL</button>' +
|
||||
'</div>' +
|
||||
'<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>';
|
||||
}).join('');
|
||||
}).join('') +
|
||||
'</div>';
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'<div style="font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;color:#1f2937;">' +
|
||||
'<h2 style="color:#0f766e;margin:0 0 12px;">Patient Take Home</h2>' +
|
||||
'<p style="white-space:pre-wrap;">' + String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</p>' +
|
||||
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This summary was created for a caregiver. Keep following your care team’s instructions.</p></div>');
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
43
src/utils/patientTakehome.js
Normal file
43
src/utils/patientTakehome.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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'), {
|
||||
|
|
|
|||
|
|
@ -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'), {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
138
test/assistant-voice.test.js
Normal file
138
test/assistant-voice.test.js
Normal file
|
|
@ -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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'), {
|
||||
|
|
|
|||
|
|
@ -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()=>({})},
|
||||
|
|
|
|||
223
test/patient-takehome.test.js
Normal file
223
test/patient-takehome.test.js
Normal file
|
|
@ -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('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { 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');
|
||||
});
|
||||
Loading…
Reference in a new issue