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.
This commit is contained in:
Daniel 2026-04-26 19:06:00 +02:00
parent 062276e9cc
commit f8e883e969
21 changed files with 899 additions and 298 deletions

View file

@ -0,0 +1,99 @@
<div class="module-header">
<h2><i class="fas fa-truck-medical" style="color:#dc2626;"></i> ED Encounter</h2>
<p>Multi-stage emergency note: dictate → review → optionally add more → finalize with MDM.</p>
</div>
<!-- Save/Load bar -->
<div class="save-bar-wrap">
<div class="save-bar" id="ed-save-bar">
<div style="display:flex;align-items:center;gap:8px;flex:1;">
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
<input type="text" id="ed-label" class="save-label-input" placeholder="Patient label (e.g. Jane D, 8y abdominal pain)">
</div>
<span id="ed-stage-badge" class="model-tag" style="background:var(--g100);color:var(--g600);">Stage 1</span>
<button id="btn-ed-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save draft</button>
<button id="btn-ed-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
<button id="btn-ed-new" class="btn-sm btn-ghost" title="Clear and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
</div>
<div id="ed-load-popover" class="enc-load-popover hidden">
<div class="enc-load-popover-inner">
<input type="text" id="ed-load-search" class="enc-load-search" placeholder="Search saved encounters...">
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
</div>
<div id="ed-pop-list" class="enc-pop-list"></div>
</div>
</div>
<!-- Demographics + Model -->
<div class="card" style="margin-bottom:10px;">
<div class="card-header"><h3><i class="fas fa-user"></i> Patient Info</h3></div>
<div style="padding:12px 16px;display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;">
<div class="demo-field"><label>Age</label><input type="text" id="ed-age" placeholder="e.g., 5 years" style="width:110px;"></div>
<div class="demo-field"><label>Gender</label><select id="ed-gender"><option value="">Select</option><option>Male</option><option>Female</option><option>Non-binary/Other</option></select></div>
<div class="demo-field" style="flex:1;min-width:200px;"><label>Chief Complaint</label><input type="text" id="ed-cc" placeholder="e.g., abdominal pain, head injury, fever in infant" style="width:100%;"></div>
<div class="demo-field demo-field-model"><label><i class="fas fa-robot"></i> Model</label><select id="ed-model-select" class="tab-model-select"></select></div>
</div>
</div>
<!-- Recording -->
<div class="card" style="margin-bottom:10px;">
<div class="card-header">
<h3><i class="fas fa-microphone"></i> Stage <span id="ed-rec-stage-num">1</span> Recording / Dictation</h3>
<div style="display:flex;gap:6px;align-items:center;">
<button id="ed-record-btn" class="btn-sm btn-ghost"><i class="fas fa-microphone"></i> Listen In</button>
<button id="ed-pause-btn" class="btn-sm btn-ghost hidden"><i class="fas fa-pause"></i> Pause</button>
<div id="ed-rec-indicator" class="recording-indicator hidden" style="font-size:12px;"><div class="pulse-dot"></div><span>Recording... <span id="ed-timer">00:00</span></span></div>
</div>
</div>
<div style="padding:8px 16px;">
<div id="ed-transcript" class="editable-box" contenteditable="true" data-placeholder="Transcript appears here. Speak naturally — include direct asides like 'include normal cardiac exam' or 'assessment is viral URI' and the AI will route them to the right section."></div>
</div>
</div>
<button id="btn-ed-generate" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate Stage <span id="ed-gen-stage-num">1</span> Note</button>
<!-- Output: note + don't-miss panel + stage controls -->
<div id="ed-note-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> ED Note <span id="ed-note-stage-tag" class="model-tag" style="background:var(--g100);color:var(--g600);margin-left:6px;">Stage 1</span></h3>
<div class="output-actions">
<span id="ed-note-model-tag" class="model-tag"></span>
<button class="btn-sm btn-primary" data-action="copy" data-target="ed-note-text"><i class="fas fa-copy"></i> Copy</button>
<button class="btn-sm btn-ghost" data-action="speak" data-target="ed-note-text"><i class="fas fa-volume-high"></i> Read</button>
</div>
</div>
<div id="ed-note-text" class="output-text" contenteditable="true"></div>
<div class="refine-bar">
<textarea id="ed-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'add that patient received Zofran')"></textarea>
<button class="btn-sm btn-primary" id="ed-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<button class="btn-sm btn-ghost" id="ed-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
</div>
<!-- Stage controls — appear after note generation -->
<div id="ed-stage-controls" style="padding:12px 16px;border-top:1px solid var(--g100);display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button id="btn-ed-add-more" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add more (next stage)</button>
<button id="btn-ed-finalize" class="btn-sm" style="background:var(--green);color:white;border:none;"><i class="fas fa-check-double"></i> Save &amp; Done (with MDM)</button>
<span style="font-size:11px;color:var(--g500);">Stages auto-save as drafts. "Done" generates the MDM block and locks the encounter.</span>
</div>
</div>
<!-- Don't-miss panel (rendered next to the note via ed-encounters.js) -->
<div id="ed-dontmiss-card" class="card hidden" style="margin-top:10px;border-left:3px solid #f59e0b;">
<div class="card-header">
<h3><i class="fas fa-triangle-exclamation" style="color:#f59e0b;"></i> Don't Miss</h3>
<span style="font-size:11px;color:var(--g500);">High-yield items tailored to the chief complaint and age. Suggestions only.</span>
</div>
<div id="ed-dontmiss-list" style="padding:8px 16px;"></div>
</div>
<!-- MDM block (rendered after finalize) -->
<div id="ed-mdm-card" class="card hidden" style="margin-top:10px;border-left:3px solid var(--green);">
<div class="card-header output-header">
<h3><i class="fas fa-file-invoice-dollar"></i> Medical Decision Making (2023 E/M)</h3>
<div class="output-actions">
<span id="ed-mdm-level-tag" class="model-tag" style="background:var(--green-light);color:var(--green);"></span>
<button class="btn-sm btn-primary" data-action="copy" data-target="ed-mdm-text"><i class="fas fa-copy"></i> Copy</button>
</div>
</div>
<div id="ed-mdm-text" class="output-text"></div>
</div>

View file

@ -67,21 +67,6 @@
</div>
</div>
<div class="faq-item">
<button class="faq-question">Does the AI learn from my edits?</button>
<div class="faq-answer">
<p><strong>Yes.</strong> The app uses a correction tracking system inspired by Dragon Medical's adaptive learning. Here is how it works:</p>
<ol>
<li>When the AI generates a note, the original output is stored in memory</li>
<li>You edit the note to match your preferred style &mdash; fix phrasing, add details, restructure sections</li>
<li>When you click <strong>Save</strong>, the app detects what you changed and stores the correction</li>
<li>On future notes, your past corrections are included as style hints so the AI adapts to your documentation preferences</li>
</ol>
<p>The more you use the app and save your edits, the better the AI gets at matching your style. You can view and manage your stored corrections in <strong>Settings &gt; AI Corrections</strong>.</p>
<p><em>Note: Corrections are applied as gentle suggestions, not strict rules. The AI prioritizes clinical accuracy over style matching.</em></p>
</div>
</div>
<div class="faq-item">
<button class="faq-question">Can I customize the AI's prompts?</button>
<div class="faq-answer">

View file

@ -77,6 +77,7 @@
STT provider, then asks the AI to produce a clean note.
Pause/Resume available natively through MediaRecorder. -->
<div class="notes-voice-bar" id="notes-voice-bar">
<select id="notes-model-select" class="tab-model-select" title="AI model for voice → note conversion" style="font-size:11px;padding:2px 6px;max-width:160px;"></select>
<button id="btn-note-rec-start" class="btn-sm btn-ghost" type="button" title="Record voice → AI note">
<i class="fas fa-microphone" style="color:var(--red);"></i> Dictate
</button>

View file

@ -190,6 +190,7 @@
<option value="template_hpi">HPI Template</option>
<option value="template_wellvisit">Well Visit Template</option>
<option value="template_sickvisit">Sick Visit Template</option>
<option value="template_ed">ED Template</option>
<option value="custom">Custom</option>
</select>
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
@ -219,15 +220,6 @@
</div>
</div>
<!-- AI Corrections (Dragon-like memory) -->
<div class="settings-section card">
<h3><i class="fas fa-brain"></i> AI Learning (Corrections)</h3>
<p style="font-size:13px;color:var(--g600);">The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.</p>
<div id="corrections-list" style="display:flex;flex-direction:column;gap:6px;">
<p style="color:var(--g400);font-size:13px;">Loading corrections...</p>
</div>
</div>
<!-- Audio Backups -->
<div class="settings-section card">
<h3><i class="fas fa-microphone-lines"></i> Audio Backups</h3>

View file

@ -202,6 +202,10 @@
<i class="fas fa-microphone"></i>
<span>Dictation HPI</span>
</button>
<button class="tab-btn" data-tab="ed">
<i class="fas fa-truck-medical"></i>
<span>ED Encounter</span>
</button>
<span class="sidebar-section-label">Notes</span>
<button class="tab-btn" data-tab="hospital">
<i class="fas fa-hospital"></i>
@ -311,6 +315,7 @@
<section id="admin-tab" class="tab-content" data-component="admin"></section>
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
<section id="sickvisit-tab" class="tab-content" data-component="sickvisit"></section>
<section id="ed-tab" class="tab-content" data-component="ed-encounter"></section>
<section id="settings-tab" class="tab-content" data-component="settings"></section>
<section id="faq-tab" class="tab-content" data-component="faq"></section>
@ -442,7 +447,6 @@
<script defer src="/js/milestonesData.js"></script>
<script defer src="/js/pediatricScheduleData.js"></script>
<script defer src="/js/audioBackup.js"></script>
<script defer src="/js/correctionTracker.js"></script>
<script defer src="/js/browserWhisper.js"></script>
<script defer src="/js/speechRecognition.js"></script>
<script defer src="/js/transcriptionSettings.js"></script>
@ -465,6 +469,7 @@
<script defer src="/js/wellVisit.js"></script>
<script defer src="/js/shadess.js"></script>
<script defer src="/js/sickVisit.js"></script>
<script defer src="/js/ed-encounters.js"></script>
<script defer src="/js/encounters.js"></script>
<script defer src="/js/memories.js"></script>
<script defer src="/js/documents.js"></script>

View file

@ -1,86 +0,0 @@
// ============================================================
// CORRECTION TRACKER — Dragon-like AI learning from user edits
// Tracks when users edit AI-generated output and saves corrections
// so the AI can learn user preferences over time.
// ============================================================
(function() {
// Store original AI outputs per element
var _originals = {};
// Track an output element: store original text when AI generates it
window.trackAIOutput = function(elementId, originalText) {
if (!elementId || !originalText) return;
_originals[elementId] = originalText.trim();
};
// Save correction when user is done editing (call on save or blur)
window.saveCorrection = function(elementId, section) {
var original = _originals[elementId];
if (!original) return;
var el = document.getElementById(elementId);
if (!el) return;
var current = (el.innerText || el.textContent || '').trim();
if (!current || current === original) return;
// Only save if there's a meaningful difference (not just whitespace)
var origWords = original.split(/\s+/).length;
var currWords = current.split(/\s+/).length;
var wordDiff = Math.abs(origWords - currWords);
// Require at least some meaningful change
if (wordDiff < 2 && original.length > 100) {
// Check character-level difference
var charDiff = Math.abs(original.length - current.length);
if (charDiff < 20) return; // too minor
}
// Find the most significant changed section (not full text)
var origSnippet = extractDiffSnippet(original, current);
var corrSnippet = extractDiffSnippet(current, original);
if (!origSnippet || !corrSnippet) {
origSnippet = original.substring(0, 500);
corrSnippet = current.substring(0, 500);
}
fetch('/api/memories/correction', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
section: section || 'encounter',
original_snippet: origSnippet,
corrected_snippet: corrSnippet
})
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && !data.skipped) {
console.log('[CorrectionTracker] Saved correction for', section);
}
}).catch(function() {});
// Clear so we don't re-save
delete _originals[elementId];
};
// Extract the most changed portion between two texts
function extractDiffSnippet(text1, text2) {
var lines1 = text1.split('\n');
var lines2 = text2.split('\n');
var changed = [];
var maxLen = Math.max(lines1.length, lines2.length);
for (var i = 0; i < maxLen; i++) {
var l1 = (lines1[i] || '').trim();
var l2 = (lines2[i] || '').trim();
if (l1 !== l2 && l1) {
changed.push(l1);
}
}
if (changed.length === 0) return null;
return changed.slice(0, 10).join('\n').substring(0, 1000);
}
console.log('Correction tracker loaded');
})();

524
public/js/ed-encounters.js Normal file
View file

@ -0,0 +1,524 @@
// ============================================================
// 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');
})();

View file

@ -95,7 +95,7 @@
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
(function() {
['encounter','dictation','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
['encounter','dictation','ed','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
try {
var id = sessionStorage.getItem('_savedEncId_' + t);
if (id) window['_savedEncId_' + t] = id;
@ -306,7 +306,8 @@
encounter: 'encounter', dictation: 'dictation',
hospital: 'hospital', chart: 'chart',
soap: 'soap', milestones: 'milestones',
wellvisit: 'wellvisit', sickvisit: 'sickvisit'
wellvisit: 'wellvisit', sickvisit: 'sickvisit',
ed: 'ed'
};
var tabName = tabMap[enc.enc_type] || 'encounter';
var tabBtn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
@ -443,12 +444,6 @@
};
var noteEl = document.getElementById(noteIdMap[type] || (prefix + '-hpi-text'));
// Save any corrections (Dragon-like learning) before saving encounter
var noteElId = noteIdMap[type] || (prefix + '-hpi-text');
if (typeof saveCorrection === 'function') {
saveCorrection(noteElId, type);
}
window.saveEncounter({
id: savedId,
label: label,

View file

@ -209,7 +209,6 @@
if (data.success) {
setOutputText(hpiText, data.hpi);
storeSourceContext('enc-hpi-text', transcript.innerText.trim());
if (typeof trackAIOutput === 'function') trackAIOutput('enc-hpi-text', data.hpi);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });

View file

@ -17,18 +17,10 @@
template_hpi: 'HPI Template',
template_wellvisit: 'Well Visit Template',
template_sickvisit: 'Sick Visit Template',
template_ed: 'ED Template',
custom: 'Custom'
};
// Correction categories (hidden from manual editing, managed by correction tracker)
var CORRECTION_LABELS = {
correction_soap: 'SOAP Correction',
correction_hpi: 'HPI Correction',
correction_encounter: 'Encounter Correction',
correction_wellvisit: 'Well Visit Correction',
correction_sickvisit: 'Sick Visit Correction'
};
// ── Load memories ────────────────────────────────────────────────────────
function loadMemories() {
@ -45,16 +37,11 @@
function renderMemoryList() {
var container = document.getElementById('mem-list');
if (!container) return;
// Separate templates from corrections
var templates = _memories.filter(function(m) { return !m.category.startsWith('correction_'); });
var corrections = _memories.filter(function(m) { return m.category.startsWith('correction_'); });
// Render corrections list
renderCorrectionsList(corrections);
if (templates.length === 0) {
if (_memories.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No templates saved yet. Add one above.</p>';
return;
}
container.innerHTML = templates.map(function(m) {
container.innerHTML = _memories.map(function(m) {
var catLabel = CATEGORY_LABELS[m.category] || m.category;
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
return '<div class="mem-item" data-id="' + m.id + '">' +
@ -78,75 +65,6 @@
});
}
function renderCorrectionsList(corrections) {
var container = document.getElementById('corrections-list');
if (!container) return;
if (corrections.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No corrections yet. Edit AI-generated notes and save to start learning.</p>';
return;
}
container.innerHTML = corrections.map(function(m) {
var catLabel = CORRECTION_LABELS[m.category] || m.category;
var preview = (m.name || '').replace(/\n/g, ' ');
// Parse original and corrected from content
var parts = parseCorrection(m.content || '');
var date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
return '<div class="mem-item" style="flex-direction:column;align-items:stretch;cursor:pointer;" data-id="' + m.id + '">' +
'<div style="display:flex;align-items:center;gap:8px;" class="correction-header" data-toggle="' + m.id + '">' +
'<i class="fas fa-chevron-right correction-arrow" id="arrow-' + m.id + '" style="font-size:10px;color:var(--g400);transition:transform 0.2s;"></i>' +
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
'<span style="flex:1;font-size:12px;color:var(--g600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(preview) + '</span>' +
'<span style="font-size:11px;color:var(--g400);flex-shrink:0;">' + date + '</span>' +
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
'</div>' +
'<div class="correction-detail hidden" id="detail-' + m.id + '" style="margin-top:8px;padding:8px 12px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.6;">' +
'<div style="margin-bottom:8px;">' +
'<div style="font-weight:600;color:var(--red);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Original (AI generated):</div>' +
'<div style="color:var(--g600);white-space:pre-wrap;font-family:inherit;">' + esc(parts.original) + '</div>' +
'</div>' +
'<div>' +
'<div style="font-weight:600;color:var(--green);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Corrected to:</div>' +
'<div style="color:var(--g800);white-space:pre-wrap;font-family:inherit;">' + esc(parts.corrected) + '</div>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
// Toggle expand/collapse
container.querySelectorAll('.correction-header').forEach(function(header) {
header.addEventListener('click', function(e) {
if (e.target.closest('.correction-delete-btn')) return;
var id = header.dataset.toggle;
var detail = document.getElementById('detail-' + id);
var arrow = document.getElementById('arrow-' + id);
if (detail) {
detail.classList.toggle('hidden');
if (arrow) arrow.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(90deg)';
}
});
});
container.querySelectorAll('.correction-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
deleteMemory(parseInt(btn.dataset.id));
});
});
}
function parseCorrection(content) {
var original = '';
var corrected = '';
var idx = content.indexOf('\nCORRECTED TO: ');
if (idx !== -1) {
original = content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '');
corrected = content.substring(idx + '\nCORRECTED TO: '.length);
} else {
original = content;
}
return { original: original.trim(), corrected: corrected.trim() };
}
// ── Save memory ──────────────────────────────────────────────────────────
function saveMemory() {

View file

@ -595,11 +595,13 @@
updateStatus(msg, 'err'); setRecUI('idle'); return;
}
updateStatus('Generating note…', 'saving');
var modelEl = document.getElementById('notes-model-select');
var selectedModel = modelEl ? modelEl.value : '';
return fetch('/api/notes/from-voice', {
method: 'POST',
credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
body: JSON.stringify({ transcript: resp.text }),
body: JSON.stringify({ transcript: resp.text, model: selectedModel || undefined }),
})
.then(function(r) { return r.json(); })
.then(function(data) {

View file

@ -911,7 +911,6 @@
var outCard = document.getElementById('wv-note-output');
var tag = document.getElementById('wv-note-model-tag');
if (noteEl) setOutputText(noteEl, data.note);
if (typeof trackAIOutput === 'function') trackAIOutput('wv-note-text', data.note);
// Store source for refine — collect all well visit inputs
var wvSource = [];
var wvTranscript = document.getElementById('wv-transcript');

View file

@ -333,7 +333,6 @@
var outCard = document.getElementById('sick-note-output');
var tag = document.getElementById('sick-note-model-tag');
if (noteEl) setOutputText(noteEl, data.note);
if (typeof trackAIOutput === 'function') trackAIOutput('sick-note-text', data.note);
var sickTranscript = document.getElementById('sick-transcript');
if (sickTranscript) storeSourceContext('sick-note-text', sickTranscript.innerText.trim());
if (tag) tag.textContent = (data.model || '').split('/').pop();

View file

@ -190,7 +190,6 @@
if (data.success) {
setOutputText(soapText, data.soap);
storeSourceContext('soap-text', transcript.innerText.trim());
if (typeof trackAIOutput === 'function') trackAIOutput('soap-text', data.soap);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });

View file

@ -183,7 +183,6 @@
if (data.success) {
setOutputText(hpiText, data.hpi || data.soap);
storeSourceContext('dict-hpi-text', transcript.innerText.trim());
if (typeof trackAIOutput === 'function') trackAIOutput('dict-hpi-text', data.hpi || data.soap);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });

View file

@ -102,7 +102,6 @@ const DYNAMIC_ID_PREFIXES = [
'nc-', // nextcloud settings
'mem-', // memories
'doc-', // documents
'corrections-', // corrections list
'audio-', // audio backups
'saved-', // saved encounters
'ext-', // extensions CRUD

View file

@ -297,6 +297,7 @@ app.use('/api', require('./src/routes/billing'));
app.use('/api/sessions', require('./src/routes/sessions'));
app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.use('/api', require('./src/routes/edEncounters'));
app.use('/api/user', require('./src/routes/userPreferences'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));

161
src/routes/edEncounters.js Normal file
View file

@ -0,0 +1,161 @@
// ============================================================
// ED ENCOUNTER ROUTE — multi-stage emergency department note
// generation with don't-miss tooltips and 2023 E/M MDM finalize.
//
// Two endpoints, both auth-gated:
// POST /api/ed-encounters/generate — per-stage note + tooltips
// POST /api/ed-encounters/finalize — MDM block for billing
//
// Stage state lives client-side (localStorage + draft row in
// saved_encounters via the existing encounters route). This file
// is purely AI plumbing — it does not own persistence.
// ============================================================
var express = require('express');
var router = express.Router();
var { callAI } = require('../utils/ai');
var PROMPTS = require('../utils/prompts');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
router.use(authMiddleware);
// Models sometimes wrap JSON in ```json fences or prepend prose. Strip the
// fence and recover the JSON object between the first { and last }.
function extractJson(raw) {
var t = String(raw || '').trim();
t = t.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
var s = t.indexOf('{');
if (s > 0) t = t.substring(s);
var e = t.lastIndexOf('}');
if (e > -1 && e < t.length - 1) t = t.substring(0, e + 1);
try { return JSON.parse(t); } catch (err) { return null; }
}
// ── POST /ed-encounters/generate — one stage of an ED encounter ─────
// Body:
// stage (1, 2, ...) — informational, drives "previous note" handling
// transcript — current stage's voice transcript (required)
// chiefComplaint — string, required
// patientAge — string, optional but strongly preferred
// patientGender — string, optional
// previousNote — string, only on stage > 1, the last generated note
// physicianMemories — string from /api/memories/context, optional
// model — admin/user-selected model id, optional
// Returns: { success, note, dontMiss: [{point, why}], model }
router.post('/ed-encounters/generate', async function (req, res) {
var start = Date.now();
try {
var {
stage, transcript, chiefComplaint,
patientAge, patientGender,
previousNote, physicianMemories, model
} = req.body;
if (!chiefComplaint || !chiefComplaint.trim()) {
return res.status(400).json({ error: 'Chief complaint is required' });
}
if (!transcript || !transcript.trim()) {
return res.status(400).json({ error: 'Transcript is required' });
}
var stageNum = parseInt(stage, 10) || 1;
var context = 'ED ENCOUNTER — STAGE ' + stageNum + '\n';
context += 'Patient: ' + (patientAge || 'Unknown age') + ', ' + (patientGender || 'Unknown gender') + '\n';
context += 'Chief Complaint: ' + wrapUserText('chief_complaint', chiefComplaint) + '\n\n';
context += 'CURRENT STAGE TRANSCRIPT (may include direct physician asides — preserve and route them per the prompt rules):\n'
+ wrapUserText('transcript', transcript) + '\n\n';
if (previousNote && previousNote.trim() && stageNum > 1) {
context += 'PREVIOUS-STAGE NOTE (baseline to integrate on top of — do not start fresh):\n'
+ wrapUserText('previous_note', previousNote) + '\n\n';
}
if (physicianMemories && physicianMemories.trim()) {
context += physicianMemories + '\n\n';
}
var result = await callAI([
{ role: 'system', content: PROMPTS.edEncounterStaged + INJECTION_GUARD },
{ role: 'user', content: context }
], { model: model, maxTokens: 4000 });
var parsed = extractJson(result.content);
if (!parsed || typeof parsed.note !== 'string') {
// Recovery: if the model returned plain prose, treat the whole response
// as the note and return an empty don't-miss list rather than 500-ing.
parsed = { note: String(result.content || '').trim(), dontMiss: [] };
}
if (!Array.isArray(parsed.dontMiss)) parsed.dontMiss = [];
parsed.dontMiss = parsed.dontMiss
.filter(function (d) { return d && d.point; })
.map(function (d) { return { point: String(d.point).trim(), why: String(d.why || '').trim() }; });
var dur = Date.now() - start;
logger.apiCall(req.user.id, '/api/ed-encounters/generate', {
model: result.model,
tokensInput: result.usage && result.usage.input_tokens,
tokensOutput: result.usage && result.usage.output_tokens,
duration: dur,
statusCode: 200
});
logger.audit(req.user.id, 'generate_ed_encounter', 'ED encounter stage ' + stageNum, req, { category: 'clinical' });
res.json({ success: true, note: parsed.note, dontMiss: parsed.dontMiss, model: result.model });
} catch (e) {
logger.error('[edEncounters] generate failed', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /ed-encounters/finalize — generate 2023 E/M MDM block ─────
// Body:
// note — final clinical note (required)
// transcript — concatenated transcript across all stages (optional but recommended)
// patientAge — string, optional
// model — optional override
// Returns: { success, mdm: {...}, model }
router.post('/ed-encounters/finalize', async function (req, res) {
var start = Date.now();
try {
var { note, transcript, patientAge, model } = req.body;
if (!note || !note.trim()) {
return res.status(400).json({ error: 'Final note is required' });
}
var context = 'PATIENT: ' + (patientAge || 'Unknown age') + '\n\n';
context += 'FINAL CLINICAL NOTE:\n' + wrapUserText('note', note) + '\n\n';
if (transcript && transcript.trim()) {
context += 'FULL ENCOUNTER TRANSCRIPT (all stages):\n' + wrapUserText('transcript', transcript) + '\n';
}
var result = await callAI([
{ role: 'system', content: PROMPTS.edFinalize + INJECTION_GUARD },
{ role: 'user', content: context }
], { model: model, maxTokens: 2000 });
var parsed = extractJson(result.content);
if (!parsed || !parsed.mdm) {
return res.status(502).json({ error: 'AI did not return a parseable MDM block', raw: result.content });
}
var dur = Date.now() - start;
logger.apiCall(req.user.id, '/api/ed-encounters/finalize', {
model: result.model,
tokensInput: result.usage && result.usage.input_tokens,
tokensOutput: result.usage && result.usage.output_tokens,
duration: dur,
statusCode: 200
});
logger.audit(req.user.id, 'finalize_ed_encounter', 'ED encounter MDM finalized', req, { category: 'clinical' });
res.json({ success: true, mdm: parsed.mdm, model: result.model });
} catch (e) {
logger.error('[edEncounters] finalize failed', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;

View file

@ -22,17 +22,17 @@ function decryptMemory(row) {
var VALID_CATEGORIES = [
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit',
'correction_soap', 'correction_hpi', 'correction_encounter', 'correction_wellvisit', 'correction_sickvisit'
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit', 'template_ed'
];
// ── GET all memories for current user ───────────────────────────────────
router.get('/memories', async function(req, res) {
try {
// Cannot ORDER BY encrypted `name`; order by id as a stable proxy after
// per-category sort. Frontend can re-sort alphabetically client-side.
// Legacy correction_* rows from the removed Dragon-style learning feature
// are filtered out — invisible to UI and to the AI context endpoint, but
// not dropped from the table.
var rows = await db.all(
'SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 ORDER BY category, id',
"SELECT id, category, name, content, created_at, updated_at FROM user_memories WHERE user_id = $1 AND category NOT LIKE 'correction_%' ORDER BY category, id",
[req.user.id]
);
rows.forEach(decryptMemory);
@ -93,89 +93,25 @@ router.delete('/memories/:id', async function(req, res) {
});
// ── GET memories as prompt context (for AI generation) ──────────────────
// Returns user templates concatenated as system-prompt context. Multiple
// templates per category are allowed; we order by id ASC so the newest
// row lands last in the prompt — models weight later context more heavily,
// which gives the most recently saved template implicit predominance.
router.get('/memories/context', async function(req, res) {
try {
var rows = await db.all(
'SELECT category, name, content FROM user_memories WHERE user_id = $1 ORDER BY category',
"SELECT category, name, content FROM user_memories WHERE user_id = $1 AND category NOT LIKE 'correction_%' ORDER BY category, id",
[req.user.id]
);
if (rows.length === 0) return res.json({ success: true, context: '' });
rows.forEach(decryptMemory);
var templates = [];
var corrections = [];
var context = '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
rows.forEach(function(r) {
if (r.category.startsWith('correction_')) corrections.push(r);
else templates.push(r);
context += '--- ' + r.category.toUpperCase().replace(/_/g, ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
});
var context = '';
if (templates.length > 0) {
context += '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
templates.forEach(function(r) {
context += '--- ' + r.category.toUpperCase().replace(/_/g, ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
});
}
if (corrections.length > 0) {
context += '\n\nSTYLE CORRECTIONS (these are examples of past edits — use only the writing style, never the clinical content):\n';
corrections.slice(-10).forEach(function(r) {
// Trim to just the key style difference, not full encounter text
var lines = r.content.split('\n').filter(function(l) { return l.trim(); });
var orig = '', corr = '';
var inCorrected = false;
lines.forEach(function(l) {
if (l.startsWith('CORRECTED TO:')) { inCorrected = true; corr = l.replace('CORRECTED TO:', '').trim(); }
else if (l.startsWith('ORIGINAL:')) { orig = l.replace('ORIGINAL:', '').trim(); }
else if (inCorrected) { corr += ' ' + l.trim(); }
else { orig += ' ' + l.trim(); }
});
// Keep only first 200 chars of each to avoid flooding the prompt
orig = orig.substring(0, 200);
corr = corr.substring(0, 200);
if (orig && corr) {
context += '- Before: "' + orig + '..."\n After: "' + corr + '..."\n';
}
});
}
res.json({ success: true, context: context.trim() });
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: 'Request failed' }); }
});
// ── POST auto-save correction (Dragon-like learning) ──────────────────
router.post('/memories/correction', async function(req, res) {
try {
var { section, original_snippet, corrected_snippet } = req.body;
if (!section || !original_snippet || !corrected_snippet) {
return res.status(400).json({ error: 'section, original_snippet, and corrected_snippet required' });
}
if (original_snippet.trim() === corrected_snippet.trim()) {
return res.json({ success: true, skipped: true });
}
var cat = 'correction_' + section;
if (!VALID_CATEGORIES.includes(cat)) cat = 'correction_encounter';
// Limit corrections per category: keep only latest 20
var existing = await db.all(
'SELECT id FROM user_memories WHERE user_id = $1 AND category = $2 ORDER BY created_at ASC',
[req.user.id, cat]
);
if (existing.length >= 20) {
// Delete oldest to make room
var toDelete = existing.slice(0, existing.length - 19);
for (var i = 0; i < toDelete.length; i++) {
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [toDelete[i].id, req.user.id]);
}
}
var name = original_snippet.substring(0, 60).replace(/\n/g, ' ') + '...';
var content = 'ORIGINAL: ' + original_snippet.substring(0, 2000) + '\nCORRECTED TO: ' + corrected_snippet.substring(0, 2000);
await db.run(
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
[req.user.id, cat, cryptoUtil.encryptString(name), cryptoUtil.encryptString(content)]
);
res.json({ success: true });
} catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: 'Request failed' }); }
});
module.exports = router;

View file

@ -227,27 +227,28 @@ router.post('/notes/from-voice', async function (req, res) {
if (!transcript) return res.status(400).json({ error: 'No transcript provided' });
var systemPrompt =
'You are a medical scribe turning a physician\'s dictated notes into a clean personal note.\n' +
'You turn a voice dictation into a clean, well-structured personal note.\n' +
'The note may be anything: a clinical observation, a shopping list, a reminder, a travel idea, a journal entry. Match the dictation — do not assume it is medical and do not impose clinical structure (assessment/plan, SOAP, etc.) on non-clinical content.\n' +
'Output STRICT JSON only — no preamble, no code fences, no commentary.\n' +
'Shape: {"title": "<short descriptive title, max 80 chars>", "body": "<HTML body>"}.\n' +
'The body must be HTML using only these tags: <p>, <h2>, <h3>, <strong>, <em>, <u>, <ul>, <ol>, <li>, <blockquote>, <code>, <a href="…">, <br>.\n' +
'Tone: concise clinical prose. Preserve all clinical facts the physician dictated — never invent, never drop.\n' +
'Tone follows the dictation: concise clinical prose if clinical, plain prose or a list if casual. Preserve every fact the speaker dictated — never invent, never drop.\n' +
'If the dictation is fragmentary, still produce a title and a clear body. Return pure JSON.' +
INJECTION_GUARD;
var userContent =
'Physician voice dictation to convert into a personal note:\n' +
'Voice dictation to convert into a personal note:\n' +
wrapUserText('dictation', transcript);
// Client never supplies a model — "admin decides" is the feature
// contract. Read the admin-configured default from settings; if
// it's not set (LiteLLM deployments with no explicit default),
// fall back to the LITELLM_DEFAULT_MODEL env var, then to the
// provider's built-in default. Passing an empty string to LiteLLM
// returns a 400, so we guard against that.
// Model: prefer the client's selection (validated against the admin
// allow-list inside callAI), fall back to the admin-configured default,
// then to the LITELLM_DEFAULT_MODEL env var. Passing an empty string
// to LiteLLM returns a 400, so we guard against that.
var requested = (req.body.model || '').trim();
var adminDefault = '';
try { adminDefault = await db.getSetting('models.default'); } catch (e) {}
var model = (adminDefault || process.env.LITELLM_DEFAULT_MODEL || '').trim();
var fallback = (adminDefault || process.env.LITELLM_DEFAULT_MODEL || '').trim();
var model = requested || fallback;
var opts = { maxTokens: 4000 };
if (model) opts.model = model;

View file

@ -375,7 +375,80 @@ Assessment and Plan:
- Follow-up instructions
- Return precautions
Format as a structured clinical note. Be factual. Do not fabricate clinical data.`
Format as a structured clinical note. Be factual. Do not fabricate clinical data.`,
// ======================== ED ENCOUNTER (multi-stage) ========================
edEncounterStaged: `You are an expert pediatric emergency-medicine physician documenting an ED encounter.
${CORE_RULES}
${ROS_PE_RULES}
OUTPUT FORMAT STRICT JSON ONLY, no preamble, no code fences, no commentary:
{
"note": "<plain-text clinical note>",
"dontMiss": [
{"point": "<short imperative — e.g. 'Document hydration status', 'Consider testicular torsion', 'Reassess after antipyretic'>", "why": "<one-line clinical rationale>"},
...
]
}
NOTE STRUCTURE (plain text, follow CORE RULES above NO markdown):
Chief Complaint:
History of Present Illness: (OLDCARTS, pertinent positives and negatives MENTIONED, historian noted)
Review of Systems: (per ROS/PE rules)
Physical Examination: (per ROS/PE rules)
ED Course: (interventions, response, evolution only when present in transcript)
Assessment and Plan: (problem-oriented; brief differential reasoning where dictated)
DON'T-MISS LIST:
- High-yield differentials, red flags, "must-document" items, and reassessments the physician may have skipped
- Tailor strictly to age + chief complaint (e.g. infant fever consider full sepsis workup; adolescent abdominal pain testicular torsion / pregnancy; head injury SBI screening / discharge precautions)
- No fixed limit. Quality over quantity. Each point must be actionable.
- Never repeat what is already clearly documented in the note.
PRESERVE INSTRUCTIONS WITHIN DICTATION (CRITICAL):
- The physician may include direct asides such as "include normal cardiac exam", "assessment is viral URI", "plan is discharge with strict precautions". Treat these as first-class clinical input route exam findings to PE, assessment statements to A&P, plan statements to A&P.
- Do NOT echo the asides as quoted speech. Integrate them as if the physician documented them directly.
- Do NOT discard them as filler.
PHYSICIAN TEMPLATES:
- The user's saved templates (especially any ED templates, but also matching HPI/SOAP/sickvisit templates) are provided as PHYSICIAN TEMPLATES AND PREFERENCES.
- Apply matching template sections where relevant. Newer templates take precedence over older ones for the same section.
- Never copy clinical content from a template only formatting and structure.
PREVIOUS-STAGE NOTE (only present on Stage 2+):
- The previous note is the established baseline. Integrate the new transcript on top revise sections, add new findings, refine A&P. Do NOT start fresh.
- Preserve the don't-miss items still relevant; drop items that have been addressed.`,
// ======================== ED FINALIZE — MDM ========================
edFinalize: `You are an expert pediatric emergency-medicine physician finalizing the medical decision-making (MDM) section for billing per the 2023 AMA E/M guidelines for emergency department visits (CPT 9928199285).
${CORE_RULES}
OUTPUT FORMAT STRICT JSON ONLY, no preamble, no code fences, no commentary:
{
"mdm": {
"problemsAddressed": "minimal|low|moderate|high",
"problemsNarrative": "<one short paragraph: number and complexity of problems addressed>",
"dataReviewed": "minimal|limited|moderate|extensive",
"dataNarrative": "<one short paragraph: tests ordered/reviewed, independent interpretation, external records, discussion with other professionals>",
"risk": "minimal|low|moderate|high",
"riskNarrative": "<one short paragraph: risk of complications, morbidity, mortality of patient management; medications considered or prescribed>",
"suggestedLevel": "99281|99282|99283|99284|99285",
"levelRationale": "<one sentence: which 2 of the 3 elements (problems, data, risk) drive this level>"
}
}
LEVEL MAPPING (2 of 3 elements must meet):
- 99281: minimal problems, minimal/no data, minimal risk
- 99282: low problems, limited data, low risk
- 99283: moderate problems, limited data, moderate risk
- 99284: moderate-to-high problems, moderate data, moderate-to-high risk (typical ED visit with workup)
- 99285: high problems, extensive data, high risk (life-threat, critical decision, admission for high-acuity care)
CRITICAL RULES:
- Use ONLY information present in the note and transcript
- Do NOT invent data points, interventions, or diagnoses
- Be conservative when ambiguous pick the lower level
- The levelRationale must reference specific elements actually documented in the encounter`
};
// ── DB override support ────────────────────────────────────────────────────