diff --git a/public/components/ed-encounter.html b/public/components/ed-encounter.html new file mode 100644 index 0000000..17a6bca --- /dev/null +++ b/public/components/ed-encounter.html @@ -0,0 +1,99 @@ +
+

ED Encounter

+

Multi-stage emergency note: dictate → review → optionally add more → finalize with MDM.

+
+ + +
+
+
+ + +
+ Stage 1 + + + +
+ +
+ + +
+

Patient Info

+
+
+
+
+
+
+
+ + +
+
+

Stage 1 Recording / Dictation

+
+ + + +
+
+
+
+
+
+ + + + + + + + + + + diff --git a/public/components/faq.html b/public/components/faq.html index 2aa948f..380b559 100644 --- a/public/components/faq.html +++ b/public/components/faq.html @@ -67,21 +67,6 @@ -
- -
-

Yes. The app uses a correction tracking system inspired by Dragon Medical's adaptive learning. Here is how it works:

-
    -
  1. When the AI generates a note, the original output is stored in memory
  2. -
  3. You edit the note to match your preferred style — fix phrasing, add details, restructure sections
  4. -
  5. When you click Save, the app detects what you changed and stores the correction
  6. -
  7. On future notes, your past corrections are included as style hints so the AI adapts to your documentation preferences
  8. -
-

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 Settings > AI Corrections.

-

Note: Corrections are applied as gentle suggestions, not strict rules. The AI prioritizes clinical accuracy over style matching.

-
-
-
diff --git a/public/components/notes.html b/public/components/notes.html index be7b0b7..c688180 100644 --- a/public/components/notes.html +++ b/public/components/notes.html @@ -77,6 +77,7 @@ STT provider, then asks the AI to produce a clean note. Pause/Resume available natively through MediaRecorder. -->
+ diff --git a/public/components/settings.html b/public/components/settings.html index b4cd898..ecd25ef 100644 --- a/public/components/settings.html +++ b/public/components/settings.html @@ -190,6 +190,7 @@ + @@ -219,15 +220,6 @@
- -
-

AI Learning (Corrections)

-

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.

-
-

Loading corrections...

-
-
-

Audio Backups

diff --git a/public/index.html b/public/index.html index f263044..ea19b26 100644 --- a/public/index.html +++ b/public/index.html @@ -202,6 +202,10 @@ Dictation HPI + ' + - '
' + - '' + - '
'; - }).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() { diff --git a/public/js/notes.js b/public/js/notes.js index 04a6c32..d823f0a 100644 --- a/public/js/notes.js +++ b/public/js/notes.js @@ -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) { diff --git a/public/js/shadess.js b/public/js/shadess.js index 72e3e76..c094674 100644 --- a/public/js/shadess.js +++ b/public/js/shadess.js @@ -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'); diff --git a/public/js/sickVisit.js b/public/js/sickVisit.js index 00c2aa5..660b3c5 100644 --- a/public/js/sickVisit.js +++ b/public/js/sickVisit.js @@ -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(); diff --git a/public/js/soap.js b/public/js/soap.js index eb69ec3..4f89838 100644 --- a/public/js/soap.js +++ b/public/js/soap.js @@ -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' }); diff --git a/public/js/voiceDictation.js b/public/js/voiceDictation.js index faa1b53..980c76c 100644 --- a/public/js/voiceDictation.js +++ b/public/js/voiceDictation.js @@ -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' }); diff --git a/scripts/lint-references.js b/scripts/lint-references.js index 4eeb61e..d572237 100755 --- a/scripts/lint-references.js +++ b/scripts/lint-references.js @@ -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 diff --git a/server.js b/server.js index 2dce218..d05152c 100644 --- a/server.js +++ b/server.js @@ -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')); diff --git a/src/routes/edEncounters.js b/src/routes/edEncounters.js new file mode 100644 index 0000000..42257b3 --- /dev/null +++ b/src/routes/edEncounters.js @@ -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; diff --git a/src/routes/memories.js b/src/routes/memories.js index ed5c321..503f6d1 100644 --- a/src/routes/memories.js +++ b/src/routes/memories.js @@ -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; diff --git a/src/routes/notes.js b/src/routes/notes.js index a46b433..dc45afa 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -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": "", "body": ""}.\n' + 'The body must be HTML using only these tags:

,

,

, , , ,