pediatric-ai-scribe-v3/docs/logic/clinical-notes.md
Daniel 503f5afaad feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs
Three concurrent themes from this session:

═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════

UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):

- Each generated stage stays on screen as its own editable card with
  its own embedded "Don't Miss" panel. No more single rolling note
  element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
  before any operation (advance, finalize, persist) so inline edits
  flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
  background after Add-more before generation; "Stage N" with gray
  after generation. Fixes the bug where the badge flipped to Stage 2
  the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
  1. edConsolidate (new prompt) → polished single final note that
     integrates every stage chronologically (HPI / ROS / PE / ED Course /
     A&P with disposition).
  2. edFinalize (rewritten with full inline 2023 AMA E/M element
     rubric — problems / data / risk definitions, level mapping with
     concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
  Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
  finalized} so resume re-renders the full state.

Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.

Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).

═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════

Multiple iterations based on Daniel's feedback:

- Layout: align-items:flex-start so action buttons stay pinned top-right
  when long numbers wrap (was align-items:center → buttons drifted into
  the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
  so long numbers wrap within their column instead of pushing under the
  buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
  at-a-glance type signal (replacing an earlier 3px left stripe that
  Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
  -0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
  list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
  55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
  at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.

Files: public/js/extensions.js, public/css/styles.css.

═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════

Document moves (preserving git history via git mv):
  BROWSER_WHISPER_SETUP.md          → docs/browser-whisper-setup.md
  BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
  DEVELOPER_GUIDE.md                → docs/developer-guide-extended.md
  EMBEDDINGS_SETUP.md               → docs/embeddings-setup.md
  FEATURES_EXPLAINED.md             → docs/features-explained.md
  IMPROVEMENTS.md                   → docs/improvements.md
  OPENID_SETUP.md                   → docs/openid-setup.md
  TRANSCRIPTION_OPTIONS.md          → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.

New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.

  docs/logic/README.md                — index + recommended reading order
  docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
                                         load, backend route convention,
                                         schema, encryption, deployment
  docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
  docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
                                         pocket + calculators + PE Guide
                                         + suture selector
  docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
                                         admin panel + Learning Hub
                                         (Quiz engine logic at sub-detail
                                         only — TODO follow-up)
  docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
                                         prompts, voice/STT, helper trio
  docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
                                         session's worked example)

Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
  the tree as JSON; /file?path=X validates path stays inside docs/ and
  renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
  revealed in auth.js when user.role==='admin' (same pattern as the
  existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
  with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
  activation, renders collapsible folders, persists expanded state and
  last-opened path via UIState. Server-rendered HTML so no client
  markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
  scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
  with router.use(authMiddleware) at /api accidentally 401s every other
  /api/* path (caught and fixed during testing — /api/health was 401'ing).

Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).

═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════

- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
  tracking + progress dashboard) is covered at the architectural level
  in docs/logic/auth-admin-learning.md but not drilled into the quiz
  data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
  the consolidated finalNote in the error payload, but client doesn't
  surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
2026-04-28 03:09:38 +02:00

1546 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Clinical notes — application logic
This doc covers every "note-producing" tab in the ped-ai app: how each
one collects input, what AI prompt it uses, which endpoint it hits, what
DOM elements it touches, and how the shared save/resume infrastructure
ties them together.
It is written for a developer who has never opened the codebase. File
paths are absolute. Where a fact depends on a specific line, the line
number is named so you can jump straight to it.
The covered tabs are:
- **Encounter HPI** — live mic recording → HPI
- **Dictation HPI** — physician dictation → HPI
- **Sick Visit** — chief complaint + auto-inferred ROS/PE + dictation → sick visit note
- **Well Visit** — multi-pane Bright Futures workflow (by-visit, milestones, SSHADESS, visit note)
- **SOAP** — transcript or dictation → SOAP note (full or subjective only)
- **Hospital Course** — pasted progress notes / H&P / ED note → admission summary
- **Chart Review** — pasted outpatient / subspecialty / ED notes → AI summary
- **Personal Notes** — free-form rich-text editor with optional voice → AI cleanup
ED Encounter is referenced in passing because it shares the recorder and
the `enc_type='ed'` save path, but it has its own separate logic doc and
is not the focus here.
---
## 1. Overview
Each clinical-notes tab is independent: its own DOM IDs, its own JS
module (an IIFE), its own `/api/...` endpoint, and its own AI prompt.
But every tab sits on top of the same scaffolding:
1. A small header bar with patient demographics (age, gender, sometimes
setting / chief complaint / PMH).
2. A **save bar** at the top with `Save / Load / New` buttons and a
patient-label input.
3. A **recorder** (most tabs) that writes into a `contenteditable`
transcript box.
4. A **Generate** button that POSTs the assembled context to a route in
`src/routes/`.
5. An **output card** that hides until generation succeeds, then
reveals the AI's note plus a `Refine` and `Shorten` bar.
6. After generation, a few of the tabs auto-call **billing-codes** and
**don't-miss** helpers that mount additional cards beneath the note.
Why a tab per note type?
Each note type has different inputs (e.g. Sick Visit needs CC + ROS +
PE, Hospital Course needs many dated notes + H&P + labs, Personal Notes
needs neither demographics nor a transcript), and a different AI system
prompt. A "one-size note generator" would have an unwieldy form and a
prompt full of conditionals. Splitting them gives each tab a focused UI
and a clean prompt.
The shared lifecycle — recorder → transcribe → generate → save — is the
same skeleton in every recording tab; only the per-tab payload, prompt,
and post-generation UI differ.
---
## 2. The shared lifecycle
### 2.1 Lazy tab loading
`public/js/app.js` (lines 26104) implements the SPA tab system. The
sidebar buttons are `<button class="tab-btn" data-tab="encounter">…`
and the tab panes are `<section id="encounter-tab" class="tab-content"
data-component="encounter"></section>`. When the user clicks a tab,
`activateTab(name)`:
1. Removes `.active` from every tab button and pane.
2. Adds `.active` to the clicked one.
3. Calls `loadComponent(tabEl)` which `fetch('/components/' +
component + '.html')`s the per-tab HTML fragment, caches it in
`_componentCache`, and writes it into `tabEl.innerHTML`.
4. After the fragment is in the DOM, dispatches a `tabChanged`
`CustomEvent` with `detail: { tab: tabName }`.
5. Persists the last opened tab to `localStorage['ped_last_tab']`.
Each tab's JS module listens for `tabChanged` and runs its `init`
exactly once:
```
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'encounter' || _inited) return;
_inited = true;
// wire DOM, attach listeners
});
```
This is why every JS module starts with the same one-shot guard —
without it, hot-loading the same tab twice would double-wire all the
buttons.
### 2.2 The recorder
`AudioRecorder` is defined in `public/js/app.js:659683`:
- `start()` calls `getUserMedia({ audio: { channelCount: 1, sampleRate:
16000, echoCancellation: true, noiseSuppression: true } })`, picks
`audio/webm;codecs=opus` (fallback `audio/webm`), runs at 32 kbps
Opus, and pushes 1-second chunks into `this.chunks`.
- `stop()` finalises a `Blob`, stops the mic tracks, and resolves the
Blob.
Each recording tab (Encounter, Dictation, Sick, Well-Visit, SHADESS,
SOAP) holds **its own** `AudioRecorder` instance. They also each
instantiate `createSpeechRecognition()` (`app.js:1007`) — the browser's
`webkitSpeechRecognition` if available — for live preview text while
the recorder buffers audio.
The browser's interim transcript is shown greyed-out as the user talks;
when the user stops, the buffered audio is uploaded to the server for a
proper transcription. Two reasons for the dual-track approach:
- The browser's STT is good enough to give live feedback so the
physician knows the mic is working.
- Server STT (Whisper / Gemini / Amazon Transcribe Medical / etc.) is
much more accurate, especially for medical jargon, but only returns
after the recording ends.
If the recording exceeds 24 MB, the audio upload is skipped and the
browser-STT transcript is used as-is (e.g. `liveEncounter.js:101`).
Each tab also dispatches `recording-started` and `recording-stopped`
`CustomEvent`s with `detail.module = 'enc' | 'dict' | 'soap' | 'sick'`
so other modules (currently encounters.js' pause-button helper) can
react.
### 2.3 The transcribe call
`transcribeAudio(blob)` in `public/js/app.js:739795` is the single
entry point for STT:
- If browser-Whisper is enabled in user settings (and supported), it
runs locally — no network call, no PHI leaves the device.
- Otherwise it POSTs the Blob to `/api/transcribe` (handled by
`src/routes/transcribe.js` — out of scope here). The server picks
whichever provider the admin configured: Gemini, AWS Transcribe
Medical, OpenAI Whisper, Local Whisper, or LiteLLM.
- On any failure with a non-empty Blob, the audio is auto-saved via
`saveAudioBackup(blob, 'failed-transcription')` (`audioBackup.js`)
so the user can retry from the Settings page.
A startup probe (`checkTranscribeStatus`, `app.js:722`) queries
`/api/transcribe/status` and stores the result in
`window._transcribeAvailable`. If the server reports `false`, every tab
short-circuits the upload path and just uses the live-browser
transcript instead.
### 2.4 The generate call
Every tab assembles a payload and POSTs it to its own endpoint. The
shared parts:
- Demographics (`patientAge`, `patientGender`, sometimes `setting`).
- The transcript or dictation text (read from the contenteditable
`<div>` via `.innerText`).
- An optional `physicianMemories` blob, fetched ahead of the call via
`getUserMemoryContext()` (`memories.js:166`) which hits
`/api/memories/context`.
- A `model` string from `getSelectedModel()` or the per-tab
`<select class="tab-model-select">`.
The route handler builds the system + user messages and calls
`callAI()` (`src/utils/ai.js`, multi-provider router). Every route also
runs the user-derived text through `wrapUserText(label, text)` from
`src/utils/promptSafe.js`, which wraps it in
`<UNTRUSTED_LABEL>…</UNTRUSTED_LABEL>` and appends the standard
`INJECTION_GUARD` so the model treats the text as data, not
instructions.
The result is rendered into the output `<div>` via `setOutputText(el,
text)` (`app.js:342`). The model name is shown in a small `model-tag`
chip beside the output card.
### 2.5 Post-generation helpers
After the note is rendered, several tabs trigger optional follow-ups:
- `storeSourceContext(outputId, sourceText)` — stashes the original
transcript onto `outputEl.dataset.sourceContext` so the Refine call
can reference the original facts (not just the AI's rewrite).
- `suggestBillingCodes(outputId, noteText, noteType, age, visitType)`
(`app.js:804`) — POSTs to `/api/suggest-codes` and mounts a
`.billing-codes-card` with ICD-10 + CPT chips.
- `suggestDontMiss(outputId, noteText, noteType, age, cc)`
(`app.js:894`) — POSTs to `/api/dont-miss` and mounts an
orange-bordered card with up to 5 high-yield "don't miss" items.
Refine and Shorten use the same pattern: `refineDocument(outputId,
inputId)` (`app.js:946`) and `shortenDocument(outputId)` (`app.js:971`)
read the output `<div>`'s `.innerText`, send it to `/api/refine` or
`/api/shorten`, and replace the output's content.
### 2.6 The save flow
Every tab (except Personal Notes, which has its own CRUD) wires its
Save button to `window.saveEncounter()` exposed by
`public/js/encounters.js:113`. The flow:
1. User types a `label` (e.g. "John D, 5y fever") in the tab's
`*-label` input.
2. Save button click calls `saveFromTab(type, prefix)` in
`encounters.js:431`, which reads the transcript and the generated
note, attaches the cached `_savedEncId_<type>` (so updates hit the
same row), generates / reuses an `idempotency_key` from
sessionStorage, and POSTs `/api/encounters/saved`.
3. The server (`src/routes/encounters.js`) encrypts every text field
with the AES-256-GCM helper, applies optimistic versioning, and
returns the row ID + new version.
4. Client stashes the ID in both `window._savedEncId_<type>` and
`sessionStorage['_savedEncId_' + type]` so a page refresh won't
create a duplicate row.
Resume works in reverse: the load popover hits `/api/encounters/saved`
to list, then `/api/encounters/saved/:id` for the full row. The client
navigates to the right tab via `activateTab()`, then calls the
tab-specific load handler registered through
`registerEncounterLoadHandler(type, fn)`.
### 2.7 End-to-end example: Sick Visit
To make this concrete, here is one full Sick Visit cycle, with the
files and lines involved:
1. **User clicks Sick Visit tab.**
`app.js:97``activateTab('sickvisit')`
`loadComponent(tabEl)` fetches
`/components/sickvisit.html` → injects HTML → fires `tabChanged`.
`sickVisit.js:439` catches the event, runs `initRecording()`,
`populateAddDropdowns()`, `renderRosPe()`, `initDx()`.
2. **User types the chief complaint** (`#sick-cc`) and clicks
`#btn-sick-infer`. `inferAndRender()` (`sickVisit.js:272`) walks
the `INFERENCE_RULES` array (lines 4980) — e.g. "fever" matches
`['constitutional', 'ent', 'respiratory', 'gastrointestinal']`
ROS systems and 7 PE systems — and re-renders the rows.
3. **User clicks "Listen In"** (`#sick-record-btn`). The handler at
`sickVisit.js:155` instantiates a fresh `AudioRecorder`, starts
the browser `SpeechRecognition`, and shows the live transcript in
`#sick-transcript`. The `pulse-dot` indicator goes red and the
`00:00` timer starts.
4. **User clicks "Stop"**. `sickVisit.js:170` stops the recorder,
gets the Blob, calls `transcribeAudio(blob)`. On success, the
server transcript replaces the live one in `#sick-transcript`.
5. **User clicks each ROS row's WNL/Abnormal button.** The shared
`wireRosContainer()` (`shadess.js:460`) is what handles these —
it lives in the SHADESS module because both sick visit and well
visit share the row-renderer pair `renderRosRows` /
`wireRosContainer` (both exposed on `window`).
6. **User clicks Generate.** `generateSickNote()` (`sickVisit.js:292`)
reads everything: transcript, ROS data, PE data, selected
diagnoses (via `formatDxForAI` in `shadess.js`), picks up the
user's saved templates from `getUserMemoryContext()`, and POSTs
`/api/sick-visit/note`.
7. **Server route** (`src/routes/sickVisit.js:14`) builds the user
message:
- `Patient: 5 years, Female`
- `Chief Complaint: <UNTRUSTED_CHIEF_COMPLAINT>fever</UNTRUSTED_CHIEF_COMPLAINT>`
- `ENCOUNTER TRANSCRIPT/DICTATION: <UNTRUSTED_TRANSCRIPT>…</UNTRUSTED_TRANSCRIPT>`
- `<UNTRUSTED_ROS>…</UNTRUSTED_ROS>`
- `<UNTRUSTED_PHYSICAL_EXAM>…</UNTRUSTED_PHYSICAL_EXAM>`
- `<UNTRUSTED_DIAGNOSES>…</UNTRUSTED_DIAGNOSES>`
- And, if present, a low-priority STYLE HINTS block from the
user's memories.
System prompt = `PROMPTS.sickVisitNote + INJECTION_GUARD`. Calls
`callAI()`. Logs an audit row.
8. **Client gets the response.** `sickVisit.js:329`:
- `setOutputText(noteEl, data.note)` writes the note into
`#sick-note-text`.
- `storeSourceContext('sick-note-text', transcript)` saves the
transcript on the dataset for future Refine calls.
- `outputCard.classList.remove('hidden')` reveals the card.
- `suggestBillingCodes('sick-note-text', data.note, 'sickvisit',
age)` mounts the billing card.
- `suggestDontMiss('sick-note-text', data.note, 'sickvisit', age,
cc)` mounts the don't-miss card.
9. **User types a label** (`#sick-label = "John D 5y fever"`) and
clicks Save. `sickVisit.js:401` calls
`saveEncounter({id, label, enc_type: 'sickvisit',
transcript, generated_note, onSaved})`.
Server encrypts and stores; client caches the ID in
`_savedEncId_sickvisit`.
10. **User comes back tomorrow**, clicks Load, picks the saved entry.
`encounters.js:287 resumeEncounter(id, 'sickvisit')` fetches the
row, navigates to the Sick Visit tab, runs the registered load
handler at `sickVisit.js:420` which restores transcript and note.
That's the full cycle. Every other tab does a variant of the same
sequence — the differences are listed below.
---
## 3. Per-tab deep dives
### 3.1 Encounter HPI — `liveEncounter.js`
**File:** `public/js/liveEncounter.js`
**Component:** `public/components/encounter.html`
**Tab name:** `encounter`
**Endpoint:** `POST /api/generate-hpi-encounter`
`src/routes/hpi.js:10`
**Prompt:** `PROMPTS.hpiEncounter` (or `PROMPTS.hpiInpatient` if
`setting === 'inpatient'`) — both at `src/utils/prompts.js:53` and
`:70`.
**Purpose:** Live recording of a doctor-patient conversation. The AI
extracts an OLDCARTS HPI from the dialogue.
**User flow:**
1. Type age / gender / setting.
2. Click "Start Recording" — mic engages, live STT begins.
3. Use the visible Pause / Stop buttons mid-encounter as needed
(Stop is available even when the record button is hidden — see
`liveEncounter.js:119` for the dedicated stop path).
4. On stop, the audio is uploaded to `/api/transcribe`; the server
transcript replaces the live one.
5. Click Generate → AI produces an HPI, mounted in `#enc-output`.
**DOM IDs:**
| Element | ID |
|---|---|
| Age / gender / setting | `enc-age`, `enc-gender`, `enc-setting` |
| Record / pause / stop | `enc-record-btn`, `enc-pause-btn`, `enc-stop-btn` |
| Recording indicator + timer | `enc-recording-indicator`, `enc-timer` |
| Transcript (contenteditable) | `enc-transcript` |
| Clear / Generate | `enc-clear`, `enc-generate-btn` |
| Output card / text / model tag | `enc-output`, `enc-hpi-text`, `enc-model-tag` |
| Save bar | `enc-label`, `btn-enc-save`, `btn-enc-load`, `btn-enc-new` |
| Refine / shorten | `enc-refine-input`, `enc-refine-btn`, `enc-shorten-btn` |
**Quirks:**
- Uses `nativeHaptic`, `nativeKeepAwake`, `nativeStartRecordingService`
if running in the Capacitor app (lines 6971). Web sessions skip
these.
- The dedicated Stop button is visible only while recording; the main
record button is hidden during recording so the user can't double-
trigger the stop flow. Stop just `recordBtn.click()`s under the hood
(line 124).
- After generation: calls `suggestBillingCodes` and `suggestDontMiss`
(lines 216217), unlike Dictation HPI which doesn't.
- `partial_data` is round-tripped through JSON in the load handler
(line 235) to restore age + gender after resume.
**On save:** `enc_type='encounter'`, transcript = `#enc-transcript`,
generated note = `#enc-hpi-text`. Uses the generic `saveFromTab`
plumbing in `encounters.js:431`.
### 3.2 Dictation HPI — `voiceDictation.js`
**File:** `public/js/voiceDictation.js`
**Component:** `public/components/dictation.html`
**Tab name:** `dictation`
**Endpoint:** `POST /api/generate-hpi-dictation` (or
`/api/generate-soap` if user picks SOAP output) →
`src/routes/hpi.js:35` and `src/routes/soap.js:9`.
**Prompt:** `PROMPTS.hpiDictation` (`prompts.js:62`) or
`PROMPTS.hpiInpatient` if setting=inpatient. If user picks
`soap-full` / `soap-subjective`, switches to `PROMPTS.soapFull` /
`PROMPTS.soapSubjective`.
**Purpose:** The physician dictates a narrative monologue (no patient
present). AI cleans + restructures it into a polished HPI (or SOAP
note).
**User flow:**
Same shape as Encounter HPI, but the user is dictating in their own
voice. The output-type select at `#dict-output-type` lets them pick
HPI, full SOAP, or subjective-only — the Generate handler branches the
endpoint at `voiceDictation.js:157`.
**DOM IDs:** Mirror the Encounter tab with `dict-` prefix:
`dict-record-btn`, `dict-pause-btn`, `dict-transcript`,
`dict-generate-btn`, `dict-output`, `dict-hpi-text`, `dict-age`,
`dict-gender`, `dict-setting`, `dict-output-type`, `dict-label`, etc.
**Quirks:**
- No dedicated Stop button (unlike Encounter / SOAP). Stop is the
same record button toggling state.
- After generation does NOT call `suggestBillingCodes` or
`suggestDontMiss` — dictation HPIs are intentionally lean.
- The HPI prompt is a different system prompt from the Encounter HPI
one. `hpiEncounter` says "Generate a professional HPI from the
doctor-patient encounter transcript", while `hpiDictation` says
"Restructure physician dictation into a polished HPI" — the latter
is more aggressive about reorganising and polishing.
**On save:** `enc_type='dictation'`, transcript = `#dict-transcript`,
generated note = `#dict-hpi-text`.
### 3.3 Sick Visit — `sickVisit.js` + `shadess.js`
**File:** `public/js/sickVisit.js` (450 lines, biggest single-tab
module after shadess+wellVisit).
**Component:** `public/components/sickvisit.html`
**Tab name:** `sickvisit`
**Endpoint:** `POST /api/sick-visit/note``src/routes/sickVisit.js`
**Prompt:** `PROMPTS.sickVisitNote` (`prompts.js:364`).
**Purpose:** Quick sick visit documentation. The user types a chief
complaint, the app auto-suggests which ROS systems and PE systems are
worth reviewing (4 ROS + 7 PE by default), the user toggles each as
WNL / Abnormal / Not reviewed (typing a free-text descriptor for
abnormal), then records or dictates the encounter, picks ICD-10 codes,
and clicks Generate.
**The auto-inference logic** (the Sick Visit's distinguishing feature)
lives at `sickVisit.js:49116`:
- `INFERENCE_RULES` is a list of `{ keywords, ros, pe }` rules.
- `inferSystems(cc)` lowercases the chief complaint and OR-matches
each rule's keywords. All matched rules' `ros` + `pe` keys are
unioned.
- If nothing matches, `DEFAULT_ROS` (4 systems) and `DEFAULT_PE`
(7 systems) are used.
- The result is sliced to 4 ROS + 7 PE max so the UI doesn't explode
for "fever" matching every rule. If under-filled, defaults are
topped up.
Examples:
- "ear pain" → ROS: constitutional, ENT. PE: general, ears, nose,
mouth/throat, neck.
- "cough" → ROS: constitutional, respiratory, cardiovascular, ENT.
PE: general, chest/lungs, CV, ears, nose.
- "abdominal pain" → ROS: constitutional, GI, GU. PE: general,
abdomen, chest/lungs, CV, skin.
The user can also Add (`#sick-ros-add`, `#sick-pe-add` selects) any of
the 15 ROS / 17 PE systems manually.
**The diagnosis component** (`shadess.js:582 renderDxComponent`) is
shared between Sick and Well Visit. It renders:
- A search box that hits the NLM Clinical Tables API
(`https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search`) — no
auth, CORS-enabled, free.
- A 28-entry chip row of common pediatric DX (`COMMON_DX` at
`shadess.js:531`) for one-click adds.
- A pill list of selected diagnoses with × to remove.
**DOM IDs:**
| Element | ID |
|---|---|
| Demographics | `sick-age`, `sick-gender`, `sick-cc`, `sick-model-select` |
| Infer button | `btn-sick-infer` |
| Recording | `sick-record-btn`, `sick-pause-btn`, `sick-rec-indicator`, `sick-timer`, `sick-transcript` |
| ROS / PE | `sick-ros-container`, `sick-pe-container`, `sick-ros-add`, `sick-pe-add`, `sick-ros-all-wnl`, `sick-ros-clear`, `sick-pe-all-normal`, `sick-pe-clear` |
| Diagnoses | `sick-dx-container`, `sick-dx-search`, `sick-dx-tags`, `sick-dx-results`, `sick-dx-chips` |
| Generate / output | `btn-sick-generate`, `sick-note-output`, `sick-note-text`, `sick-note-model-tag` |
| Save bar | `sick-label`, `btn-sick-save`, `btn-sick-load`, `btn-sick-new`, `sick-load-popover` |
| Refine | `sick-refine-input`, `sick-refine-btn`, `sick-shorten-btn` |
| New patient | `btn-sick-new-patient` (in output card) |
**Other quirks:**
- `formatRosForAI(systems, dataObj, heading)` (`shadess.js:509`)
serialises ROS/PE into text the AI can read. WNL systems are
marked `"NORMAL [domain: …]"`, abnormals are `"ABNORMAL — <user
note> [domain: …]"`. The PROMPTS' `ROS_PE_RULES` block (in
`prompts.js:1748`) tells the AI to expand each NORMAL into
13 specific pertinent negatives instead of writing "WNL", and to
expand ABNORMALs with the most common matching presentation.
- After generation, the tab calls **both** `suggestBillingCodes` and
`suggestDontMiss` (`sickVisit.js:341342`). Sick Visit is
intentionally the canonical "don't miss" tab — the prompt is tuned
for this kind of single-complaint visit.
- Reset (`#btn-sick-new-patient`) is implemented in
`resetSickVisit()` at `sickVisit.js:351` — it explicitly clears the
ROS/PE/Dx state in addition to the form fields. The shared
`clearTab()` in `encounters.js:459` is also wired via the save
bar's New button, but the in-output reset button uses the more
exhaustive `resetSickVisit`.
**On save:** `enc_type='sickvisit'`. The save bar's button calls the
generic `saveFromTab('sickvisit', 'sick')`; the in-output Save (also
wired) calls `saveEncounter` directly with the transcript +
generated_note.
### 3.4 Well Visit — `wellVisit.js` + `shadess.js`
This is the largest tab (`wellVisit.js`: 729 lines + `shadess.js`:
1061 lines, of which ~700 are the well-visit / SHADESS UI). It's
also the only tab with sub-tabs.
**Files:** `public/js/wellVisit.js` (the by-visit/catch-up/schedule
panes + state) and `public/js/shadess.js` (the SHADESS form, the
shared ROS/PE/DX renderers, the recording for SHADESS and Visit
Note panes, and the Visit Note generation).
**Component:** `public/components/wellvisit.html` (308 lines).
**Tab name:** `wellvisit`. Two siblings — `vaxschedule` (the full
periodicity grid) and `catchup` — share data and renderers but live
in their own top-level tabs.
**Sub-tabs** (rendered as `.wv-subtab-btn` in
`wellvisit.html:720`):
- **By Visit Age** (`wv-panel-byvisit`) — pick a visit from the
AAP/Bright Futures schedule (`VISIT_AGES` from
`pediatricScheduleData.js`) and see all the recommended
measurements, vaccines, screenings, procedures, oral health, expected
growth, expected reflexes, BMI classification, billing codes for
that visit. Each item has a Done/Refused/Not Due/Given/Refused/
Deferred/Already Done button row whose state is persisted to
`localStorage['ped_visit_statuses']`.
A "Copy to Visit Note" button (`#btn-wv-copy-to-note`) flattens
the per-visit statuses into the Visit Note's `#wv-vaccines` and
`#wv-screenings` textareas.
- **Milestones** (`wv-panel-milestones`) — handled by
`public/js/milestones.js` (out of scope for this doc but cited for
completeness). The "Copy to Note" button fills `#wv-milestones-text`
via the handler at `encounters.js:374`.
- **SSHADESS** (`wv-panel-shadess`) — only visible for visits in
`SSHADESS_VISITS = ['12y' … '21y']` (`wellVisit.js:82`). When a
younger visit is selected, the sub-tab button is hidden in
`onVisitChange()` (line 192201) and the user is bumped back to the
by-visit pane.
- **Visit Note** (`wv-panel-note`) — the actual note generator: vitals
textarea, encounter recording / dictation, screenings textarea,
vaccines textarea, SSHADESS carry-over textarea, milestones
carry-over textarea, ROS card, PE card, ICD-10 diagnosis component,
Generate button, output card with refine/shorten.
**Endpoints:**
- `POST /api/well-visit/shadess` (`src/routes/wellVisit.js:63`) —
produces the SSHADESS narrative.
Prompt: `PROMPTS.shadessAssessment` (`prompts.js:311`).
- `POST /api/well-visit/note` (`src/routes/wellVisit.js:135`) —
produces the full visit note. Prompt: `PROMPTS.wellVisitNote`
(`prompts.js:321`) for full encounter, or `PROMPTS.wellVisitShort`
(`prompts.js:354`) for brief SOAP — selected by `noteStyle` from
`#wv-note-style`.
**The SSHADESS form** (`shadess.js:9119, 124192`) is 8 domains —
Strengths, School, Home, Activities, Drugs/Substances, Emotions/
Eating, Sexuality, Safety — each with 36 yes/no or text questions.
Some questions have `concern_if: true` (auto-flag the domain on YES,
e.g. household violence, suicidal ideation). Each domain has an
`Additional notes` textarea, a Skip checkbox, and a manual flag
toggle. State lives in the module-local `_shadessAnswers` object.
**The recording paths** in this module:
- `initShadessRecording()` (`shadess.js:240`) wires the SSHADESS
"Listen In" mic. Audio goes to `transcribeAudio` and the result is
stored in `_shadessTranscript`, which is sent in the
`dictationText` field on the SSHADESS endpoint.
- `initWvRecording()` (`shadess.js:712`) wires the Visit Note pane's
recorder. The transcript ends up in `#wv-transcript` (and module
local `_wvTranscript`), and is sent in the `transcript` field on
the Visit Note endpoint.
**Server-side enrichment** (`wellVisit.js:135215`): The Visit Note
route takes the patient age, looks up `GROWTH_REFERENCE` (also from
`pediatricScheduleData.js`) via `getGrowthRefForAge()`, and appends the
expected weight gain, length gain, head circumference gain, feeding
guidance bullets, and the AAP 2023 BMI Classification table to the
user message. The prompt then incorporates this as anticipatory
guidance.
**DOM IDs (Visit Note pane only):**
| Element | ID |
|---|---|
| Visit details | `wv-note-age`, `wv-note-gender`, `wv-note-style` |
| Vitals | `wv-vitals` |
| Recording | `wv-record-btn`, `wv-pause-btn`, `wv-rec-indicator`, `wv-timer`, `wv-transcript` |
| Screenings / vaccines | `wv-screenings`, `wv-vaccines` |
| Carry-over textareas | `wv-shadess-text`, `wv-milestones-text` |
| ROS / PE | `wv-ros-container`, `wv-pe-container`, `wv-ros-all-wnl`, `wv-ros-clear`, `wv-pe-all-normal`, `wv-pe-clear` |
| Diagnoses | `wv-dx-container` (id-prefix `wv` for the shared component) |
| Generate / output | `btn-wv-generate`, `wv-note-output`, `wv-note-text`, `wv-note-model-tag` |
| Save bar | `wv-label`, `btn-wv-save`, `btn-wv-load`, `btn-wv-new`, `wv-load-popover` |
| Refine / shorten | `wv-refine-input`, `wv-refine-btn`, `wv-shorten-btn` |
| New patient | `btn-wv-new-patient` |
**Other quirks:**
- The SHADESS pane has its own age/gender inputs (`#shadess-age`,
`#shadess-gender`) separate from the Visit Note's
(`#wv-note-age`, `#wv-note-gender`). They don't auto-sync.
- After SHADESS generation, the assessment is auto-copied into
`#wv-shadess-text` (see `shadess.js:382`).
- After Visit Note generation, only `suggestBillingCodes` is called
(`shadess.js:924`). No don't-miss for well visits — by design, well
visits aren't the right context for "don't miss" alerts.
- The full periodicity grid (vaxschedule tab) and catch-up grid
(catchup tab) share `wellVisit.js`'s `renderFullSchedule` and
`renderCatchUp` — they are siblings, not sub-tabs (see
`wellVisit.js:722727` for the per-tab init guard).
- `localStorage['ped_visit_statuses']` keys are `<visitId>.<itemKey>`,
e.g. `12y.vax_HPV_d1``{status: 'Given', note: 'Lot ABC123'}`.
This survives reloads but is intentionally per-browser, not synced
to the server.
**On save:** `enc_type='wellvisit'`, transcript = `#wv-transcript`,
generated note = `#wv-note-text`. The save bar wires the standard
flow; the output card's Save button (separately wired in
`shadess.js:963`) does the same thing.
### 3.5 SOAP — `soap.js`
**File:** `public/js/soap.js`
**Component:** `public/components/soap.html`
**Tab name:** `soap`
**Endpoint:** `POST /api/generate-soap``src/routes/soap.js:9`
**Prompt:** `PROMPTS.soapFull` (`prompts.js:168`) or
`PROMPTS.soapSubjective` (`prompts.js:176`), selected by
`#soap-type`.
**Purpose:** Generate a full SOAP note (or just the Subjective
section) from a transcript or dictated text. There's a free-form
"additional instructions" field that gets concatenated to the system
prompt as **trusted operator-supplied** instructions (i.e. NOT wrapped
in `UNTRUSTED_*` — the user is operating their own scribe, so their
free-text is treated as a prompt extension; see `soap.js:181`,
`soap.js:2123` in the route).
**User flow:**
1. Type age, gender, pick "Full SOAP" or "Subjective Only".
2. Click Dictate → mic engages. Stop when done. Transcript fills
`#soap-transcript`.
3. (Optional) Type additional AI instructions in `#soap-instructions`.
4. Click Generate → `#soap-text` populates.
**DOM IDs:** `soap-age`, `soap-gender`, `soap-type`,
`soap-instructions`, `soap-record-btn`, `soap-pause-btn`,
`soap-stop-btn`, `soap-recording-indicator`, `soap-timer`,
`soap-transcript`, `soap-clear`, `soap-generate-btn`, `soap-output`,
`soap-text`, `soap-model-tag`, `soap-label`, `soap-refine-btn`,
`soap-shorten-btn`, `soap-refine-input`.
**Quirks:**
- Has the same dedicated Stop button as Encounter HPI (`soap.js:113`).
- After generation calls `suggestBillingCodes` only — no don't-miss
card.
- `additionalInstructions` is the only "trusted" free-text injection
in the clinical-notes routes; everything else is wrapped. This is
fine because it's the physician's own text in a single-user app.
- Load handler (`soap.js:207`) restores age, gender, AND type from
`partial_data`.
**On save:** `enc_type='soap'`, transcript = `#soap-transcript`,
generated note = `#soap-text`.
### 3.6 Hospital Course — `hospitalCourse.js`
**File:** `public/js/hospitalCourse.js`
**Component:** `public/components/hospital.html`
**Tab name:** `hospital`
**Endpoint:** `POST /api/generate-hospital-course`
`src/routes/hospitalCourse.js:10`
**Prompt:** picked by setting + LOS + `formatPreference`:
| Format | Prompt key | When |
|---|---|---|
| `prose` | `hospitalCourseShort` | default; LOS ≤ 3 |
| `dayByDay` | `hospitalCourseLong` | LOS > 3, format=auto |
| `organSystem` | `hospitalCourseICU` | setting=picu/nicu |
| `psych` | `hospitalCoursePsych` | setting=psych |
User can override via `#hc-format`. Selected at
`src/routes/hospitalCourse.js:3451`.
**Purpose:** Build an organised hospital course from a stack of
admission documents.
**User flow:**
1. Patient demographics + setting (floor/PICU/NICU/psych) + LOS +
format.
2. (Optional) Paste an ED note in the dedicated card with a date.
3. (Optional) Paste an H&P with a date.
4. Add as many Progress Notes as needed (each with date and type:
attending / resident / NP / consult / procedure / discharge).
Each card has its own contenteditable body and its own Dictate
button (sets up its own per-card `AudioRecorder` via
`setupDictateButton()` at `hospitalCourse.js:52`).
5. Add labs as date-stamped entries.
6. Optional free-text additional instructions.
7. Generate → AI produces the course in the chosen format.
The generate POST sends a structured payload: `notes` (array),
`edNote`, `hAndP`, `labs` (array), demographics, setting, LOS,
formatPreference, additionalInstructions, model, physicianMemories.
The server sorts the notes by date before assembling the user
message.
**Extras:**
- `Clarify` button (`#hc-clarify-btn`) hits `/api/clarify` (handled
in `src/routes/refine.js:67`) which uses `PROMPTS.askClarification`
to surface what's missing from the draft.
- `additionalInstructions` is treated as trusted, like SOAP.
- After generation calls `suggestBillingCodes` only.
**DOM IDs:** `hc-age`, `hc-gender`, `hc-pmh`, `hc-setting`, `hc-los`,
`hc-format`, `hc-instructions`, `hc-add-note`, `hc-notes-container`,
`hc-add-lab`, `hc-labs-container`, `hc-ed-content`, `hc-ed-date`,
`hc-ed-labs`, `hc-hp-content`, `hc-hp-date`, `hc-generate-btn`,
`hc-output`, `hc-course-text`, `hc-format-tag`, `hc-model-tag`,
`hc-clarify-btn`, `hc-clarify-output`, `hc-refine-btn`,
`hc-shorten-btn`, `hc-refine-input`.
**On save:** `enc_type='hospital'`. Note: this tab does NOT save the
transcript (there isn't one) — only `generated_note`. The pasted
notes themselves are not persisted; if the user resumes the encounter,
they get the AI's hospital course back but not the per-note source
material. This is intentional — pasted PHI shouldn't sit in the saved
encounter row indefinitely.
### 3.7 Chart Review — `chartReview.js`
**File:** `public/js/chartReview.js`
**Component:** `public/components/chart.html`
**Tab name:** `chart`
**Endpoint:** `POST /api/generate-chart-review`
`src/routes/chartReview.js:10`
**Prompt:** picked by `cr-type`:
| Type | Prompt key |
|---|---|
| `outpatient` | `chartReviewOutpatient` (`prompts.js:136`) |
| `subspecialty` | `chartReviewSubspecialty` (`prompts.js:148`) |
| `ed` | `chartReviewED` (`prompts.js:158`) |
The prompt-selection key is the **top-level review type**, not the
per-visit type — see the comment at `src/routes/chartReview.js:27`.
This means an outpatient chart review can include subspecialty and
ED visits (and they'll all be rolled into one outpatient-style
summary).
**Purpose:** Summarise a stack of pasted notes for precharting. No
recording — pure paste-in.
**User flow:**
1. Patient demographics + PMH + review type.
2. Add as many Visit cards as needed. Each has a date, a per-visit
type (outpatient / subspecialty / ED), specialist name + specialty
(only relevant if subspecialty), pasted content, and per-visit
labs.
3. Add additional date-stamped labs not tied to a visit.
4. Optional additional instructions.
5. Generate.
**Client-side guards** (`chartReview.js:127135`):
- Warns if estimated payload >8 MB (the express `bodyParser.json` cap
is 10 MB).
- Toasts a "this may take a moment" notice if >30 notes.
**Quirks:**
- After generation calls `suggestBillingCodes` only (no don't-miss).
- Does NOT call `getUserMemoryContext()` — chart reviews are summaries
of others' notes, not original documents that should follow the
user's templates.
- `resetChartReview()` is exposed as `window.resetChartReview` so the
shared `clearTab('chart')` can call it (encounters.js:502).
**DOM IDs:** `cr-age`, `cr-gender`, `cr-pmh`, `cr-type`,
`cr-add-visit`, `cr-visits-container`, `cr-add-lab`,
`cr-labs-container`, `cr-instructions`, `cr-generate-btn`, `cr-output`,
`cr-review-text`, `cr-model-tag`, `chart-label`, `cr-refine-btn`,
`cr-shorten-btn`, `cr-refine-input`. Note: `cr-` for content vs `chart-`
for the save label — historical naming inconsistency.
**On save:** `enc_type='chart'`, generated note = `#cr-review-text`. As
with Hospital Course, no transcript is persisted; the source notes
have to be re-pasted on resume.
### 3.8 Personal Notes — `notes.js`
This tab is structurally different from all the others — see Section 7
for the deep dive. Briefly: it is a notes-list / reader / editor (CRUD
against `personal_notes` table), not a record-and-generate tab.
Voice → AI is an optional input that produces a clean note via
`/api/notes/from-voice`.
---
## 4. Encounters.js — the save / load infrastructure
**File:** `public/js/encounters.js` (516 lines).
**Server route:** `src/routes/encounters.js` (175 lines).
**Table:** `saved_encounters` (encrypted-at-rest columns).
### 4.1 The `saveEncounter` API
`window.saveEncounter(opts)` (`encounters.js:113`) is the single
entry point. Options:
| Field | What |
|---|---|
| `id` | Existing row ID (or null for new) |
| `label` | Human-readable label, must be non-empty |
| `enc_type` | One of `encounter`, `dictation`, `hospital`, `chart`, `wellvisit`, `sickvisit`, `soap`, `ed`, `milestones` |
| `transcript` | The transcript text |
| `generated_note` | The AI-generated note text |
| `partial_data` | (optional) JSON string with extra fields like age/gender/type |
| `idempotency_key` | (optional) UUID v4 to detect double-clicks |
| `expected_version` | (optional) the version number from the last GET |
| `onSaved(id)` | Callback fired after successful save |
The function:
1. Validates a non-empty label client-side.
2. De-dupes double-clicks via `_savingInProgress[type]` flag.
3. POSTs `/api/encounters/saved`.
4. Handles 409 (concurrent edit detected) with a toast asking the user
to reload.
5. On success, stashes the new version in
`window._encounterVersions[id]` and the new ID in both
`window._savedEncId_<type>` and `sessionStorage['_savedEncId_' +
type]`.
6. Calls `loadSavedEncountersList()` to refresh the saved-encounters
UI (settings page + any open load-popover).
### 4.2 Optimistic versioning
Server side (`src/routes/encounters.js:75104`):
- Every saved encounter row has an integer `version` column.
- On update, if the client sends `expected_version` in the body, the
server compares it to the current `version`. Mismatch → HTTP 409
with `{currentVersion, yourVersion}`.
- The UPDATE itself is also conditioned on the version (`AND
(version = $9 OR $9::int IS NULL)`) so even concurrent writers
between the SELECT and UPDATE get caught (`upd.changes === 0` →
409).
- New version on success = previous + 1, returned to the client which
caches it in `_encounterVersions`.
Backwards-compat: if the client doesn't send `expected_version`, the
server falls back to last-write-wins.
### 4.3 Idempotency keys
Each `enc_type` gets a UUID v4 (`generateUUID()`,
`encounters.js:68`), generated lazily and cached in
`sessionStorage['_idempKey_' + type]` (`getIdempotencyKey`,
`encounters.js:77`).
Server side (`src/routes/encounters.js:119139`):
- On a new-row POST (no `id`), if an `idempotency_key` is supplied,
the server checks for an existing row with the same key for this
user. If found, it UPDATEs that row instead of inserting a new one
and returns the existing ID.
The idempotency key is reset by `resetIdempotencyKey(type)`
(`encounters.js:90`) which is called from `clearTab(type)` whenever
the user clicks "New" — so each new patient gets a fresh key that
won't collide with any prior save.
### 4.4 sessionStorage restore
`encounters.js:97104` runs at module load:
```
['encounter','dictation','ed','hospital','chart','wellvisit',
'sickvisit','soap'].forEach(function(t) {
try {
var id = sessionStorage.getItem('_savedEncId_' + t);
if (id) window['_savedEncId_' + t] = id;
} catch(e) {}
});
```
So if the user refreshes mid-encounter, the next Save click will
update the existing row instead of creating a duplicate. This is the
session-scoped (not localStorage) layer that prevents the
"page-refresh-creates-a-new-encounter" footgun.
### 4.5 Load + resume
- `loadSavedEncountersList()` (`encounters.js:159`) GETs
`/api/encounters/saved`, refreshes any open popovers and the
settings-page list.
- `resumeEncounter(id, type)` (`encounters.js:287`) GETs
`/api/encounters/saved/:id` (full decrypted row), navigates to the
right tab via `tabBtn.click()`, and after a 200 ms `setTimeout`
(giving the lazy-loaded component HTML time to mount), calls the
registered load handler from `_encLoadHandlers[enc_type]`.
- Each tab module registers its handler via
`registerEncounterLoadHandler('sickvisit', function(enc) {...})`.
The 200 ms delay is a best-effort wait — see the comment at
`encounters.js:330`. If the component is already cached, this is
mostly a no-op; on first-load, it covers the fetch + parse.
### 4.6 SACRED — do not refactor
Per the project memory, `public/js/encounters.js` is sacred. Even
pre-approved changes get rejected if they refactor save/idempotency.
The reason: the save flow is the one place every clinical tab
converges, the idempotency + versioning logic is subtle, and silent
regressions here would cost clinical work. Any change must be
narrowly scoped, named, and approved per-change.
Concretely off-limits without explicit approval:
- The save POST body shape.
- The 409 handling.
- The idempotency-key generation / storage.
- The sessionStorage restore array (extending it for a new note type
is fine, but only as an additive change).
- The `tabMap` and `domPfxMap` and `noteIdMap` in
`resumeEncounter()`.
- The dual-listener wiring in `clearTab` / save bar / output-card
buttons.
---
## 5. Helper trio — refine, billing-codes, don't-miss
These are post-generation features. Each lives in `app.js`, posts to
its own endpoint, and renders into the same `.output-card` container
as the note.
### 5.1 Refine + Shorten
`refineDocument(outputId, inputId)` (`app.js:946`):
1. Reads the output `<div>`'s `.innerText` as `currentDocument`.
2. Reads the user's instructions from the textarea.
3. If `outputEl.dataset.sourceContext` exists (set by
`storeSourceContext()` after generation), includes it as
`sourceContext`.
4. POSTs `/api/refine` with `{currentDocument, instructions,
sourceContext, model}`.
5. Server (`src/routes/refine.js:14`) wraps both `currentDocument` and
`instructions` with `wrapUserText()`, prepends `sourceContext` as
"ORIGINAL SOURCE MATERIAL" so the AI can do data lookups, runs
`PROMPTS.refine`, and returns the rewritten text.
6. Client replaces the output content.
`shortenDocument(outputId)` (`app.js:971`) is simpler — POSTs to
`/api/shorten`, runs `PROMPTS.shortenDocument` (target ~50% shorter,
keep all clinical content), returns rewritten text.
`/api/clarify` (used only by Hospital Course's Clarify button) lives
in the same route file and uses `PROMPTS.askClarification`.
### 5.2 Billing codes
`suggestBillingCodes(outputId, noteText, noteType, age, visitType)`
(`app.js:804`) is called by Encounter HPI, Sick Visit, Well Visit
(via `shadess.js:924`), SOAP, Hospital Course, Chart Review.
- Mounts a dynamically created `.billing-codes-card` directly after
the output `<div>`.
- POSTs `/api/suggest-codes` with the note text, type, age, and
visit type.
- Server (`src/routes/billing.js:291`) does NOT call an LLM — it
parses the note text deterministically:
1. **Diagnosis extraction** (`extractDiagnoses`, `billing.js:128`):
extracts assessment-section text via regex, pulls out any
existing ICD-10 code patterns (`[A-TV-Z]\d{2}(?:\.\d{1,4})?`),
numbered items (`1. Otitis media`), bullet items, and "diagnosed
with X" inline phrases.
2. **ICD-10 lookup**: each extracted term is checked against
`COMMON_ICD10` (a 47-entry pediatric local map at
`billing.js:81`); on miss, it falls back to NLM Clinical Tables
(`lookupICD10`, `billing.js:271`) with a 5 s timeout.
3. **CPT selection**: based on `noteType`. `wellvisit`
age-bucketed `99391``99395`. `hospital` + admission →
`99221``99223`, discharge → `99238/99239`, otherwise subsequent
`99231``99233`. ED → `99281``99285`. Outpatient → new vs
established detection.
4. **E/M complexity** (`estimateEMLevel`, `billing.js:212`): counts
ROS / PE systems documented, looks for high-risk keywords
(admit, sepsis, intubated, etc.), looks for moderate-risk keywords
(IV fluid, antibiotic, x-ray, etc.) and produces a level + MDM
complexity.
- Result rendered as ICD-10 chips (red), CPT chips (blue), and an
E/M assessment line. Every chip is click-to-copy.
- ReDoS guard: refuses note text > 20 000 chars.
### 5.3 Don't-miss
`suggestDontMiss(outputId, noteText, noteType, age, cc)`
(`app.js:894`) is currently called by Encounter HPI
(`liveEncounter.js:217`) and Sick Visit (`sickVisit.js:342`).
- Mounts a dynamically created card with a 3 px orange left border
(`#f59e0b`) and a Triangle-Exclamation icon.
- POSTs `/api/dont-miss` with `{noteText, noteType, patientAge,
chiefComplaint, model}`.
- Server (`src/routes/dontMiss.js`) runs `PROMPTS.dontMissTooltip`
(`prompts.js:505`) which produces strict JSON
`{points: [{point, why}, …]}`. The route hard-caps the result to 5
items at line 61 (in addition to the prompt-level "HARD CAP: at
most 5 points"). If the model returns 05 items the cap is a no-op;
if it ignores the prompt and returns 12, the server slices to 5.
For the **ED Encounter** tab (out of scope but worth noting): its
note generation prompt itself produces `dontMiss[]` inline as part of
the JSON envelope, with **no fixed cap**. The standalone
`/api/dont-miss` endpoint is sick-visit and HPI only.
### 5.4 The orange-bordered card pattern
The cards use this insertion pattern (works for billing too):
1. Look up an existing container with id
`<outputId-without-text> + '-dont-miss'` (e.g.
`sick-note-dont-miss`).
2. If missing, create a `div`, set `.dont-miss-card` class + inline
left-border style, and `parentNode.insertBefore` it directly after
the output `<div>`.
3. Show a spinner placeholder.
4. After fetch, replace innerHTML with the final content.
5. On empty / error, hide the container.
Failures are silent (no toast) so the user isn't pestered if a model
is slow or returns junk.
---
## 6. User templates ("memories") flow
Daniel's saved templates live in the `user_memories` table (one
encrypted row per template). The UI is in
`public/js/memories.js` — Settings page → Physician Memory section.
The flow into the system prompt:
1. Each tab, just before its Generate POST, calls
`getUserMemoryContext()` (`memories.js:166`):
```
fetch('/api/memories/context', { headers: getAuthHeaders() })
.then(r => r.json())
.then(data => data.success ? data.context : '')
.catch(() => '');
```
— falling back to an empty string on any failure so the generation
still works without templates.
2. The returned string is passed in the request body as
`physicianMemories`.
3. Server side, the routes append it to the user message wrapped as a
STYLE HINTS block:
```
[STYLE HINTS (low priority — only apply if relevant to the current
note, never copy content from these examples)]
<UNTRUSTED_STYLE_HINTS>
...templates...
</UNTRUSTED_STYLE_HINTS>
[END STYLE HINTS]
```
Always the LAST block before the call, marked low priority, double-
wrapped (the brackets and the `UNTRUSTED_STYLE_HINTS` tags).
4. The system-prompt's `INJECTION_GUARD` ensures the model treats it
as data, never instructions.
The "low priority — only apply if relevant" wording is deliberate.
Without it, models will sometimes paste a stored template's clinical
content into a new patient's note. The wording + the wrapper together
keep memories as STYLE/STRUCTURE hints, not content.
Where in each frontend module the fetch happens:
- `liveEncounter.js:190` — Encounter HPI
- `voiceDictation.js:154` — Dictation HPI
- `sickVisit.js:309` — Sick Visit
- `shadess.js:882` — Well Visit Note
- `soap.js:170` — SOAP
- `hospitalCourse.js:137` — Hospital Course
- (Chart Review intentionally does NOT fetch — see §3.7.)
---
## 7. Notes — the odd one out
Personal Notes (`public/js/notes.js`, 890 lines) breaks the pattern
because it isn't a record-and-generate tab; it's a per-user note
**editor** with optional voice → AI cleanup as one of several entry
methods.
### 7.1 Why it's structurally different
The other clinical-notes tabs are all single-form pages. You walk in,
fill it, click Generate, copy out, optionally Save. There's at most
one open "encounter" at a time, and the on-disk model is a row in
`saved_encounters` with a 7-day expiry.
Personal Notes is a Notion-style **list / reader / editor** with:
- A persistent list of notes (no expiry).
- Soft-delete to a Trash pane that the user can Restore from or
Empty.
- A rich-text editor (Tiptap) instead of a contenteditable + AI-
generated text.
- Autosave every 1.2 s (debounced) plus a `beforeunload` flush via
`fetch({keepalive: true})`.
- Three view states (`list`, `reader`, `editor`) that on mobile (<900
px) take turns occupying the screen via the layout's `data-view`
attribute.
- The voice AI flow targets the editor as an alternative entry
method, not as the main UI.
The data model is also different `personal_notes` (separate table
from `saved_encounters`), encrypted title + body, one row per note,
500-note cap per user, soft-delete via `deleted_at IS NOT NULL`.
### 7.2 The from-voice endpoint
When the user clicks the mic in the editor, `notes.js:536
startRecording()` engages a fresh `AudioRecorder`. On Stop,
`notes.js:589` calls `transcribeAudio(blob)` same shared STT path
and POSTs `/api/notes/from-voice` (`src/routes/notes.js:224`).
The route's system prompt has a deliberately wider scope than the
other tabs:
> "You turn a voice dictation into a clean, well-structured personal
> note. 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."
This is the "drop the medical scribe framing" change cited in the
recent commit history (`b8693ba: voice AI note now produces HTML
the editor can render`). The previous prompt had the model treat
every note as clinical. Now Daniel can voice-record anything a
grocery list, a paper outline and get a clean note back without
the model bolting on assessments and plans.
The route returns strict JSON `{title, body}`. The `body` must be
HTML using only a small allowlist (`<p>`, `<h2>`, `<h3>`, `<strong>`,
`<em>`, `<u>`, `<ul>`, `<ol>`, `<li>`, `<blockquote>`, `<code>`,
`<a href>`, `<br>`) — Tiptap can render this directly.
`toHtmlBody()` at `src/routes/notes.js:24` is a defensive normaliser:
- Strips ` ```html ` / ` ``` ` fences a model might wrap the body in.
- If the body has any real HTML tag, returns as-is.
- Otherwise runs through `marked.parse()` — covers the case where the
model returned markdown despite the prompt.
- On marked failure, falls back to wrapping plain text in `<p>`.
The route also recovers from the model returning extra prose around
its JSON: line 263 strips fences, line 265 finds the first `{`, line
267 finds the last `}` and trims everything outside.
The client (`notes.js:600`) calls `applyGeneratedNote(title, body)`
which prefers `Tiptap.commands.setContent()` over a remount (avoids
toolbar flicker) and marks the note dirty so the autosave kicks in.
### 7.3 The recorder lifecycle in notes
Notes uses the same `AudioRecorder` class as the other tabs but with
its own UI lifecycle:
- Button states: `idle``recording``paused` (optional) →
`processing``idle`. State changes are routed through
`setRecUI(state)` (`notes.js:670`).
- Stop has TWO modes: `silent` (cancel cleanly without transcribing —
used when the user navigates away) and `not silent` (the normal
Stop button — transcribes and generates).
- The recent fix in `66f319e` (mentioned in the recent commits) wraps
the click handler so the click `Event` doesn't get truthily passed
as `silent: true` to `stopRecording`:
```
if (recStop) recStop.addEventListener('click',
function() { stopRecording(false); });
```
Without the wrapper, `addEventListener` would pass the `Event` as
the first arg, which is truthy, and silent-cancel would always be
taken — i.e. Stop would do nothing.
### 7.4 Other notes-only details
- The pane state lives in module-locals
(`_notes`, `_trash`, `_pane`, `_activeId`, `_editor`, `_dirty`,
etc.). The note-list always pulls both active and trash in parallel
(`refreshList()`, `notes.js:216`) so the trash count badge stays
current.
- Sanitisation of saved HTML on render uses `DOMPurify` with a strict
allowlist (`notes.js:861`). If DOMPurify fails to load,
`sanitizeHtml` refuses to render HTML and falls back to plain text
rather than risking an XSS via a homegrown sanitiser.
- Save shortcuts: Ctrl/Cmd+S in the editor calls
`saveNote({source: 'shortcut', returnToReader: true})`.
- Server-side, every title and body is AES-256-GCM encrypted at rest
via the same `cryptoUtil.encryptString` used for saved encounters
(`src/routes/notes.js:113, :138`).
- Trash mechanics: DELETE soft-deletes (sets `deleted_at = NOW()`),
POST `/restore` clears `deleted_at`, DELETE with `?hard=1` only
removes rows that are already in trash (so a UI bug can't
accidentally erase active notes), POST `/trash/empty` deletes every
trashed row for the user.
---
## 8. Sacred zones
Two areas of the codebase are explicitly off-limits for refactoring,
based on Daniel's stated rules:
### 8.1 `public/js/encounters.js` — save / idempotency
Already covered in §4.6. The summary: every change to encounters.js
must be:
- Per-change explicitly approved.
- Narrowly scoped — additive only when possible.
- Reviewed for impact on save POST shape, version handling,
idempotency, sessionStorage restore, and the tabMap/domPfxMap/
noteIdMap.
Refactors that "tidy up" encounters.js are rejected on principle,
even if the rewrite preserves behaviour. Reason: the save flow is
clinically load-bearing, and silent regressions are expensive.
### 8.2 Voice / STT plumbing
The recording-and-transcription flow is:
1. `AudioRecorder` class in `app.js:659683``getUserMedia` +
`MediaRecorder` setup.
2. `transcribeAudio(blob)` in `app.js:739795` and the
`_serverTranscribe(blob)` helper.
3. The per-tab recording handlers in `liveEncounter.js`,
`voiceDictation.js`, `sickVisit.js`, `soap.js`, `wellVisit.js` /
`shadess.js` (all the tab-specific record/pause/stop wiring).
4. `audioBackup.js` — the IndexedDB / server-side audio backup
fallback.
These are sacred because:
- Voice is the primary input modality for the app. If a recording
silently cuts off, drops audio, doesn't pause cleanly, or fails to
fall back to the live transcript when the server STT is down, the
user loses clinical work.
- The patterns differ slightly per tab (some have a Stop button,
some don't; some dispatch `recording-started` events, some don't;
Sick Visit's pause logic handles `inactive` MediaRecorder state by
re-creating a MediaRecorder on the same stream, etc.). These are
deliberate per-tab quirks, not inconsistencies to be tidied.
- The browser's `MediaRecorder.pause()` / `.resume()` support varies
across browsers. Every tab has a fallback path for when `state`
becomes `inactive` (the browser killed the recorder mid-pause)
that re-instantiates the MediaRecorder on the same stream. That
fallback is replicated (not factored out) in each tab on purpose.
Off-limits without explicit approval per change:
- `AudioRecorder.start` / `.stop` semantics.
- The dual-track live-STT + uploaded-audio flow.
- The per-tab pause/resume + MediaRecorder-state fallback.
- The `_transcribeAvailable` probe and the no-server-fallback path.
- The audio-backup-on-failure path.
OK to do narrowly: fixing a named bug with the smallest possible
diff. Recent example: the `66f319e` fix for "Stop button never
transcribed because silent-cancel was always taken" — a one-line
event-handler wrapper change in `notes.js`.
---
## 9. How to add a new note tab
Concrete checklist. Use Sick Visit (a non-trivial example) as the
reference shape.
1. **HTML component** — create `public/components/<name>.html`. Use
the Sick Visit component as a template. Required structure:
- `.module-header` with title.
- Save bar with `<input id="<pfx>-label">`, Save / Load / New
buttons (`btn-<pfx>-save`, `btn-<pfx>-load`, `btn-<pfx>-new`),
and the load popover (`<pfx>-load-popover`,
`<pfx>-load-search`, `<pfx>-pop-list`).
- Demographics card with whichever fields you need
(`<pfx>-age`, `<pfx>-gender`, `<pfx>-model-select`).
- (If recording) Recording card with `<pfx>-record-btn`,
`<pfx>-pause-btn`, `<pfx>-rec-indicator`, `<pfx>-timer`,
`<pfx>-transcript` (contenteditable).
- Whatever per-tab structured inputs (ROS/PE/Dx/etc.) you need.
- Generate button: `btn-<pfx>-generate`.
- Output card: `<pfx>-output` (initially `hidden`),
`<pfx>-text` (or `<pfx>-note-text` etc — must match what you
register in `noteIdMap` below), `<pfx>-model-tag`.
- Refine bar: `<pfx>-refine-input`, `<pfx>-refine-btn`,
`<pfx>-shorten-btn`.
2. **Tab pane in `public/index.html`** — add:
```
<button class="tab-btn" data-tab="<name>"></button>
```
in the sidebar (around line 197) and:
```
<section id="<name>-tab" class="tab-content"
data-component="<name>"></section>
```
in the tab-content area (around line 301). The `data-component`
value is the filename (without `.html`) under `public/components/`.
3. **JS module** — create `public/js/<name>.js`. Use this skeleton:
```
(function() {
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== '<name>' || _inited) return;
_inited = true;
init();
});
function init() {
// Wire buttons. Read inputs. Call generate on click.
}
function generate() {
var memoriesPromise = (typeof getUserMemoryContext === 'function')
? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
return fetch('/api/<your-endpoint>', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
...,
physicianMemories: memCtx || null,
model: getSelectedModel()
})
});
}).then(...).then(function(data) {
setOutputText(noteEl, data.note);
storeSourceContext('<pfx>-text', sourceMaterial);
outputCard.classList.remove('hidden');
// Optionally:
if (typeof suggestBillingCodes === 'function') suggestBillingCodes(...);
if (typeof suggestDontMiss === 'function') suggestDontMiss(...);
});
}
// Register a load handler so resume works
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('<enc_type>', function(enc) {
// Restore transcript + generated_note + any per-tab state
});
}
})();
```
Add a `<script src="/js/<name>.js"></script>` to `public/index.html`
(the script load order is fixed; check the existing block).
4. **Server route + prompt** — create `src/routes/<name>.js`:
```
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.post('/<your-endpoint>', authMiddleware, async function(req, res) {
try {
var { ..., physicianMemories, model } = req.body;
// Validate inputs, build context with wrapUserText() per field.
// Append style hints if physicianMemories.
var result = await callAI([
{ role: 'system', content: PROMPTS.<yourPromptKey> + INJECTION_GUARD },
{ role: 'user', content: context }
], { model });
res.json({ success: true, note: result.content, model: result.model });
logger.audit(req.user.id, 'generate_<name>', '...', req,
{ category: 'clinical' });
} catch (e) {
logger.error('[<name>] failed', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
```
Add a new prompt key in `src/utils/prompts.js` near related ones,
following the style — start with `${CORE_RULES}` and (if relevant)
`${ROS_PE_RULES}`, list required sections, end with constraints.
5. **Mount in `server.js`** — add a line in the existing block
(around line 280):
```
app.use('/api', require('./src/routes/<name>'));
```
6. **Register in `encounters.js` for save/resume** (NB: this is the
minimum sacred-file change; treat as "additive, narrow"):
- Add the new `enc_type` to the sessionStorage restore list
(`encounters.js:98`):
```
['encounter','dictation','ed','hospital','chart','wellvisit',
'sickvisit','soap','<name>'].forEach(...);
```
- Add to `tabMap`, `domPfxMap`, `noteIdMap` in
`resumeEncounter()` (lines 305, 317, 322).
- Add a click case in the document-level click handler (around
line 398) for `#btn-<pfx>-save`, `#btn-<pfx>-load`,
`#btn-<pfx>-new`.
- Add to `pfxMap`, `noteElMap`, `outputElMap` in `clearTab()`
(lines 460473) so the New Patient button clears your tab.
- If the type appears in load popovers, add the prefix to the
popover-list refresher arrays at lines 166 and 256 and 299
and 424.
7. **Test the round-trip**: tab loads, recording works (if used),
generate hits the route, save persists, page-refresh + Save
doesn't duplicate, Load → Resume restores the state, New clears
everything, idempotency_key resets on New.
---
## Appendix — file map (quick reference)
Frontend:
| Tab | JS module | HTML component | Save type |
|---|---|---|---|
| Encounter HPI | `public/js/liveEncounter.js` | `public/components/encounter.html` | `encounter` |
| Dictation HPI | `public/js/voiceDictation.js` | `public/components/dictation.html` | `dictation` |
| Sick Visit | `public/js/sickVisit.js` (+ `shadess.js` for shared ROS/PE/Dx) | `public/components/sickvisit.html` | `sickvisit` |
| Well Visit | `public/js/wellVisit.js` + `public/js/shadess.js` | `public/components/wellvisit.html` | `wellvisit` |
| SOAP | `public/js/soap.js` | `public/components/soap.html` | `soap` |
| Hospital Course | `public/js/hospitalCourse.js` | `public/components/hospital.html` | `hospital` |
| Chart Review | `public/js/chartReview.js` | `public/components/chart.html` | `chart` |
| Personal Notes | `public/js/notes.js` | `public/components/notes.html` | n/a (own table) |
Shared frontend:
- `public/js/app.js` — tab loader, `AudioRecorder`, `transcribeAudio`,
`setOutputText`, `storeSourceContext`, `suggestBillingCodes`,
`suggestDontMiss`, `refineDocument`, `shortenDocument`,
`createSpeechRecognition`, `deduplicateFinal`.
- `public/js/encounters.js` — save/load/resume infrastructure
(sacred).
- `public/js/audioBackup.js` — IndexedDB + server audio backup
(sacred-adjacent — touch only narrowly).
- `public/js/memories.js``getUserMemoryContext()`.
Backend:
| Endpoint | File |
|---|---|
| `POST /api/generate-hpi-encounter` | `src/routes/hpi.js:10` |
| `POST /api/generate-hpi-dictation` | `src/routes/hpi.js:35` |
| `POST /api/sick-visit/note` | `src/routes/sickVisit.js:14` |
| `POST /api/well-visit/shadess` | `src/routes/wellVisit.js:63` |
| `POST /api/well-visit/note` | `src/routes/wellVisit.js:135` |
| `POST /api/generate-soap` | `src/routes/soap.js:9` |
| `POST /api/generate-hospital-course` | `src/routes/hospitalCourse.js:10` |
| `POST /api/hospital-course-clarify` | `src/routes/hospitalCourse.js:100` |
| `POST /api/hospital-course-update` | `src/routes/hospitalCourse.js:116` |
| `POST /api/generate-chart-review` | `src/routes/chartReview.js:10` |
| `GET/POST/PUT/DELETE /api/notes` (+ `/trash`, `/restore`, `/empty`) | `src/routes/notes.js` |
| `POST /api/notes/from-voice` | `src/routes/notes.js:224` |
| `GET /api/encounters/saved` | `src/routes/encounters.js:23` |
| `GET /api/encounters/saved/:id` | `src/routes/encounters.js:54` |
| `POST /api/encounters/saved` | `src/routes/encounters.js:70` |
| `DELETE /api/encounters/saved/:id` | `src/routes/encounters.js:163` |
| `POST /api/refine` | `src/routes/refine.js:14` |
| `POST /api/shorten` | `src/routes/refine.js:47` |
| `POST /api/clarify` | `src/routes/refine.js:67` |
| `POST /api/suggest-codes` | `src/routes/billing.js:291` |
| `POST /api/dont-miss` | `src/routes/dontMiss.js:36` |
Prompts (all in `src/utils/prompts.js`):
| Use | Key | Line |
|---|---|---|
| Encounter HPI (outpatient) | `hpiEncounter` | 53 |
| Encounter / Dictation HPI (inpatient) | `hpiInpatient` | 70 |
| Dictation HPI | `hpiDictation` | 62 |
| Hospital course (short / prose) | `hospitalCourseShort` | 82 |
| Hospital course (day-by-day) | `hospitalCourseLong` | 94 |
| Hospital course (ICU) | `hospitalCourseICU` | 105 |
| Hospital course (psych) | `hospitalCoursePsych` | 124 |
| Chart review (outpatient) | `chartReviewOutpatient` | 136 |
| Chart review (subspecialty) | `chartReviewSubspecialty` | 148 |
| Chart review (ED) | `chartReviewED` | 158 |
| SOAP (full) | `soapFull` | 168 |
| SOAP (subjective only) | `soapSubjective` | 176 |
| Refine | `refine` | 287 |
| Shorten | `shortenDocument` | 295 |
| Clarify (gap-finder) | `askClarification` | 304 |
| SSHADESS assessment | `shadessAssessment` | 311 |
| Well visit (full) | `wellVisitNote` | 321 |
| Well visit (short SOAP) | `wellVisitShort` | 354 |
| Sick visit | `sickVisitNote` | 364 |
| Don't-miss tooltip | `dontMissTooltip` | 505 |
Cross-cutting:
- `src/utils/promptSafe.js``wrapUserText()` and `INJECTION_GUARD`
used by every clinical-notes route.
- `src/utils/ai.js``callAI()` multi-provider router (OpenRouter,
Bedrock, Azure, Vertex, LiteLLM).
- `src/utils/crypto.js` — AES-256-GCM helpers used to encrypt every
encounter / note column at rest.