pediatric-ai-scribe-v3/public/js/ed-encounters.js
Daniel 0007d0adb1 feat: ED encounters + notes model selector; remove AI corrections; fix notes framing
Four changes batched:

1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
   tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
   (generate per-stage + finalize MDM), new ed-encounters.js owning all
   client logic, new ed-encounter.html component, new template_ed memory
   category. Persists draft to localStorage every keystroke and to
   saved_encounters on stage advance. encounters.js touched only to
   register the new tab in sessionStorage restore + tabMap (save and
   idempotency code untouched).

2. Notes model selector — /notes/from-voice now accepts a client-supplied
   model (validated by the existing callAI allow-list); falls back to the
   admin default. Added <select class="tab-model-select"> to notes.html
   so the existing app.js populator handles options + default.

3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
   POST /memories/correction, the corrections branch in
   /memories/context, the settings UI section, the FAQ entry, and all
   dead trackAIOutput/saveCorrection guards in callers. Legacy
   correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
   no destructive migration.

4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
   "physician dictation". Plain notes (shopping lists, reminders,
   ideas) now match the dictation tone instead of being forced into
   clinical structure.

All 46 tests pass.
2026-04-26 19:06:00 +02:00

524 lines
22 KiB
JavaScript

// ============================================================
// ED ENCOUNTER TAB — multi-stage emergency note with don't-miss
// tooltips and 2023 E/M MDM finalize.
//
// State model:
// _state = {
// stage: 1|2|..., // current stage number
// stages: [ // per-stage history
// { transcript, note, dontMiss, model, generatedAt }
// ],
// finalized: false,
// mdm: null
// }
//
// Persistence: localStorage on every mutation (debounced) + auto-save
// to saved_encounters DB row (status='draft') on stage transitions.
// "Save & Done" generates MDM, marks status='final', clears localStorage.
//
// Sacred-file note: this file owns ALL ED logic. encounters.js gets
// a 2-string touch only (sessionStorage array + tabMap).
// ============================================================
(function() {
'use strict';
var LS_KEY = 'ped_ed_draft_v1';
var AUTOSAVE_MS = 1500;
var _state = freshState();
var _saveTimer = null;
var _recorder = null;
var _timer = null;
var _recording = false;
var _paused = false;
var _recognition = null;
var _liveTranscript = '';
function freshState() {
return { stage: 1, stages: [], finalized: false, mdm: null };
}
// ── Persistence ──────────────────────────────────────────────────────
function persistLocal() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(function() {
try {
var snap = {
state: _state,
label: getVal('ed-label'),
age: getVal('ed-age'),
gender: getVal('ed-gender'),
cc: getVal('ed-cc'),
currentTranscript: getText('ed-transcript'),
editedNote: getText('ed-note-text')
};
localStorage.setItem(LS_KEY, JSON.stringify(snap));
} catch (e) {}
}, 300);
}
function loadLocal() {
try {
var raw = localStorage.getItem(LS_KEY);
if (!raw) return;
var snap = JSON.parse(raw);
if (!snap || !snap.state) return;
_state = snap.state;
setVal('ed-label', snap.label || '');
setVal('ed-age', snap.age || '');
setVal('ed-gender', snap.gender || '');
setVal('ed-cc', snap.cc || '');
setText('ed-transcript', snap.currentTranscript || '');
// Render last generated stage if present
var last = lastStage();
if (last) {
setText('ed-note-text', snap.editedNote || last.note || '');
renderDontMiss(last.dontMiss || []);
showOutput();
updateStageTags();
}
if (_state.finalized && _state.mdm) renderMdm(_state.mdm);
} catch (e) {}
}
function clearLocal() {
try { localStorage.removeItem(LS_KEY); } catch (e) {}
}
function lastStage() {
return _state.stages.length ? _state.stages[_state.stages.length - 1] : null;
}
// ── DOM helpers ──────────────────────────────────────────────────────
function getVal(id) { var el = document.getElementById(id); return el ? el.value : ''; }
function setVal(id, v) { var el = document.getElementById(id); if (el) el.value = v; }
function getText(id) {
var el = document.getElementById(id);
if (!el) return '';
return (el.innerText || el.textContent || '').trim();
}
function setText(id, v) {
var el = document.getElementById(id);
if (!el) return;
if (typeof setOutputText === 'function' && id === 'ed-note-text') setOutputText(el, v);
else el.textContent = v;
}
function showEl(id) { var el = document.getElementById(id); if (el) el.classList.remove('hidden'); }
function hideEl(id) { var el = document.getElementById(id); if (el) el.classList.add('hidden'); }
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function updateStageTags() {
var s = _state.stage;
var badge = document.getElementById('ed-stage-badge');
var rec = document.getElementById('ed-rec-stage-num');
var gen = document.getElementById('ed-gen-stage-num');
var tag = document.getElementById('ed-note-stage-tag');
if (badge) badge.textContent = _state.finalized ? 'Finalized' : 'Stage ' + s;
if (rec) rec.textContent = String(s);
if (gen) gen.textContent = String(s);
if (tag) tag.textContent = 'Stage ' + s;
}
function showOutput() {
var card = document.getElementById('ed-note-output');
if (card) { card.classList.remove('hidden'); card.scrollIntoView({ behavior: 'smooth' }); }
}
// ── Recording ────────────────────────────────────────────────────────
function initRecording() {
var recordBtn = document.getElementById('ed-record-btn');
var pauseBtn = document.getElementById('ed-pause-btn');
var indicator = document.getElementById('ed-rec-indicator');
var timerEl = document.getElementById('ed-timer');
var transcriptEl = document.getElementById('ed-transcript');
if (!recordBtn) return;
_timer = createTimer(timerEl);
_recognition = createSpeechRecognition();
if (_recognition) {
_recognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) _liveTranscript += e.results[i][0].transcript + ' ';
else interim = e.results[i][0].transcript;
}
if (transcriptEl) transcriptEl.innerHTML = _liveTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
};
_recognition.onend = function() { if (_recording && !_paused) try { _recognition.start(); } catch(e) {} };
}
recordBtn.addEventListener('click', function() {
if (!_recording) {
_liveTranscript = '';
_recorder = new AudioRecorder();
_recorder.start().then(function() {
_recording = true; _paused = false;
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
recordBtn.classList.add('recording');
if (pauseBtn) pauseBtn.classList.remove('hidden');
indicator.classList.remove('hidden');
_timer.start();
if (_recognition) try { _recognition.start(); } catch(e) {}
showToast('ED recording started — Stage ' + _state.stage, 'info');
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'ed' } }));
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
_recording = false; _paused = false;
var dur = _timer.stop();
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
recordBtn.classList.remove('recording');
if (pauseBtn) pauseBtn.classList.add('hidden');
indicator.classList.add('hidden');
if (_recognition) try { _recognition.stop(); } catch(e) {}
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'ed' } }));
showBusy('Transcribing...');
_recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) {
hideBusy();
if (_liveTranscript.trim() && transcriptEl) transcriptEl.textContent = _liveTranscript.trim();
persistLocal();
return;
}
return transcribeAudio(blob).then(function(data) {
hideBusy();
if (data && data.success) {
if (transcriptEl) transcriptEl.textContent = data.text;
showToast('Transcribed ' + dur + 's', 'success');
} else if (_liveTranscript.trim() && transcriptEl) {
transcriptEl.textContent = _liveTranscript.trim();
}
persistLocal();
});
}).catch(function() { hideBusy(); });
}
});
if (pauseBtn) {
pauseBtn.addEventListener('click', function() {
if (!_recording || !_recorder || !_recorder.mediaRecorder) return;
if (!_paused) {
try { if (_recorder.mediaRecorder.state === 'recording') _recorder.mediaRecorder.pause(); } catch(e) {}
_timer.stop(); _paused = true;
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_recognition) try { _recognition.stop(); } catch(e) {}
} else {
try {
if (_recorder.mediaRecorder.state === 'paused') { _recorder.mediaRecorder.resume(); }
else if (_recorder.mediaRecorder.state === 'inactive' && _recorder.stream && _recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
_recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
_recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) _recorder.chunks.push(e.data); };
_recorder.mediaRecorder.start(1000);
}
} catch(e) {}
_timer.resume(); _paused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
if (_recognition) try { _recognition.start(); } catch(e) {}
}
});
}
}
// ── Generate (per-stage) ─────────────────────────────────────────────
function generateStage() {
var cc = getVal('ed-cc');
if (!cc.trim()) { showToast('Enter a chief complaint first', 'error'); return; }
var transcript = getText('ed-transcript');
if (!transcript) { showToast('No transcript — record or type the dictation first', 'error'); return; }
var modelEl = document.getElementById('ed-model-select');
var selectedModel = modelEl ? modelEl.value : '';
showBusy('Generating ED note (Stage ' + _state.stage + ')...');
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
var prevNote = (_state.stages.length > 0) ? _state.stages[_state.stages.length - 1].note : '';
return fetch('/api/ed-encounters/generate', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
stage: _state.stage,
transcript: transcript,
chiefComplaint: cc,
patientAge: getVal('ed-age'),
patientGender: getVal('ed-gender'),
previousNote: prevNote || undefined,
physicianMemories: memCtx || undefined,
model: selectedModel || undefined
})
});
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
_state.stages.push({
transcript: transcript,
note: data.note || '',
dontMiss: data.dontMiss || [],
model: data.model || '',
generatedAt: new Date().toISOString()
});
setText('ed-note-text', data.note || '');
var modelTag = document.getElementById('ed-note-model-tag');
if (modelTag) modelTag.textContent = (data.model || '').split('/').pop();
renderDontMiss(data.dontMiss || []);
updateStageTags();
showOutput();
persistLocal();
autoSaveDraft(); // best-effort DB draft save on stage completion
showToast('Stage ' + _state.stage + ' generated. Save & Done, or Add more.', 'success');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
// ── Don't-miss rendering ─────────────────────────────────────────────
function renderDontMiss(items) {
var card = document.getElementById('ed-dontmiss-card');
var list = document.getElementById('ed-dontmiss-list');
if (!card || !list) return;
if (!items || !items.length) {
card.classList.add('hidden');
list.innerHTML = '';
return;
}
list.innerHTML = items.map(function(it) {
var why = it.why ? '<div style="font-size:11px;color:var(--g500);margin-top:2px;">' + escHtml(it.why) + '</div>' : '';
return '<div style="padding:6px 0;border-bottom:1px solid var(--g100);">' +
'<div style="font-size:13px;color:var(--g800);"><i class="fas fa-circle-exclamation" style="color:#f59e0b;font-size:11px;margin-right:6px;"></i>' + escHtml(it.point) + '</div>' +
why + '</div>';
}).join('');
card.classList.remove('hidden');
}
// ── Add another stage ────────────────────────────────────────────────
function advanceStage() {
// Lock in any physician edits to the displayed note before moving on
var edited = getText('ed-note-text');
if (edited && _state.stages.length) {
_state.stages[_state.stages.length - 1].note = edited;
}
_state.stage += 1;
setText('ed-transcript', '');
_liveTranscript = '';
updateStageTags();
persistLocal();
showToast('Ready for Stage ' + _state.stage + ' — record or type additional findings.', 'info');
var rec = document.getElementById('ed-record-btn');
if (rec) rec.scrollIntoView({ behavior: 'smooth' });
}
// ── Finalize → MDM + persist as final ────────────────────────────────
function finalize() {
var noteEl = document.getElementById('ed-note-text');
var note = noteEl ? (noteEl.innerText || noteEl.textContent || '').trim() : '';
if (!note) { showToast('Generate a note first', 'error'); return; }
var label = getVal('ed-label');
if (!label.trim()) { showToast('Enter a patient label before finalizing', 'error'); return; }
// Lock in physician edits
if (_state.stages.length) _state.stages[_state.stages.length - 1].note = note;
var modelEl = document.getElementById('ed-model-select');
var selectedModel = modelEl ? modelEl.value : '';
var fullTranscript = _state.stages.map(function(s, i) {
return '--- Stage ' + (i + 1) + ' ---\n' + (s.transcript || '');
}).join('\n\n');
showBusy('Generating MDM and finalizing...');
fetch('/api/ed-encounters/finalize', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
note: note,
transcript: fullTranscript,
patientAge: getVal('ed-age'),
model: selectedModel || undefined
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { hideBusy(); showToast(data.error || 'MDM generation failed', 'error'); return; }
_state.mdm = data.mdm;
_state.finalized = true;
renderMdm(data.mdm);
// Final save: encounter row with status='final', full transcript +
// note + MDM bundled into partial_data so resume works.
var partial = { stages: _state.stages, mdm: data.mdm, finalized: true };
if (typeof saveEncounter === 'function') {
saveEncounter({
id: window._savedEncId_ed || null,
label: label,
enc_type: 'ed',
transcript: fullTranscript,
generated_note: composeFinalNote(note, data.mdm),
partial_data: partial,
status: 'final',
idempotency_key: 'ed-final-' + Date.now(),
onSaved: function(id) {
window._savedEncId_ed = id;
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
clearLocal();
hideBusy();
updateStageTags();
showToast('Encounter finalized and saved.', 'success');
}
});
} else {
hideBusy();
}
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function composeFinalNote(noteText, mdm) {
if (!mdm) return noteText;
var lines = [
noteText,
'',
'Medical Decision Making (2023 E/M):',
'Problems Addressed (' + (mdm.problemsAddressed || '') + '): ' + (mdm.problemsNarrative || ''),
'Data Reviewed (' + (mdm.dataReviewed || '') + '): ' + (mdm.dataNarrative || ''),
'Risk (' + (mdm.risk || '') + '): ' + (mdm.riskNarrative || ''),
'Suggested Level: ' + (mdm.suggestedLevel || '') + ' — ' + (mdm.levelRationale || '')
];
return lines.join('\n');
}
function renderMdm(mdm) {
var card = document.getElementById('ed-mdm-card');
var text = document.getElementById('ed-mdm-text');
var tag = document.getElementById('ed-mdm-level-tag');
if (!card || !text || !mdm) return;
if (tag) tag.textContent = mdm.suggestedLevel || '';
var html =
'<div style="margin-bottom:6px;"><strong>Problems Addressed</strong> (' + escHtml(mdm.problemsAddressed) + ')<br>' + escHtml(mdm.problemsNarrative) + '</div>' +
'<div style="margin-bottom:6px;"><strong>Data Reviewed</strong> (' + escHtml(mdm.dataReviewed) + ')<br>' + escHtml(mdm.dataNarrative) + '</div>' +
'<div style="margin-bottom:6px;"><strong>Risk</strong> (' + escHtml(mdm.risk) + ')<br>' + escHtml(mdm.riskNarrative) + '</div>' +
'<div style="padding-top:8px;border-top:1px solid var(--g100);"><strong>Suggested Level: ' + escHtml(mdm.suggestedLevel) + '</strong><br>' +
'<span style="font-size:12px;color:var(--g600);">' + escHtml(mdm.levelRationale) + '</span></div>' +
'<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestion only. Verify against your institution\'s coding guidelines.</p>';
text.innerHTML = html;
card.classList.remove('hidden');
card.scrollIntoView({ behavior: 'smooth' });
}
// ── Save draft (manual / auto on stage advance) ──────────────────────
function autoSaveDraft() {
var label = getVal('ed-label');
if (!label.trim()) return; // need a label to save; skip silently
if (typeof saveEncounter !== 'function') return;
var fullTranscript = _state.stages.map(function(s) { return s.transcript || ''; }).join('\n\n');
var partial = { stages: _state.stages, finalized: false };
saveEncounter({
id: window._savedEncId_ed || null,
label: label,
enc_type: 'ed',
transcript: fullTranscript,
generated_note: lastStage() ? lastStage().note : '',
partial_data: partial,
status: 'draft',
idempotency_key: 'ed-draft-' + (window._savedEncId_ed || 'new'),
onSaved: function(id) {
window._savedEncId_ed = id;
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
}
});
}
function manualSaveDraft() {
if (!getVal('ed-label').trim()) { showToast('Enter a patient label first', 'error'); return; }
autoSaveDraft();
showToast('Draft saved', 'success');
}
// ── Reset / new patient ──────────────────────────────────────────────
function resetEncounter() {
_state = freshState();
setVal('ed-label', '');
setVal('ed-age', '');
setVal('ed-gender', '');
setVal('ed-cc', '');
setText('ed-transcript', '');
setText('ed-note-text', '');
_liveTranscript = '';
hideEl('ed-note-output');
hideEl('ed-dontmiss-card');
hideEl('ed-mdm-card');
var modelTag = document.getElementById('ed-note-model-tag');
if (modelTag) modelTag.textContent = '';
window._savedEncId_ed = null;
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
clearLocal();
updateStageTags();
showToast('ED encounter cleared for new patient', 'info');
}
// ── Wire click handlers ──────────────────────────────────────────────
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-ed-generate')) generateStage();
if (e.target.closest('#btn-ed-add-more')) advanceStage();
if (e.target.closest('#btn-ed-finalize')) finalize();
if (e.target.closest('#btn-ed-new')) resetEncounter();
if (e.target.closest('#btn-ed-save')) manualSaveDraft();
if (e.target.closest('#ed-refine-btn')) refineDocument('ed-note-text', 'ed-refine-input');
if (e.target.closest('#ed-shorten-btn')) shortenDocument('ed-note-text');
});
// Persist on input changes
document.addEventListener('input', function(e) {
if (!e.target) return;
var id = e.target.id;
if (id === 'ed-label' || id === 'ed-age' || id === 'ed-gender' || id === 'ed-cc' || id === 'ed-transcript' || id === 'ed-note-text') {
persistLocal();
}
});
// ── Load handler (resume from saved encounters list) ────────────────
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('ed', function(enc) {
// Reset and restore from the row
_state = freshState();
setVal('ed-label', enc.label || '');
var partial = enc.partial_data;
try { if (typeof partial === 'string') partial = JSON.parse(partial); } catch (e) { partial = null; }
if (partial && partial.stages) {
_state.stages = partial.stages;
_state.stage = partial.stages.length || 1;
_state.finalized = !!partial.finalized;
_state.mdm = partial.mdm || null;
var last = lastStage();
if (last) {
setText('ed-note-text', last.note || '');
renderDontMiss(last.dontMiss || []);
showOutput();
}
if (_state.mdm) renderMdm(_state.mdm);
} else if (enc.generated_note) {
setText('ed-note-text', enc.generated_note);
showOutput();
}
if (enc.transcript) setText('ed-transcript', enc.transcript);
updateStageTags();
});
}
// ── Init on tab activation ───────────────────────────────────────────
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'ed' || _inited) return;
_inited = true;
initRecording();
loadLocal();
updateStageTags();
});
console.log('ED encounters module loaded');
})();