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.
1546 lines
64 KiB
Markdown
1546 lines
64 KiB
Markdown
# 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 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)`:
|
||
|
||
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:659–683`:
|
||
|
||
- `start()` calls `getUserMedia({ audio: { channelCount: 1, sampleRate:
|
||
16000, echoCancellation: true, noiseSuppression: true } })`, picks
|
||
`audio/webm;codecs=opus` (fallback `audio/webm`), runs at 32 kbps
|
||
Opus, and pushes 1-second chunks into `this.chunks`.
|
||
- `stop()` finalises a `Blob`, stops the mic tracks, and resolves the
|
||
Blob.
|
||
|
||
Each recording tab (Encounter, Dictation, Sick, Well-Visit, SHADESS,
|
||
SOAP) holds **its own** `AudioRecorder` instance. They also each
|
||
instantiate `createSpeechRecognition()` (`app.js:1007`) — the browser's
|
||
`webkitSpeechRecognition` if available — for live preview text while
|
||
the recorder buffers audio.
|
||
|
||
The browser's interim transcript is shown greyed-out as the user talks;
|
||
when the user stops, the buffered audio is uploaded to the server for a
|
||
proper transcription. Two reasons for the dual-track approach:
|
||
|
||
- The browser's STT is good enough to give live feedback so the
|
||
physician knows the mic is working.
|
||
- Server STT (Whisper / Gemini / Amazon Transcribe Medical / etc.) is
|
||
much more accurate, especially for medical jargon, but only returns
|
||
after the recording ends.
|
||
|
||
If the recording exceeds 24 MB, the audio upload is skipped and the
|
||
browser-STT transcript is used as-is (e.g. `liveEncounter.js:101`).
|
||
|
||
Each tab also dispatches `recording-started` and `recording-stopped`
|
||
`CustomEvent`s with `detail.module = 'enc' | 'dict' | 'soap' | 'sick'`
|
||
so other modules (currently encounters.js' pause-button helper) can
|
||
react.
|
||
|
||
### 2.3 The transcribe call
|
||
|
||
`transcribeAudio(blob)` in `public/js/app.js: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 by
|
||
`src/routes/transcribe.js` — out of scope here). The server picks
|
||
whichever provider the admin configured: Gemini, AWS Transcribe
|
||
Medical, OpenAI Whisper, Local Whisper, or LiteLLM.
|
||
- On any failure with a non-empty Blob, the audio is auto-saved via
|
||
`saveAudioBackup(blob, 'failed-transcription')` (`audioBackup.js`)
|
||
so the user can retry from the Settings page.
|
||
|
||
A startup probe (`checkTranscribeStatus`, `app.js:722`) queries
|
||
`/api/transcribe/status` and stores the result in
|
||
`window._transcribeAvailable`. If the server reports `false`, every tab
|
||
short-circuits the upload path and just uses the live-browser
|
||
transcript instead.
|
||
|
||
### 2.4 The generate call
|
||
|
||
Every tab assembles a payload and POSTs it to its own endpoint. The
|
||
shared parts:
|
||
|
||
- Demographics (`patientAge`, `patientGender`, sometimes `setting`).
|
||
- The transcript or dictation text (read from the contenteditable
|
||
`<div>` via `.innerText`).
|
||
- An optional `physicianMemories` blob, fetched ahead of the call via
|
||
`getUserMemoryContext()` (`memories.js:166`) which hits
|
||
`/api/memories/context`.
|
||
- A `model` string from `getSelectedModel()` or the per-tab
|
||
`<select class="tab-model-select">`.
|
||
|
||
The route handler builds the system + user messages and calls
|
||
`callAI()` (`src/utils/ai.js`, multi-provider router). Every route also
|
||
runs the user-derived text through `wrapUserText(label, text)` from
|
||
`src/utils/promptSafe.js`, which wraps it in
|
||
`<UNTRUSTED_LABEL>…</UNTRUSTED_LABEL>` and appends the standard
|
||
`INJECTION_GUARD` so the model treats the text as data, not
|
||
instructions.
|
||
|
||
The result is rendered into the output `<div>` via `setOutputText(el,
|
||
text)` (`app.js:342`). The model name is shown in a small `model-tag`
|
||
chip beside the output card.
|
||
|
||
### 2.5 Post-generation helpers
|
||
|
||
After the note is rendered, several tabs trigger optional follow-ups:
|
||
|
||
- `storeSourceContext(outputId, sourceText)` — stashes the original
|
||
transcript onto `outputEl.dataset.sourceContext` so the Refine call
|
||
can reference the original facts (not just the AI's rewrite).
|
||
- `suggestBillingCodes(outputId, noteText, noteType, age, visitType)`
|
||
(`app.js:804`) — POSTs to `/api/suggest-codes` and mounts a
|
||
`.billing-codes-card` with ICD-10 + CPT chips.
|
||
- `suggestDontMiss(outputId, noteText, noteType, age, cc)`
|
||
(`app.js:894`) — POSTs to `/api/dont-miss` and mounts an
|
||
orange-bordered card with up to 5 high-yield "don't miss" items.
|
||
|
||
Refine and Shorten use the same pattern: `refineDocument(outputId,
|
||
inputId)` (`app.js:946`) and `shortenDocument(outputId)` (`app.js:971`)
|
||
read the output `<div>`'s `.innerText`, send it to `/api/refine` or
|
||
`/api/shorten`, and replace the output's content.
|
||
|
||
### 2.6 The save flow
|
||
|
||
Every tab (except Personal Notes, which has its own CRUD) wires its
|
||
Save button to `window.saveEncounter()` exposed by
|
||
`public/js/encounters.js:113`. The flow:
|
||
|
||
1. User types a `label` (e.g. "John D, 5y fever") in the tab's
|
||
`*-label` input.
|
||
2. Save button click calls `saveFromTab(type, prefix)` in
|
||
`encounters.js:431`, which reads the transcript and the generated
|
||
note, attaches the cached `_savedEncId_<type>` (so updates hit the
|
||
same row), generates / reuses an `idempotency_key` from
|
||
sessionStorage, and POSTs `/api/encounters/saved`.
|
||
3. The server (`src/routes/encounters.js`) encrypts every text field
|
||
with the AES-256-GCM helper, applies optimistic versioning, and
|
||
returns the row ID + new version.
|
||
4. Client stashes the ID in both `window._savedEncId_<type>` and
|
||
`sessionStorage['_savedEncId_' + type]` so a page refresh won't
|
||
create a duplicate row.
|
||
|
||
Resume works in reverse: the load popover hits `/api/encounters/saved`
|
||
to list, then `/api/encounters/saved/:id` for the full row. The client
|
||
navigates to the right tab via `activateTab()`, then calls the
|
||
tab-specific load handler registered through
|
||
`registerEncounterLoadHandler(type, fn)`.
|
||
|
||
### 2.7 End-to-end example: Sick Visit
|
||
|
||
To make this concrete, here is one full Sick Visit cycle, with the
|
||
files and lines involved:
|
||
|
||
1. **User clicks Sick Visit tab.**
|
||
`app.js:97` → `activateTab('sickvisit')` →
|
||
`loadComponent(tabEl)` fetches
|
||
`/components/sickvisit.html` → injects HTML → fires `tabChanged`.
|
||
`sickVisit.js:439` catches the event, runs `initRecording()`,
|
||
`populateAddDropdowns()`, `renderRosPe()`, `initDx()`.
|
||
|
||
2. **User types the chief complaint** (`#sick-cc`) and clicks
|
||
`#btn-sick-infer`. `inferAndRender()` (`sickVisit.js:272`) walks
|
||
the `INFERENCE_RULES` array (lines 49–80) — e.g. "fever" matches
|
||
`['constitutional', 'ent', 'respiratory', 'gastrointestinal']`
|
||
ROS systems and 7 PE systems — and re-renders the rows.
|
||
|
||
3. **User clicks "Listen In"** (`#sick-record-btn`). The handler at
|
||
`sickVisit.js:155` instantiates a fresh `AudioRecorder`, starts
|
||
the browser `SpeechRecognition`, and shows the live transcript in
|
||
`#sick-transcript`. The `pulse-dot` indicator goes red and the
|
||
`00:00` timer starts.
|
||
|
||
4. **User clicks "Stop"**. `sickVisit.js:170` stops the recorder,
|
||
gets the Blob, calls `transcribeAudio(blob)`. On success, the
|
||
server transcript replaces the live one in `#sick-transcript`.
|
||
|
||
5. **User clicks each ROS row's WNL/Abnormal button.** The shared
|
||
`wireRosContainer()` (`shadess.js:460`) is what handles these —
|
||
it lives in the SHADESS module because both sick visit and well
|
||
visit share the row-renderer pair `renderRosRows` /
|
||
`wireRosContainer` (both exposed on `window`).
|
||
|
||
6. **User clicks Generate.** `generateSickNote()` (`sickVisit.js:292`)
|
||
reads everything: transcript, ROS data, PE data, selected
|
||
diagnoses (via `formatDxForAI` in `shadess.js`), picks up the
|
||
user's saved templates from `getUserMemoryContext()`, and POSTs
|
||
`/api/sick-visit/note`.
|
||
|
||
7. **Server route** (`src/routes/sickVisit.js:14`) builds the user
|
||
message:
|
||
- `Patient: 5 years, Female`
|
||
- `Chief Complaint: <UNTRUSTED_CHIEF_COMPLAINT>fever</UNTRUSTED_CHIEF_COMPLAINT>`
|
||
- `ENCOUNTER TRANSCRIPT/DICTATION: <UNTRUSTED_TRANSCRIPT>…</UNTRUSTED_TRANSCRIPT>`
|
||
- `<UNTRUSTED_ROS>…</UNTRUSTED_ROS>`
|
||
- `<UNTRUSTED_PHYSICAL_EXAM>…</UNTRUSTED_PHYSICAL_EXAM>`
|
||
- `<UNTRUSTED_DIAGNOSES>…</UNTRUSTED_DIAGNOSES>`
|
||
- And, if present, a low-priority STYLE HINTS block from the
|
||
user's memories.
|
||
|
||
System prompt = `PROMPTS.sickVisitNote + INJECTION_GUARD`. Calls
|
||
`callAI()`. Logs an audit row.
|
||
|
||
8. **Client gets the response.** `sickVisit.js:329`:
|
||
- `setOutputText(noteEl, data.note)` writes the note into
|
||
`#sick-note-text`.
|
||
- `storeSourceContext('sick-note-text', transcript)` saves the
|
||
transcript on the dataset for future Refine calls.
|
||
- `outputCard.classList.remove('hidden')` reveals the card.
|
||
- `suggestBillingCodes('sick-note-text', data.note, 'sickvisit',
|
||
age)` mounts the billing card.
|
||
- `suggestDontMiss('sick-note-text', data.note, 'sickvisit', age,
|
||
cc)` mounts the don't-miss card.
|
||
|
||
9. **User types a label** (`#sick-label = "John D 5y fever"`) and
|
||
clicks Save. `sickVisit.js:401` calls
|
||
`saveEncounter({id, label, enc_type: 'sickvisit',
|
||
transcript, generated_note, onSaved})`.
|
||
Server encrypts and stores; client caches the ID in
|
||
`_savedEncId_sickvisit`.
|
||
|
||
10. **User comes back tomorrow**, clicks Load, picks the saved entry.
|
||
`encounters.js:287 resumeEncounter(id, 'sickvisit')` fetches the
|
||
row, navigates to the Sick Visit tab, runs the registered load
|
||
handler at `sickVisit.js:420` which restores transcript and note.
|
||
|
||
That's the full cycle. Every other tab does a variant of the same
|
||
sequence — the differences are listed below.
|
||
|
||
---
|
||
|
||
## 3. Per-tab deep dives
|
||
|
||
### 3.1 Encounter HPI — `liveEncounter.js`
|
||
|
||
**File:** `public/js/liveEncounter.js`
|
||
**Component:** `public/components/encounter.html`
|
||
**Tab name:** `encounter`
|
||
**Endpoint:** `POST /api/generate-hpi-encounter` →
|
||
`src/routes/hpi.js:10`
|
||
**Prompt:** `PROMPTS.hpiEncounter` (or `PROMPTS.hpiInpatient` if
|
||
`setting === 'inpatient'`) — both at `src/utils/prompts.js:53` and
|
||
`:70`.
|
||
|
||
**Purpose:** Live recording of a doctor-patient conversation. The AI
|
||
extracts an OLDCARTS HPI from the dialogue.
|
||
|
||
**User flow:**
|
||
|
||
1. Type age / gender / setting.
|
||
2. Click "Start Recording" — mic engages, live STT begins.
|
||
3. Use the visible Pause / Stop buttons mid-encounter as needed
|
||
(Stop is available even when the record button is hidden — see
|
||
`liveEncounter.js:119` for the dedicated stop path).
|
||
4. On stop, the audio is uploaded to `/api/transcribe`; the server
|
||
transcript replaces the live one.
|
||
5. Click Generate → AI produces an HPI, mounted in `#enc-output`.
|
||
|
||
**DOM IDs:**
|
||
|
||
| Element | ID |
|
||
|---|---|
|
||
| Age / gender / setting | `enc-age`, `enc-gender`, `enc-setting` |
|
||
| Record / pause / stop | `enc-record-btn`, `enc-pause-btn`, `enc-stop-btn` |
|
||
| Recording indicator + timer | `enc-recording-indicator`, `enc-timer` |
|
||
| Transcript (contenteditable) | `enc-transcript` |
|
||
| Clear / Generate | `enc-clear`, `enc-generate-btn` |
|
||
| Output card / text / model tag | `enc-output`, `enc-hpi-text`, `enc-model-tag` |
|
||
| Save bar | `enc-label`, `btn-enc-save`, `btn-enc-load`, `btn-enc-new` |
|
||
| Refine / shorten | `enc-refine-input`, `enc-refine-btn`, `enc-shorten-btn` |
|
||
|
||
**Quirks:**
|
||
|
||
- Uses `nativeHaptic`, `nativeKeepAwake`, `nativeStartRecordingService`
|
||
if running in the Capacitor app (lines 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 `suggestBillingCodes` and `suggestDontMiss`
|
||
(lines 216–217), unlike Dictation HPI which doesn't.
|
||
- `partial_data` is round-tripped through JSON in the load handler
|
||
(line 235) to restore age + gender after resume.
|
||
|
||
**On save:** `enc_type='encounter'`, transcript = `#enc-transcript`,
|
||
generated note = `#enc-hpi-text`. Uses the generic `saveFromTab`
|
||
plumbing in `encounters.js:431`.
|
||
|
||
### 3.2 Dictation HPI — `voiceDictation.js`
|
||
|
||
**File:** `public/js/voiceDictation.js`
|
||
**Component:** `public/components/dictation.html`
|
||
**Tab name:** `dictation`
|
||
**Endpoint:** `POST /api/generate-hpi-dictation` (or
|
||
`/api/generate-soap` if user picks SOAP output) →
|
||
`src/routes/hpi.js:35` and `src/routes/soap.js:9`.
|
||
**Prompt:** `PROMPTS.hpiDictation` (`prompts.js:62`) or
|
||
`PROMPTS.hpiInpatient` if setting=inpatient. If user picks
|
||
`soap-full` / `soap-subjective`, switches to `PROMPTS.soapFull` /
|
||
`PROMPTS.soapSubjective`.
|
||
|
||
**Purpose:** The physician dictates a narrative monologue (no patient
|
||
present). AI cleans + restructures it into a polished HPI (or SOAP
|
||
note).
|
||
|
||
**User flow:**
|
||
Same shape as Encounter HPI, but the user is dictating in their own
|
||
voice. The output-type select at `#dict-output-type` lets them pick
|
||
HPI, full SOAP, or subjective-only — the Generate handler branches the
|
||
endpoint at `voiceDictation.js:157`.
|
||
|
||
**DOM IDs:** Mirror the Encounter tab with `dict-` prefix:
|
||
`dict-record-btn`, `dict-pause-btn`, `dict-transcript`,
|
||
`dict-generate-btn`, `dict-output`, `dict-hpi-text`, `dict-age`,
|
||
`dict-gender`, `dict-setting`, `dict-output-type`, `dict-label`, etc.
|
||
|
||
**Quirks:**
|
||
|
||
- No dedicated Stop button (unlike Encounter / SOAP). Stop is the
|
||
same record button toggling state.
|
||
- After generation does NOT call `suggestBillingCodes` or
|
||
`suggestDontMiss` — dictation HPIs are intentionally lean.
|
||
- The HPI prompt is a different system prompt from the Encounter HPI
|
||
one. `hpiEncounter` says "Generate a professional HPI from the
|
||
doctor-patient encounter transcript", while `hpiDictation` says
|
||
"Restructure physician dictation into a polished HPI" — the latter
|
||
is more aggressive about reorganising and polishing.
|
||
|
||
**On save:** `enc_type='dictation'`, transcript = `#dict-transcript`,
|
||
generated note = `#dict-hpi-text`.
|
||
|
||
### 3.3 Sick Visit — `sickVisit.js` + `shadess.js`
|
||
|
||
**File:** `public/js/sickVisit.js` (450 lines, biggest single-tab
|
||
module after shadess+wellVisit).
|
||
**Component:** `public/components/sickvisit.html`
|
||
**Tab name:** `sickvisit`
|
||
**Endpoint:** `POST /api/sick-visit/note` → `src/routes/sickVisit.js`
|
||
**Prompt:** `PROMPTS.sickVisitNote` (`prompts.js:364`).
|
||
|
||
**Purpose:** Quick sick visit documentation. The user types a chief
|
||
complaint, the app auto-suggests which ROS systems and PE systems are
|
||
worth reviewing (4 ROS + 7 PE by default), the user toggles each as
|
||
WNL / Abnormal / Not reviewed (typing a free-text descriptor for
|
||
abnormal), then records or dictates the encounter, picks ICD-10 codes,
|
||
and clicks Generate.
|
||
|
||
**The auto-inference logic** (the Sick Visit's distinguishing feature)
|
||
lives at `sickVisit.js:49–116`:
|
||
|
||
- `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: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** `suggestBillingCodes` and
|
||
`suggestDontMiss` (`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 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:7–20`):
|
||
|
||
- **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 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, or `PROMPTS.wellVisitShort`
|
||
(`prompts.js:354`) for brief SOAP — selected by `noteStyle` from
|
||
`#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 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: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` (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:722–727` for the per-tab init guard).
|
||
- `localStorage['ped_visit_statuses']` keys are `<visitId>.<itemKey>`,
|
||
e.g. `12y.vax_HPV_d1` → `{status: 'Given', note: 'Lot ABC123'}`.
|
||
This survives reloads but is intentionally per-browser, not synced
|
||
to the server.
|
||
|
||
**On save:** `enc_type='wellvisit'`, transcript = `#wv-transcript`,
|
||
generated note = `#wv-note-text`. The save bar wires the standard
|
||
flow; the output card's Save button (separately wired in
|
||
`shadess.js:963`) does the same thing.
|
||
|
||
### 3.5 SOAP — `soap.js`
|
||
|
||
**File:** `public/js/soap.js`
|
||
**Component:** `public/components/soap.html`
|
||
**Tab name:** `soap`
|
||
**Endpoint:** `POST /api/generate-soap` → `src/routes/soap.js:9`
|
||
**Prompt:** `PROMPTS.soapFull` (`prompts.js:168`) or
|
||
`PROMPTS.soapSubjective` (`prompts.js:176`), selected by
|
||
`#soap-type`.
|
||
|
||
**Purpose:** Generate a full SOAP note (or just the Subjective
|
||
section) from a transcript or dictated text. There's a free-form
|
||
"additional instructions" field that gets concatenated to the system
|
||
prompt as **trusted operator-supplied** instructions (i.e. NOT wrapped
|
||
in `UNTRUSTED_*` — the user is operating their own scribe, so their
|
||
free-text is treated as a prompt extension; see `soap.js:181`,
|
||
`soap.js:21–23` in the route).
|
||
|
||
**User flow:**
|
||
|
||
1. Type age, gender, pick "Full SOAP" or "Subjective Only".
|
||
2. Click Dictate → mic engages. Stop when done. Transcript fills
|
||
`#soap-transcript`.
|
||
3. (Optional) Type additional AI instructions in `#soap-instructions`.
|
||
4. Click Generate → `#soap-text` populates.
|
||
|
||
**DOM IDs:** `soap-age`, `soap-gender`, `soap-type`,
|
||
`soap-instructions`, `soap-record-btn`, `soap-pause-btn`,
|
||
`soap-stop-btn`, `soap-recording-indicator`, `soap-timer`,
|
||
`soap-transcript`, `soap-clear`, `soap-generate-btn`, `soap-output`,
|
||
`soap-text`, `soap-model-tag`, `soap-label`, `soap-refine-btn`,
|
||
`soap-shorten-btn`, `soap-refine-input`.
|
||
|
||
**Quirks:**
|
||
|
||
- Has the same dedicated Stop button as Encounter HPI (`soap.js:113`).
|
||
- After generation calls `suggestBillingCodes` only — no don't-miss
|
||
card.
|
||
- `additionalInstructions` is the only "trusted" free-text injection
|
||
in the clinical-notes routes; everything else is wrapped. This is
|
||
fine because it's the physician's own text in a single-user app.
|
||
- Load handler (`soap.js:207`) restores age, gender, AND type from
|
||
`partial_data`.
|
||
|
||
**On save:** `enc_type='soap'`, transcript = `#soap-transcript`,
|
||
generated note = `#soap-text`.
|
||
|
||
### 3.6 Hospital Course — `hospitalCourse.js`
|
||
|
||
**File:** `public/js/hospitalCourse.js`
|
||
**Component:** `public/components/hospital.html`
|
||
**Tab name:** `hospital`
|
||
**Endpoint:** `POST /api/generate-hospital-course` →
|
||
`src/routes/hospitalCourse.js:10`
|
||
**Prompt:** picked by setting + LOS + `formatPreference`:
|
||
|
||
| Format | Prompt key | When |
|
||
|---|---|---|
|
||
| `prose` | `hospitalCourseShort` | default; LOS ≤ 3 |
|
||
| `dayByDay` | `hospitalCourseLong` | LOS > 3, format=auto |
|
||
| `organSystem` | `hospitalCourseICU` | setting=picu/nicu |
|
||
| `psych` | `hospitalCoursePsych` | setting=psych |
|
||
|
||
User can override via `#hc-format`. Selected at
|
||
`src/routes/hospitalCourse.js:34–51`.
|
||
|
||
**Purpose:** Build an organised hospital course from a stack of
|
||
admission documents.
|
||
|
||
**User flow:**
|
||
|
||
1. Patient demographics + setting (floor/PICU/NICU/psych) + LOS +
|
||
format.
|
||
2. (Optional) Paste an ED note in the dedicated card with a date.
|
||
3. (Optional) Paste an H&P with a date.
|
||
4. Add as many Progress Notes as needed (each with date and type:
|
||
attending / resident / NP / consult / procedure / discharge).
|
||
Each card has its own contenteditable body and its own Dictate
|
||
button (sets up its own per-card `AudioRecorder` via
|
||
`setupDictateButton()` at `hospitalCourse.js:52`).
|
||
5. Add labs as date-stamped entries.
|
||
6. Optional free-text additional instructions.
|
||
7. Generate → AI produces the course in the chosen format.
|
||
|
||
The generate POST sends a structured payload: `notes` (array),
|
||
`edNote`, `hAndP`, `labs` (array), demographics, setting, LOS,
|
||
formatPreference, additionalInstructions, model, physicianMemories.
|
||
The server sorts the notes by date before assembling the user
|
||
message.
|
||
|
||
**Extras:**
|
||
|
||
- `Clarify` button (`#hc-clarify-btn`) hits `/api/clarify` (handled
|
||
in `src/routes/refine.js:67`) which uses `PROMPTS.askClarification`
|
||
to surface what's missing from the draft.
|
||
- `additionalInstructions` is treated as trusted, like SOAP.
|
||
- After generation calls `suggestBillingCodes` only.
|
||
|
||
**DOM IDs:** `hc-age`, `hc-gender`, `hc-pmh`, `hc-setting`, `hc-los`,
|
||
`hc-format`, `hc-instructions`, `hc-add-note`, `hc-notes-container`,
|
||
`hc-add-lab`, `hc-labs-container`, `hc-ed-content`, `hc-ed-date`,
|
||
`hc-ed-labs`, `hc-hp-content`, `hc-hp-date`, `hc-generate-btn`,
|
||
`hc-output`, `hc-course-text`, `hc-format-tag`, `hc-model-tag`,
|
||
`hc-clarify-btn`, `hc-clarify-output`, `hc-refine-btn`,
|
||
`hc-shorten-btn`, `hc-refine-input`.
|
||
|
||
**On save:** `enc_type='hospital'`. Note: this tab does NOT save the
|
||
transcript (there isn't one) — only `generated_note`. The pasted
|
||
notes themselves are not persisted; if the user resumes the encounter,
|
||
they get the AI's hospital course back but not the per-note source
|
||
material. This is intentional — pasted PHI shouldn't sit in the saved
|
||
encounter row indefinitely.
|
||
|
||
### 3.7 Chart Review — `chartReview.js`
|
||
|
||
**File:** `public/js/chartReview.js`
|
||
**Component:** `public/components/chart.html`
|
||
**Tab name:** `chart`
|
||
**Endpoint:** `POST /api/generate-chart-review` →
|
||
`src/routes/chartReview.js:10`
|
||
**Prompt:** picked by `cr-type`:
|
||
|
||
| Type | Prompt key |
|
||
|---|---|
|
||
| `outpatient` | `chartReviewOutpatient` (`prompts.js:136`) |
|
||
| `subspecialty` | `chartReviewSubspecialty` (`prompts.js:148`) |
|
||
| `ed` | `chartReviewED` (`prompts.js:158`) |
|
||
|
||
The prompt-selection key is the **top-level review type**, not the
|
||
per-visit type — see the comment at `src/routes/chartReview.js:27`.
|
||
This means an outpatient chart review can include subspecialty and
|
||
ED visits (and they'll all be rolled into one outpatient-style
|
||
summary).
|
||
|
||
**Purpose:** Summarise a stack of pasted notes for precharting. No
|
||
recording — pure paste-in.
|
||
|
||
**User flow:**
|
||
|
||
1. Patient demographics + PMH + review type.
|
||
2. Add as many Visit cards as needed. Each has a date, a per-visit
|
||
type (outpatient / subspecialty / ED), specialist name + specialty
|
||
(only relevant if subspecialty), pasted content, and per-visit
|
||
labs.
|
||
3. Add additional date-stamped labs not tied to a visit.
|
||
4. Optional additional instructions.
|
||
5. Generate.
|
||
|
||
**Client-side guards** (`chartReview.js:127–135`):
|
||
|
||
- 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:75–104`):
|
||
|
||
- 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:119–139`):
|
||
|
||
- 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: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 via `tabBtn.click()`, and after a 200 ms `setTimeout`
|
||
(giving the lazy-loaded component HTML time to mount), calls the
|
||
registered load handler from `_encLoadHandlers[enc_type]`.
|
||
- Each tab module registers its handler via
|
||
`registerEncounterLoadHandler('sickvisit', function(enc) {...})`.
|
||
|
||
The 200 ms delay is a best-effort wait — see the comment at
|
||
`encounters.js:330`. If the component is already cached, this is
|
||
mostly a no-op; on first-load, it covers the fetch + parse.
|
||
|
||
### 4.6 SACRED — do not refactor
|
||
|
||
Per the project memory, `public/js/encounters.js` is sacred. Even
|
||
pre-approved changes get rejected if they refactor save/idempotency.
|
||
The reason: the save flow is the one place every clinical tab
|
||
converges, the idempotency + versioning logic is subtle, and silent
|
||
regressions here would cost clinical work. Any change must be
|
||
narrowly scoped, named, and approved per-change.
|
||
|
||
Concretely off-limits without explicit approval:
|
||
|
||
- The save POST body shape.
|
||
- The 409 handling.
|
||
- The idempotency-key generation / storage.
|
||
- The sessionStorage restore array (extending it for a new note type
|
||
is fine, but only as an additive change).
|
||
- The `tabMap` and `domPfxMap` and `noteIdMap` in
|
||
`resumeEncounter()`.
|
||
- The dual-listener wiring in `clearTab` / save bar / output-card
|
||
buttons.
|
||
|
||
---
|
||
|
||
## 5. Helper trio — refine, billing-codes, don't-miss
|
||
|
||
These are post-generation features. Each lives in `app.js`, posts to
|
||
its own endpoint, and renders into the same `.output-card` container
|
||
as the note.
|
||
|
||
### 5.1 Refine + Shorten
|
||
|
||
`refineDocument(outputId, inputId)` (`app.js:946`):
|
||
|
||
1. Reads the output `<div>`'s `.innerText` as `currentDocument`.
|
||
2. Reads the user's instructions from the textarea.
|
||
3. If `outputEl.dataset.sourceContext` exists (set by
|
||
`storeSourceContext()` after generation), includes it as
|
||
`sourceContext`.
|
||
4. POSTs `/api/refine` with `{currentDocument, instructions,
|
||
sourceContext, model}`.
|
||
5. Server (`src/routes/refine.js:14`) wraps both `currentDocument` and
|
||
`instructions` with `wrapUserText()`, prepends `sourceContext` as
|
||
"ORIGINAL SOURCE MATERIAL" so the AI can do data lookups, runs
|
||
`PROMPTS.refine`, and returns the rewritten text.
|
||
6. Client replaces the output content.
|
||
|
||
`shortenDocument(outputId)` (`app.js:971`) is simpler — POSTs to
|
||
`/api/shorten`, runs `PROMPTS.shortenDocument` (target ~50% shorter,
|
||
keep all clinical content), returns rewritten text.
|
||
|
||
`/api/clarify` (used only by Hospital Course's Clarify button) lives
|
||
in the same route file and uses `PROMPTS.askClarification`.
|
||
|
||
### 5.2 Billing codes
|
||
|
||
`suggestBillingCodes(outputId, noteText, noteType, age, visitType)`
|
||
(`app.js:804`) is called by Encounter HPI, Sick Visit, Well Visit
|
||
(via `shadess.js:924`), SOAP, Hospital Course, Chart Review.
|
||
|
||
- Mounts a dynamically created `.billing-codes-card` directly after
|
||
the output `<div>`.
|
||
- POSTs `/api/suggest-codes` with the note text, type, age, and
|
||
visit type.
|
||
- Server (`src/routes/billing.js:291`) does NOT call an LLM — it
|
||
parses the note text deterministically:
|
||
1. **Diagnosis extraction** (`extractDiagnoses`, `billing.js:128`):
|
||
extracts assessment-section text via regex, pulls out any
|
||
existing ICD-10 code patterns (`[A-TV-Z]\d{2}(?:\.\d{1,4})?`),
|
||
numbered items (`1. Otitis media`), bullet items, and "diagnosed
|
||
with X" inline phrases.
|
||
2. **ICD-10 lookup**: each extracted term is checked against
|
||
`COMMON_ICD10` (a 47-entry pediatric local map at
|
||
`billing.js:81`); on miss, it falls back to NLM Clinical Tables
|
||
(`lookupICD10`, `billing.js:271`) with a 5 s timeout.
|
||
3. **CPT selection**: based on `noteType`. `wellvisit` →
|
||
age-bucketed `99391`–`99395`. `hospital` + admission →
|
||
`99221`–`99223`, discharge → `99238/99239`, otherwise subsequent
|
||
`99231`–`99233`. ED → `99281`–`99285`. Outpatient → new vs
|
||
established detection.
|
||
4. **E/M complexity** (`estimateEMLevel`, `billing.js:212`): counts
|
||
ROS / PE systems documented, looks for high-risk keywords
|
||
(admit, sepsis, intubated, etc.), looks for moderate-risk keywords
|
||
(IV fluid, antibiotic, x-ray, etc.) and produces a level + MDM
|
||
complexity.
|
||
- Result rendered as ICD-10 chips (red), CPT chips (blue), and an
|
||
E/M assessment line. Every chip is click-to-copy.
|
||
- ReDoS guard: refuses note text > 20 000 chars.
|
||
|
||
### 5.3 Don't-miss
|
||
|
||
`suggestDontMiss(outputId, noteText, noteType, age, cc)`
|
||
(`app.js:894`) is currently called by Encounter HPI
|
||
(`liveEncounter.js:217`) and Sick Visit (`sickVisit.js:342`).
|
||
|
||
- Mounts a dynamically created card with a 3 px orange left border
|
||
(`#f59e0b`) and a Triangle-Exclamation icon.
|
||
- POSTs `/api/dont-miss` with `{noteText, noteType, patientAge,
|
||
chiefComplaint, model}`.
|
||
- Server (`src/routes/dontMiss.js`) runs `PROMPTS.dontMissTooltip`
|
||
(`prompts.js:505`) which produces strict JSON
|
||
`{points: [{point, why}, …]}`. The route hard-caps the result to 5
|
||
items at line 61 (in addition to the prompt-level "HARD CAP: at
|
||
most 5 points"). If the model returns 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):
|
||
|
||
1. Look up an existing container with id
|
||
`<outputId-without-text> + '-dont-miss'` (e.g.
|
||
`sick-note-dont-miss`).
|
||
2. If missing, create a `div`, set `.dont-miss-card` class + inline
|
||
left-border style, and `parentNode.insertBefore` it directly after
|
||
the output `<div>`.
|
||
3. Show a spinner placeholder.
|
||
4. After fetch, replace innerHTML with the final content.
|
||
5. On empty / error, hide the container.
|
||
|
||
Failures are silent (no toast) so the user isn't pestered if a model
|
||
is slow or returns junk.
|
||
|
||
---
|
||
|
||
## 6. User templates ("memories") flow
|
||
|
||
Daniel's saved templates live in the `user_memories` table (one
|
||
encrypted row per template). The UI is in
|
||
`public/js/memories.js` — Settings page → Physician Memory section.
|
||
|
||
The flow into the system prompt:
|
||
|
||
1. Each tab, just before its Generate POST, calls
|
||
`getUserMemoryContext()` (`memories.js:166`):
|
||
|
||
```
|
||
fetch('/api/memories/context', { headers: getAuthHeaders() })
|
||
.then(r => r.json())
|
||
.then(data => data.success ? data.context : '')
|
||
.catch(() => '');
|
||
```
|
||
|
||
— falling back to an empty string on any failure so the generation
|
||
still works without templates.
|
||
|
||
2. The returned string is passed in the request body as
|
||
`physicianMemories`.
|
||
|
||
3. Server side, the routes append it to the user message wrapped as a
|
||
STYLE HINTS block:
|
||
|
||
```
|
||
[STYLE HINTS (low priority — only apply if relevant to the current
|
||
note, never copy content from these examples)]
|
||
<UNTRUSTED_STYLE_HINTS>
|
||
...templates...
|
||
</UNTRUSTED_STYLE_HINTS>
|
||
[END STYLE HINTS]
|
||
```
|
||
|
||
Always the LAST block before the call, marked low priority, double-
|
||
wrapped (the brackets and the `UNTRUSTED_STYLE_HINTS` tags).
|
||
|
||
4. The system-prompt's `INJECTION_GUARD` ensures the model treats it
|
||
as data, never instructions.
|
||
|
||
The "low priority — only apply if relevant" wording is deliberate.
|
||
Without it, models will sometimes paste a stored template's clinical
|
||
content into a new patient's note. The wording + the wrapper together
|
||
keep memories as STYLE/STRUCTURE hints, not content.
|
||
|
||
Where in each frontend module the fetch happens:
|
||
|
||
- `liveEncounter.js:190` — Encounter HPI
|
||
- `voiceDictation.js:154` — Dictation HPI
|
||
- `sickVisit.js:309` — Sick Visit
|
||
- `shadess.js:882` — Well Visit Note
|
||
- `soap.js:170` — SOAP
|
||
- `hospitalCourse.js:137` — Hospital Course
|
||
- (Chart Review intentionally does NOT fetch — see §3.7.)
|
||
|
||
---
|
||
|
||
## 7. Notes — the odd one out
|
||
|
||
Personal Notes (`public/js/notes.js`, 890 lines) breaks the pattern
|
||
because it isn't a record-and-generate tab; it's a per-user note
|
||
**editor** with optional voice → AI cleanup as one of several entry
|
||
methods.
|
||
|
||
### 7.1 Why it's structurally different
|
||
|
||
The other clinical-notes tabs are all single-form pages. You walk in,
|
||
fill it, click Generate, copy out, optionally Save. There's at most
|
||
one open "encounter" at a time, and the on-disk model is a row in
|
||
`saved_encounters` with a 7-day expiry.
|
||
|
||
Personal Notes is a Notion-style **list / reader / editor** with:
|
||
|
||
- A persistent list of notes (no expiry).
|
||
- Soft-delete to a Trash pane that the user can Restore from or
|
||
Empty.
|
||
- A rich-text editor (Tiptap) instead of a contenteditable + AI-
|
||
generated text.
|
||
- Autosave every 1.2 s (debounced) plus a `beforeunload` flush via
|
||
`fetch({keepalive: true})`.
|
||
- Three view states (`list`, `reader`, `editor`) that on mobile (<900
|
||
px) take turns occupying the screen via the layout's `data-view`
|
||
attribute.
|
||
- The voice → AI flow targets the editor as an alternative entry
|
||
method, not as the main UI.
|
||
|
||
The data model is also different — `personal_notes` (separate table
|
||
from `saved_encounters`), encrypted title + body, one row per note,
|
||
500-note cap per user, soft-delete via `deleted_at IS NOT NULL`.
|
||
|
||
### 7.2 The from-voice endpoint
|
||
|
||
When the user clicks the mic in the editor, `notes.js:536
|
||
startRecording()` engages a fresh `AudioRecorder`. On Stop,
|
||
`notes.js:589` calls `transcribeAudio(blob)` — same shared STT path —
|
||
and POSTs `/api/notes/from-voice` (`src/routes/notes.js:224`).
|
||
|
||
The route's system prompt has a deliberately wider scope than the
|
||
other tabs:
|
||
|
||
> "You turn a voice dictation into a clean, well-structured personal
|
||
> note. The note may be anything: a clinical observation, a shopping
|
||
> list, a reminder, a travel idea, a journal entry. Match the
|
||
> dictation — do not assume it is medical and do not impose clinical
|
||
> structure (assessment/plan, SOAP, etc.) on non-clinical content."
|
||
|
||
This is the "drop the medical scribe framing" change cited in the
|
||
recent commit history (`b8693ba: voice → AI note now produces HTML
|
||
the editor can render`). The previous prompt had the model treat
|
||
every note as clinical. Now Daniel can voice-record anything — a
|
||
grocery list, a paper outline — and get a clean note back without
|
||
the model bolting on assessments and plans.
|
||
|
||
The route returns strict JSON `{title, body}`. The `body` must be
|
||
HTML using only a small allowlist (`<p>`, `<h2>`, `<h3>`, `<strong>`,
|
||
`<em>`, `<u>`, `<ul>`, `<ol>`, `<li>`, `<blockquote>`, `<code>`,
|
||
`<a href>`, `<br>`) — Tiptap can render this directly.
|
||
|
||
`toHtmlBody()` at `src/routes/notes.js:24` is a defensive normaliser:
|
||
|
||
- Strips ` ```html ` / ` ``` ` fences a model might wrap the body in.
|
||
- If the body has any real HTML tag, returns as-is.
|
||
- Otherwise runs through `marked.parse()` — covers the case where the
|
||
model returned markdown despite the prompt.
|
||
- On marked failure, falls back to wrapping plain text in `<p>`.
|
||
|
||
The route also recovers from the model returning extra prose around
|
||
its JSON: line 263 strips fences, line 265 finds the first `{`, line
|
||
267 finds the last `}` and trims everything outside.
|
||
|
||
The client (`notes.js:600`) calls `applyGeneratedNote(title, body)`
|
||
which prefers `Tiptap.commands.setContent()` over a remount (avoids
|
||
toolbar flicker) and marks the note dirty so the autosave kicks in.
|
||
|
||
### 7.3 The recorder lifecycle in notes
|
||
|
||
Notes uses the same `AudioRecorder` class as the other tabs but with
|
||
its own UI lifecycle:
|
||
|
||
- Button states: `idle` → `recording` → `paused` (optional) →
|
||
`processing` → `idle`. State changes are routed through
|
||
`setRecUI(state)` (`notes.js:670`).
|
||
- Stop has TWO modes: `silent` (cancel cleanly without transcribing —
|
||
used when the user navigates away) and `not silent` (the normal
|
||
Stop button — transcribes and generates).
|
||
- The recent fix in `66f319e` (mentioned in the recent commits) wraps
|
||
the click handler so the click `Event` doesn't get truthily passed
|
||
as `silent: true` to `stopRecording`:
|
||
|
||
```
|
||
if (recStop) recStop.addEventListener('click',
|
||
function() { stopRecording(false); });
|
||
```
|
||
|
||
Without the wrapper, `addEventListener` would pass the `Event` as
|
||
the first arg, which is truthy, and silent-cancel would always be
|
||
taken — i.e. Stop would do nothing.
|
||
|
||
### 7.4 Other notes-only details
|
||
|
||
- The pane state lives in module-locals
|
||
(`_notes`, `_trash`, `_pane`, `_activeId`, `_editor`, `_dirty`,
|
||
etc.). The note-list always pulls both active and trash in parallel
|
||
(`refreshList()`, `notes.js:216`) so the trash count badge stays
|
||
current.
|
||
- Sanitisation of saved HTML on render uses `DOMPurify` with a strict
|
||
allowlist (`notes.js:861`). If DOMPurify fails to load,
|
||
`sanitizeHtml` refuses to render HTML and falls back to plain text
|
||
rather than risking an XSS via a homegrown sanitiser.
|
||
- Save shortcuts: Ctrl/Cmd+S in the editor calls
|
||
`saveNote({source: 'shortcut', returnToReader: true})`.
|
||
- Server-side, every title and body is AES-256-GCM encrypted at rest
|
||
via the same `cryptoUtil.encryptString` used for saved encounters
|
||
(`src/routes/notes.js:113, :138`).
|
||
- Trash mechanics: DELETE soft-deletes (sets `deleted_at = NOW()`),
|
||
POST `/restore` clears `deleted_at`, DELETE with `?hard=1` only
|
||
removes rows that are already in trash (so a UI bug can't
|
||
accidentally erase active notes), POST `/trash/empty` deletes every
|
||
trashed row for the user.
|
||
|
||
---
|
||
|
||
## 8. Sacred zones
|
||
|
||
Two areas of the codebase are explicitly off-limits for refactoring,
|
||
based on Daniel's stated rules:
|
||
|
||
### 8.1 `public/js/encounters.js` — save / idempotency
|
||
|
||
Already covered in §4.6. The summary: every change to encounters.js
|
||
must be:
|
||
|
||
- Per-change explicitly approved.
|
||
- Narrowly scoped — additive only when possible.
|
||
- Reviewed for impact on save POST shape, version handling,
|
||
idempotency, sessionStorage restore, and the tabMap/domPfxMap/
|
||
noteIdMap.
|
||
|
||
Refactors that "tidy up" encounters.js are rejected on principle,
|
||
even if the rewrite preserves behaviour. Reason: the save flow is
|
||
clinically load-bearing, and silent regressions are expensive.
|
||
|
||
### 8.2 Voice / STT plumbing
|
||
|
||
The recording-and-transcription flow is:
|
||
|
||
1. `AudioRecorder` class in `app.js:659–683` — `getUserMedia` +
|
||
`MediaRecorder` setup.
|
||
2. `transcribeAudio(blob)` in `app.js:739–795` 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 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.
|
||
|
||
7. **Test the round-trip**: tab loads, recording works (if used),
|
||
generate hits the route, save persists, page-refresh + Save
|
||
doesn't duplicate, Load → Resume restores the state, New clears
|
||
everything, idempotency_key resets on New.
|
||
|
||
---
|
||
|
||
## Appendix — file map (quick reference)
|
||
|
||
Frontend:
|
||
|
||
| Tab | JS module | HTML component | Save type |
|
||
|---|---|---|---|
|
||
| Encounter HPI | `public/js/liveEncounter.js` | `public/components/encounter.html` | `encounter` |
|
||
| Dictation HPI | `public/js/voiceDictation.js` | `public/components/dictation.html` | `dictation` |
|
||
| Sick Visit | `public/js/sickVisit.js` (+ `shadess.js` for shared ROS/PE/Dx) | `public/components/sickvisit.html` | `sickvisit` |
|
||
| Well Visit | `public/js/wellVisit.js` + `public/js/shadess.js` | `public/components/wellvisit.html` | `wellvisit` |
|
||
| SOAP | `public/js/soap.js` | `public/components/soap.html` | `soap` |
|
||
| Hospital Course | `public/js/hospitalCourse.js` | `public/components/hospital.html` | `hospital` |
|
||
| Chart Review | `public/js/chartReview.js` | `public/components/chart.html` | `chart` |
|
||
| Personal Notes | `public/js/notes.js` | `public/components/notes.html` | n/a (own table) |
|
||
|
||
Shared frontend:
|
||
|
||
- `public/js/app.js` — tab loader, `AudioRecorder`, `transcribeAudio`,
|
||
`setOutputText`, `storeSourceContext`, `suggestBillingCodes`,
|
||
`suggestDontMiss`, `refineDocument`, `shortenDocument`,
|
||
`createSpeechRecognition`, `deduplicateFinal`.
|
||
- `public/js/encounters.js` — save/load/resume infrastructure
|
||
(sacred).
|
||
- `public/js/audioBackup.js` — IndexedDB + server audio backup
|
||
(sacred-adjacent — touch only narrowly).
|
||
- `public/js/memories.js` — `getUserMemoryContext()`.
|
||
|
||
Backend:
|
||
|
||
| Endpoint | File |
|
||
|---|---|
|
||
| `POST /api/generate-hpi-encounter` | `src/routes/hpi.js:10` |
|
||
| `POST /api/generate-hpi-dictation` | `src/routes/hpi.js:35` |
|
||
| `POST /api/sick-visit/note` | `src/routes/sickVisit.js:14` |
|
||
| `POST /api/well-visit/shadess` | `src/routes/wellVisit.js:63` |
|
||
| `POST /api/well-visit/note` | `src/routes/wellVisit.js:135` |
|
||
| `POST /api/generate-soap` | `src/routes/soap.js:9` |
|
||
| `POST /api/generate-hospital-course` | `src/routes/hospitalCourse.js:10` |
|
||
| `POST /api/hospital-course-clarify` | `src/routes/hospitalCourse.js:100` |
|
||
| `POST /api/hospital-course-update` | `src/routes/hospitalCourse.js:116` |
|
||
| `POST /api/generate-chart-review` | `src/routes/chartReview.js:10` |
|
||
| `GET/POST/PUT/DELETE /api/notes` (+ `/trash`, `/restore`, `/empty`) | `src/routes/notes.js` |
|
||
| `POST /api/notes/from-voice` | `src/routes/notes.js:224` |
|
||
| `GET /api/encounters/saved` | `src/routes/encounters.js:23` |
|
||
| `GET /api/encounters/saved/:id` | `src/routes/encounters.js:54` |
|
||
| `POST /api/encounters/saved` | `src/routes/encounters.js:70` |
|
||
| `DELETE /api/encounters/saved/:id` | `src/routes/encounters.js:163` |
|
||
| `POST /api/refine` | `src/routes/refine.js:14` |
|
||
| `POST /api/shorten` | `src/routes/refine.js:47` |
|
||
| `POST /api/clarify` | `src/routes/refine.js:67` |
|
||
| `POST /api/suggest-codes` | `src/routes/billing.js:291` |
|
||
| `POST /api/dont-miss` | `src/routes/dontMiss.js:36` |
|
||
|
||
Prompts (all in `src/utils/prompts.js`):
|
||
|
||
| Use | Key | Line |
|
||
|---|---|---|
|
||
| Encounter HPI (outpatient) | `hpiEncounter` | 53 |
|
||
| Encounter / Dictation HPI (inpatient) | `hpiInpatient` | 70 |
|
||
| Dictation HPI | `hpiDictation` | 62 |
|
||
| Hospital course (short / prose) | `hospitalCourseShort` | 82 |
|
||
| Hospital course (day-by-day) | `hospitalCourseLong` | 94 |
|
||
| Hospital course (ICU) | `hospitalCourseICU` | 105 |
|
||
| Hospital course (psych) | `hospitalCoursePsych` | 124 |
|
||
| Chart review (outpatient) | `chartReviewOutpatient` | 136 |
|
||
| Chart review (subspecialty) | `chartReviewSubspecialty` | 148 |
|
||
| Chart review (ED) | `chartReviewED` | 158 |
|
||
| SOAP (full) | `soapFull` | 168 |
|
||
| SOAP (subjective only) | `soapSubjective` | 176 |
|
||
| Refine | `refine` | 287 |
|
||
| Shorten | `shortenDocument` | 295 |
|
||
| Clarify (gap-finder) | `askClarification` | 304 |
|
||
| SSHADESS assessment | `shadessAssessment` | 311 |
|
||
| Well visit (full) | `wellVisitNote` | 321 |
|
||
| Well visit (short SOAP) | `wellVisitShort` | 354 |
|
||
| Sick visit | `sickVisitNote` | 364 |
|
||
| Don't-miss tooltip | `dontMissTooltip` | 505 |
|
||
|
||
Cross-cutting:
|
||
|
||
- `src/utils/promptSafe.js` — `wrapUserText()` and `INJECTION_GUARD`
|
||
used by every clinical-notes route.
|
||
- `src/utils/ai.js` — `callAI()` multi-provider router (OpenRouter,
|
||
Bedrock, Azure, Vertex, LiteLLM).
|
||
- `src/utils/crypto.js` — AES-256-GCM helpers used to encrypt every
|
||
encounter / note column at rest.
|