pediatric-ai-scribe-v3/docs/logic/clinical-notes.md
Daniel b53aa34248 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

64 KiB
Raw Blame History

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 CustomEvents 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:97activateTab('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-encountersrc/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/notesrc/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-soapsrc/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-coursesrc/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-reviewsrc/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 9939199395. hospital + admission → 9922199223, discharge → 99238/99239, otherwise subsequent 9923199233. ED → 9928199285. 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: idlerecordingpaused (optional) → processingidle. 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:659683getUserMedia + 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.jsgetUserMemoryContext().

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.jswrapUserText() and INJECTION_GUARD used by every clinical-notes route.
  • src/utils/ai.jscallAI() 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.