pediatric-ai-scribe-v3/src/routes/notes.js
Daniel f8e883e969 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-28 03:09:38 +02:00

304 lines
13 KiB
JavaScript

// ============================================================
// PERSONAL NOTES ROUTES — per-user scratchpad, rich-text body.
// Pure CRUD, auth-gated. Body + title encrypted at rest (same
// crypto helper as user_memories so a row dump stays useless
// without the app key).
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
var { callAI } = require('../utils/ai');
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
var { marked } = require('marked');
// Convert AI output to clean HTML for Tiptap. Tiptap's setContent
// only handles HTML — markdown comes through as literal text. Real-
// world models ignore "output HTML only" prompts ~25% of the time
// and return mixed markdown/HTML. We normalise both:
// • Body looks like HTML (has tags) → return as-is.
// • Body looks like markdown OR plain text → run through marked.
function toHtmlBody(s) {
if (!s) return '';
var t = String(s).trim();
// Strip ```html / ``` code fences a model sometimes wraps the body in.
t = t.replace(/^```(?:html|markdown|md)?\s*/i, '').replace(/\s*```$/i, '');
// Already HTML if there's a real tag.
if (/<(p|div|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|code|pre|br|hr|span)[\s>]/i.test(t)) return t;
// Markdown → HTML. marked handles plain text fine — paragraphs become <p>.
try { return marked.parse(t, { breaks: true }); }
catch (e) {
// Last-ditch: wrap the raw text in <p> so the editor at least shows it.
return '<p>' + t.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>';
}
}
router.use(authMiddleware);
var MAX_TITLE = 200;
var MAX_BODY = 50000; // 50 KB of rich-text HTML is plenty for a clinical note
var MAX_NOTES_PER_USER = 500;
function decryptRow(row) {
if (!row) return row;
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
try { row.body = cryptoUtil.decryptString(row.body); } catch (e) {}
return row;
}
// ── GET list (active only — items in trash are filtered out) ────
router.get('/notes', async function (req, res) {
try {
var rows = await db.all(
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC',
[req.user.id]
);
rows.forEach(decryptRow);
res.json({ success: true, notes: rows });
} catch (e) {
logger.error('GET /notes', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── GET trash list — items the user soft-deleted, newest-deleted first ──
router.get('/notes/trash', async function (req, res) {
try {
var rows = await db.all(
'SELECT id, title, body, created_at, updated_at, deleted_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
[req.user.id]
);
rows.forEach(decryptRow);
res.json({ success: true, notes: rows });
} catch (e) {
logger.error('GET /notes/trash', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── GET one ─────────────────────────────────────────────────
router.get('/notes/:id', async function (req, res) {
try {
var row = await db.get(
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Note not found' });
res.json({ success: true, note: decryptRow(row) });
} catch (e) {
logger.error('GET /notes/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST create ─────────────────────────────────────────────
router.post('/notes', async function (req, res) {
try {
var title = (req.body.title || '').trim();
var body = (req.body.body || '').trim();
if (!title) return res.status(400).json({ error: 'Title required' });
var count = await db.get('SELECT COUNT(*) as cnt FROM personal_notes WHERE user_id = $1', [req.user.id]);
if (count && parseInt(count.cnt) >= MAX_NOTES_PER_USER) {
return res.status(400).json({ error: 'Maximum ' + MAX_NOTES_PER_USER + ' notes per user' });
}
var result = await db.run(
'INSERT INTO personal_notes (user_id, title, body) VALUES ($1, $2, $3) RETURNING id',
[
req.user.id,
cryptoUtil.encryptString(title.substring(0, MAX_TITLE)),
cryptoUtil.encryptString(body.substring(0, MAX_BODY)),
]
);
res.json({ success: true, id: result.lastInsertRowid });
logger.audit(req.user.id, 'create_note', 'Created personal note', req, { category: 'notes' });
} catch (e) {
logger.error('POST /notes', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── PUT update ──────────────────────────────────────────────
router.put('/notes/:id', async function (req, res) {
try {
var title = (req.body.title || '').trim();
var body = (req.body.body || '').trim();
if (!title) return res.status(400).json({ error: 'Title required' });
var existing = await db.get('SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Note not found' });
await db.run(
'UPDATE personal_notes SET title = $1, body = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4',
[
cryptoUtil.encryptString(title.substring(0, MAX_TITLE)),
cryptoUtil.encryptString(body.substring(0, MAX_BODY)),
req.params.id,
req.user.id,
]
);
res.json({ success: true });
} catch (e) {
logger.error('PUT /notes/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── DELETE = soft-delete (move to trash) ───────────────────────
// Sets deleted_at = NOW(); the row stays in personal_notes but is
// filtered out of the active list. Use POST /notes/:id/restore to
// undo, or DELETE /notes/:id?hard=1 to actually remove.
router.delete('/notes/:id', async function (req, res) {
try {
var hard = req.query.hard === '1' || req.query.hard === 'true';
if (hard) {
// Hard-delete only items already in trash, so a UI bug can't
// accidentally erase an active note.
await db.run(
'DELETE FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
[req.params.id, req.user.id]
);
logger.audit(req.user.id, 'delete_note_hard', 'Permanently deleted personal note', req, { category: 'notes' });
} else {
await db.run(
'UPDATE personal_notes SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL',
[req.params.id, req.user.id]
);
logger.audit(req.user.id, 'delete_note', 'Moved personal note to trash', req, { category: 'notes' });
}
res.json({ success: true });
} catch (e) {
logger.error('DELETE /notes/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /notes/:id/restore — pull a note out of trash ─────────
router.post('/notes/:id/restore', async function (req, res) {
try {
var existing = await db.get(
'SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
[req.params.id, req.user.id]
);
if (!existing) return res.status(404).json({ error: 'Note not in trash' });
await db.run(
'UPDATE personal_notes SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
res.json({ success: true });
logger.audit(req.user.id, 'restore_note', 'Restored personal note from trash', req, { category: 'notes' });
} catch (e) {
logger.error('POST /notes/:id/restore', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /notes/trash/empty — hard-delete every trashed note for the user ─
router.post('/notes/trash/empty', async function (req, res) {
try {
var result = await db.run(
'DELETE FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL',
[req.user.id]
);
res.json({ success: true, removed: result.changes || 0 });
logger.audit(req.user.id, 'empty_notes_trash', 'Emptied notes trash', req, { category: 'notes' });
} catch (e) {
logger.error('POST /notes/trash/empty', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /api/notes/from-voice ──────────────────────────────
// Takes a raw voice transcript (produced by the shared
// /api/transcribe endpoint — whichever STT provider the admin
// configured, not user-selectable) and asks the AI to produce
// a clean, well-structured personal note: one short title
// followed by rich-text body HTML. Returned as-is to the client
// which drops it straight into the editor. The client stays
// in control of Save — this endpoint never touches the DB.
router.post('/notes/from-voice', async function (req, res) {
try {
var transcript = (req.body.transcript || '').trim();
if (!transcript) return res.status(400).json({ error: 'No transcript provided' });
var systemPrompt =
'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 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 =
'Voice dictation to convert into a personal note:\n' +
wrapUserText('dictation', transcript);
// 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 fallback = (adminDefault || process.env.LITELLM_DEFAULT_MODEL || '').trim();
var model = requested || fallback;
var opts = { maxTokens: 4000 };
if (model) opts.model = model;
var result = await callAI([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
], opts);
// Models sometimes wrap JSON in ```json fences, prepend "Here you go:",
// or close the JSON early. Strip leading prose and any trailing text
// after the last } to recover.
var raw = (result.content || '').trim();
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
var jsonStart = raw.indexOf('{');
if (jsonStart > 0) raw = raw.substring(jsonStart);
var jsonEnd = raw.lastIndexOf('}');
if (jsonEnd > -1 && jsonEnd < raw.length - 1) raw = raw.substring(0, jsonEnd + 1);
var parsed = null;
try { parsed = JSON.parse(raw); } catch (e) { /* fallback below */ }
var title, body;
if (parsed && (parsed.title || parsed.body)) {
title = String(parsed.title || '').trim();
body = toHtmlBody(parsed.body || '');
} else {
// Model didn't cooperate at all — treat the whole response as the
// body and derive a title from the first sentence of the transcript.
title = transcript.split(/[.\n]/)[0].trim().substring(0, 80) || 'Voice note';
body = toHtmlBody(result.content || transcript);
}
// Final guards: never send back an empty body — at minimum show the
// raw transcript wrapped in <p> so the user can edit it manually.
if (!body || !body.replace(/<[^>]+>/g, '').trim()) {
body = '<p>' + String(transcript).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</p>';
}
if (!title) title = 'Voice note';
res.json({
success: true,
title: title.substring(0, 200),
body: body,
model: result.model,
});
logger.audit(req.user.id, 'note_from_voice', 'AI-generated note from voice', req, { category: 'notes' });
} catch (err) {
logger.error('POST /notes/from-voice', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;