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.
64 KiB
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:
- A small header bar with patient demographics (age, gender, sometimes setting / chief complaint / PMH).
- A save bar at the top with
Save / Load / Newbuttons and a patient-label input. - A recorder (most tabs) that writes into a
contenteditabletranscript box. - A Generate button that POSTs the assembled context to a route in
src/routes/. - An output card that hides until generation succeeds, then
reveals the AI's note plus a
RefineandShortenbar. - 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 26–104) 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):
- Removes
.activefrom every tab button and pane. - Adds
.activeto the clicked one. - Calls
loadComponent(tabEl)whichfetch('/components/' + component + '.html')s the per-tab HTML fragment, caches it in_componentCache, and writes it intotabEl.innerHTML. - After the fragment is in the DOM, dispatches a
tabChangedCustomEventwithdetail: { tab: tabName }. - 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:659–683:
start()callsgetUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } }), picksaudio/webm;codecs=opus(fallbackaudio/webm), runs at 32 kbps Opus, and pushes 1-second chunks intothis.chunks.stop()finalises aBlob, 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:739–795 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 bysrc/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, sometimessetting). - The transcript or dictation text (read from the contenteditable
<div>via.innerText). - An optional
physicianMemoriesblob, fetched ahead of the call viagetUserMemoryContext()(memories.js:166) which hits/api/memories/context. - A
modelstring fromgetSelectedModel()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 ontooutputEl.dataset.sourceContextso 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-codesand mounts a.billing-codes-cardwith ICD-10 + CPT chips.suggestDontMiss(outputId, noteText, noteType, age, cc)(app.js:894) — POSTs to/api/dont-missand 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:
- User types a
label(e.g. "John D, 5y fever") in the tab's*-labelinput. - Save button click calls
saveFromTab(type, prefix)inencounters.js:431, which reads the transcript and the generated note, attaches the cached_savedEncId_<type>(so updates hit the same row), generates / reuses anidempotency_keyfrom sessionStorage, and POSTs/api/encounters/saved. - 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. - Client stashes the ID in both
window._savedEncId_<type>andsessionStorage['_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:
-
User clicks Sick Visit tab.
app.js:97→activateTab('sickvisit')→loadComponent(tabEl)fetches/components/sickvisit.html→ injects HTML → firestabChanged.sickVisit.js:439catches the event, runsinitRecording(),populateAddDropdowns(),renderRosPe(),initDx(). -
User types the chief complaint (
#sick-cc) and clicks#btn-sick-infer.inferAndRender()(sickVisit.js:272) walks theINFERENCE_RULESarray (lines 49–80) — e.g. "fever" matches['constitutional', 'ent', 'respiratory', 'gastrointestinal']ROS systems and 7 PE systems — and re-renders the rows. -
User clicks "Listen In" (
#sick-record-btn). The handler atsickVisit.js:155instantiates a freshAudioRecorder, starts the browserSpeechRecognition, and shows the live transcript in#sick-transcript. Thepulse-dotindicator goes red and the00:00timer starts. -
User clicks "Stop".
sickVisit.js:170stops the recorder, gets the Blob, callstranscribeAudio(blob). On success, the server transcript replaces the live one in#sick-transcript. -
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 pairrenderRosRows/wireRosContainer(both exposed onwindow). -
User clicks Generate.
generateSickNote()(sickVisit.js:292) reads everything: transcript, ROS data, PE data, selected diagnoses (viaformatDxForAIinshadess.js), picks up the user's saved templates fromgetUserMemoryContext(), and POSTs/api/sick-visit/note. -
Server route (
src/routes/sickVisit.js:14) builds the user message:Patient: 5 years, FemaleChief 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. CallscallAI(). Logs an audit row. -
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.
-
User types a label (
#sick-label = "John D 5y fever") and clicks Save.sickVisit.js:401callssaveEncounter({id, label, enc_type: 'sickvisit', transcript, generated_note, onSaved}). Server encrypts and stores; client caches the ID in_savedEncId_sickvisit. -
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 atsickVisit.js:420which 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:
- Type age / gender / setting.
- Click "Start Recording" — mic engages, live STT begins.
- Use the visible Pause / Stop buttons mid-encounter as needed
(Stop is available even when the record button is hidden — see
liveEncounter.js:119for the dedicated stop path). - On stop, the audio is uploaded to
/api/transcribe; the server transcript replaces the live one. - 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,nativeStartRecordingServiceif running in the Capacitor app (lines 69–71). 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
suggestBillingCodesandsuggestDontMiss(lines 216–217), unlike Dictation HPI which doesn't. partial_datais 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
suggestBillingCodesorsuggestDontMiss— dictation HPIs are intentionally lean. - The HPI prompt is a different system prompt from the Encounter HPI
one.
hpiEncountersays "Generate a professional HPI from the doctor-patient encounter transcript", whilehpiDictationsays "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:49–116:
INFERENCE_RULESis a list of{ keywords, ros, pe }rules.inferSystems(cc)lowercases the chief complaint and OR-matches each rule's keywords. All matched rules'ros+pekeys are unioned.- If nothing matches,
DEFAULT_ROS(4 systems) andDEFAULT_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_DXatshadess.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_RULESblock (inprompts.js:17–48) tells the AI to expand each NORMAL into 1–3 specific pertinent negatives instead of writing "WNL", and to expand ABNORMALs with the most common matching presentation.- After generation, the tab calls both
suggestBillingCodesandsuggestDontMiss(sickVisit.js:341–342). 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 inresetSickVisit()atsickVisit.js:351— it explicitly clears the ROS/PE/Dx state in addition to the form fields. The sharedclearTab()inencounters.js:459is also wired via the save bar's New button, but the in-output reset button uses the more exhaustiveresetSickVisit.
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:7–20):
-
By Visit Age (
wv-panel-byvisit) — pick a visit from the AAP/Bright Futures schedule (VISIT_AGESfrompediatricScheduleData.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 tolocalStorage['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-vaccinesand#wv-screeningstextareas. -
Milestones (
wv-panel-milestones) — handled bypublic/js/milestones.js(out of scope for this doc but cited for completeness). The "Copy to Note" button fills#wv-milestones-textvia the handler atencounters.js:374. -
SSHADESS (
wv-panel-shadess) — only visible for visits inSSHADESS_VISITS = ['12y' … '21y'](wellVisit.js:82). When a younger visit is selected, the sub-tab button is hidden inonVisitChange()(line 192–201) 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, orPROMPTS.wellVisitShort(prompts.js:354) for brief SOAP — selected bynoteStylefrom#wv-note-style.
The SSHADESS form (shadess.js:9–119, 124–192) is 8 domains —
Strengths, School, Home, Activities, Drugs/Substances, Emotions/
Eating, Sexuality, Safety — each with 3–6 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 totranscribeAudioand the result is stored in_shadessTranscript, which is sent in thedictationTextfield 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 thetranscriptfield on the Visit Note endpoint.
Server-side enrichment (wellVisit.js:135–215): 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(seeshadess.js:382). - After Visit Note generation, only
suggestBillingCodesis 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'srenderFullScheduleandrenderCatchUp— they are siblings, not sub-tabs (seewellVisit.js:722–727for 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:21–23 in the route).
User flow:
- Type age, gender, pick "Full SOAP" or "Subjective Only".
- Click Dictate → mic engages. Stop when done. Transcript fills
#soap-transcript. - (Optional) Type additional AI instructions in
#soap-instructions. - Click Generate →
#soap-textpopulates.
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
suggestBillingCodesonly — no don't-miss card. additionalInstructionsis 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 frompartial_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:34–51.
Purpose: Build an organised hospital course from a stack of admission documents.
User flow:
- Patient demographics + setting (floor/PICU/NICU/psych) + LOS + format.
- (Optional) Paste an ED note in the dedicated card with a date.
- (Optional) Paste an H&P with a date.
- 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
AudioRecorderviasetupDictateButton()athospitalCourse.js:52). - Add labs as date-stamped entries.
- Optional free-text additional instructions.
- 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:
Clarifybutton (#hc-clarify-btn) hits/api/clarify(handled insrc/routes/refine.js:67) which usesPROMPTS.askClarificationto surface what's missing from the draft.additionalInstructionsis treated as trusted, like SOAP.- After generation calls
suggestBillingCodesonly.
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:
- Patient demographics + PMH + review type.
- 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.
- Add additional date-stamped labs not tied to a visit.
- Optional additional instructions.
- Generate.
Client-side guards (chartReview.js:127–135):
- Warns if estimated payload >8 MB (the express
bodyParser.jsoncap is 10 MB). - Toasts a "this may take a moment" notice if >30 notes.
Quirks:
- After generation calls
suggestBillingCodesonly (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 aswindow.resetChartReviewso the sharedclearTab('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:
- Validates a non-empty label client-side.
- De-dupes double-clicks via
_savingInProgress[type]flag. - POSTs
/api/encounters/saved. - Handles 409 (concurrent edit detected) with a toast asking the user to reload.
- On success, stashes the new version in
window._encounterVersions[id]and the new ID in bothwindow._savedEncId_<type>andsessionStorage['_savedEncId_' + type]. - Calls
loadSavedEncountersList()to refresh the saved-encounters UI (settings page + any open load-popover).
4.2 Optimistic versioning
Server side (src/routes/encounters.js:75–104):
- Every saved encounter row has an integer
versioncolumn. - On update, if the client sends
expected_versionin the body, the server compares it to the currentversion. 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:119–139):
- On a new-row POST (no
id), if anidempotency_keyis 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:97–104 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 viatabBtn.click(), and after a 200 mssetTimeout(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
tabMapanddomPfxMapandnoteIdMapinresumeEncounter(). - 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):
- Reads the output
<div>'s.innerTextascurrentDocument. - Reads the user's instructions from the textarea.
- If
outputEl.dataset.sourceContextexists (set bystoreSourceContext()after generation), includes it assourceContext. - POSTs
/api/refinewith{currentDocument, instructions, sourceContext, model}. - Server (
src/routes/refine.js:14) wraps bothcurrentDocumentandinstructionswithwrapUserText(), prependssourceContextas "ORIGINAL SOURCE MATERIAL" so the AI can do data lookups, runsPROMPTS.refine, and returns the rewritten text. - 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-carddirectly after the output<div>. - POSTs
/api/suggest-codeswith 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:- 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. - ICD-10 lookup: each extracted term is checked against
COMMON_ICD10(a 47-entry pediatric local map atbilling.js:81); on miss, it falls back to NLM Clinical Tables (lookupICD10,billing.js:271) with a 5 s timeout. - CPT selection: based on
noteType.wellvisit→ age-bucketed99391–99395.hospital+ admission →99221–99223, discharge →99238/99239, otherwise subsequent99231–99233. ED →99281–99285. Outpatient → new vs established detection. - 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.
- Diagnosis extraction (
- 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-misswith{noteText, noteType, patientAge, chiefComplaint, model}. - Server (
src/routes/dontMiss.js) runsPROMPTS.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 0–5 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):
- Look up an existing container with id
<outputId-without-text> + '-dont-miss'(e.g.sick-note-dont-miss). - If missing, create a
div, set.dont-miss-cardclass + inline left-border style, andparentNode.insertBeforeit directly after the output<div>. - Show a spinner placeholder.
- After fetch, replace innerHTML with the final content.
- 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:
-
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.
-
The returned string is passed in the request body as
physicianMemories. -
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_HINTStags). -
The system-prompt's
INJECTION_GUARDensures 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 HPIvoiceDictation.js:154— Dictation HPIsickVisit.js:309— Sick Visitshadess.js:882— Well Visit Notesoap.js:170— SOAPhospitalCourse.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
beforeunloadflush viafetch({keepalive: true}). - Three view states (
list,reader,editor) that on mobile (<900 px) take turns occupying the screen via the layout'sdata-viewattribute. - 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 throughsetRecUI(state)(notes.js:670). -
Stop has TWO modes:
silent(cancel cleanly without transcribing — used when the user navigates away) andnot silent(the normal Stop button — transcribes and generates). -
The recent fix in
66f319e(mentioned in the recent commits) wraps the click handler so the clickEventdoesn't get truthily passed assilent: truetostopRecording:if (recStop) recStop.addEventListener('click', function() { stopRecording(false); });Without the wrapper,
addEventListenerwould pass theEventas 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
DOMPurifywith a strict allowlist (notes.js:861). If DOMPurify fails to load,sanitizeHtmlrefuses 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.encryptStringused for saved encounters (src/routes/notes.js:113, :138). - Trash mechanics: DELETE soft-deletes (sets
deleted_at = NOW()), POST/restoreclearsdeleted_at, DELETE with?hard=1only removes rows that are already in trash (so a UI bug can't accidentally erase active notes), POST/trash/emptydeletes 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:
AudioRecorderclass inapp.js:659–683—getUserMedia+MediaRecordersetup.transcribeAudio(blob)inapp.js:739–795and the_serverTranscribe(blob)helper.- 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). 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-startedevents, some don't; Sick Visit's pause logic handlesinactiveMediaRecorder 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 whenstatebecomesinactive(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/.stopsemantics.- The dual-track live-STT + uploaded-audio flow.
- The per-tab pause/resume + MediaRecorder-state fallback.
- The
_transcribeAvailableprobe 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.
-
HTML component — create
public/components/<name>.html. Use the Sick Visit component as a template. Required structure:.module-headerwith 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(initiallyhidden),<pfx>-text(or<pfx>-note-textetc — must match what you register innoteIdMapbelow),<pfx>-model-tag. - Refine bar:
<pfx>-refine-input,<pfx>-refine-btn,<pfx>-shorten-btn.
-
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-componentvalue is the filename (without.html) underpublic/components/. -
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>topublic/index.html(the script load order is fixed; check the existing block). -
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.jsnear related ones, following the style — start with${CORE_RULES}and (if relevant)${ROS_PE_RULES}, list required sections, end with constraints. -
Mount in
server.js— add a line in the existing block (around line 280):app.use('/api', require('./src/routes/<name>')); -
Register in
encounters.jsfor save/resume (NB: this is the minimum sacred-file change; treat as "additive, narrow"):-
Add the new
enc_typeto the sessionStorage restore list (encounters.js:98):['encounter','dictation','ed','hospital','chart','wellvisit', 'sickvisit','soap','<name>'].forEach(...); -
Add to
tabMap,domPfxMap,noteIdMapinresumeEncounter()(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,outputElMapinclearTab()(lines 460–473) 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.
-
-
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()andINJECTION_GUARDused 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.