feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs

Three concurrent themes from this session:

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

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

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

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

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

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

Multiple iterations based on Daniel's feedback:

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

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

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

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

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

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

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

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

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

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

View file

@ -133,7 +133,7 @@ Supports Azure AD, Okta, Keycloak, PocketID, Google, and any OIDC-compliant prov
2. Admin Panel > Settings > Configure OIDC (Issuer URL, Client ID, Client Secret)
3. Users are auto-created and linked by email on first SSO login
See [OPENID_SETUP.md](OPENID_SETUP.md) for provider-specific guides.
See [docs/openid-setup.md](docs/openid-setup.md) for provider-specific guides.
---
@ -247,9 +247,25 @@ This application processes data through third-party AI APIs.
## Documentation
See the [docs/](docs/) directory for detailed documentation:
See [docs/](docs/) for the full documentation set.
- [Architecture Overview](docs/architecture.md)
### Application logic — start here if you're new to the codebase
[**docs/logic/**](docs/logic/) is a deep, dev-friendly walkthrough of how each
part of the app actually works. ~8,300 lines of "how it works and why" — read
the index first to know what's there:
- [docs/logic/README.md](docs/logic/README.md) — index + recommended reading order
- [docs/logic/architecture.md](docs/logic/architecture.md) — frontend IIFE pattern, lazy tab loading, backend route convention, schema, encryption, sacred zones
- [docs/logic/clinical-notes.md](docs/logic/clinical-notes.md) — every note tab (HPI, dictation, sick, well, SOAP, hospital, chart, notes) with the shared record→generate→save lifecycle
- [docs/logic/ed-encounters.md](docs/logic/ed-encounters.md) — multi-stage ED notes, per-stage don't-miss, consolidate→MDM finalize. Worked example of how a clinical workflow is composed in this codebase.
- [docs/logic/bedside-and-calculators.md](docs/logic/bedside-and-calculators.md) — Bedside emergencies module (ES-module pocket of the frontend), pediatric calculators, PE Guide, suture selector. Lists every clinical formula that must NOT be modified without test vectors.
- [docs/logic/ai-and-voice.md](docs/logic/ai-and-voice.md) — `callAI` 5-provider routing, prompt centralization with DB overrides, `wrapUserText`+`INJECTION_GUARD`, server STT routing, browser Whisper, the helper trio (refine/billing/don't-miss).
- [docs/logic/auth-admin-learning.md](docs/logic/auth-admin-learning.md) — local + OIDC auth, 2FA, sessions, OpenBao secret loading, Admin panel, Learning Hub.
### Operational + reference
- [Architecture Overview](docs/architecture.md) — high-level (the deep version is in docs/logic/architecture.md)
- [API Reference](docs/api-reference.md)
- [Database Schema](docs/database.md)
- [Authentication & Security](docs/authentication.md)
@ -258,7 +274,14 @@ See the [docs/](docs/) directory for detailed documentation:
- [Learning Hub & CMS](docs/learning-hub.md)
- [Configuration Reference](docs/configuration.md)
- [Deployment Guide](docs/deployment.md)
- [Developer Guide](docs/developer-guide.md)
- [Developer Guide (short)](docs/developer-guide.md)
- [Developer Guide (extended)](docs/developer-guide-extended.md)
- [Browser Whisper Setup](docs/browser-whisper-setup.md) · [Troubleshooting](docs/browser-whisper-troubleshooting.md)
- [Embeddings Setup](docs/embeddings-setup.md)
- [OpenID Connect Setup](docs/openid-setup.md)
- [Transcription Options](docs/transcription-options.md)
- [Features Explained](docs/features-explained.md)
- [Improvement Roadmap](docs/improvements.md)
---

View file

@ -207,7 +207,7 @@ Set in `.env` file (copy `.env.example` to get started):
```bash
# ── Required ──────────────────────────────────────────────────
DATABASE_URL=postgresql://user:<password>@host:5432/dbname
DATABASE_URL=postgresql://user:pass@host:5432/dbname
JWT_SECRET=change-this-to-a-random-64-char-string
# ── AI Provider (choose one or let it default to OpenRouter) ──

125
docs/logic/README.md Normal file
View file

@ -0,0 +1,125 @@
# Application Logic — index
> Deep, dev-friendly documentation of how each part of the ped-ai app
> actually works. Written so a human developer can understand the
> codebase without spelunking, and so an AI assistant can confidently
> modify code without breaking sacred zones.
These docs explain **application logic** — what the user does, what the
system does in response, what the data flow is, and **why** the design
looks the way it does. They are not API reference (see
[`../api-reference.md`](../api-reference.md)) and not deployment
recipes (see [`../deployment.md`](../deployment.md)).
## Read in this order
For someone brand new to the codebase:
1. **[architecture.md](architecture.md)** — Start here. The big picture:
IIFE frontend pattern, lazy tab loading, backend route convention,
PostgreSQL schema, encryption at rest, Dockerfile + compose layout,
sacred zones. (~2,000 lines, the longest doc — but the foundation.)
2. **[clinical-notes.md](clinical-notes.md)** — How every clinical note
tab works. The shared "record → transcribe → generate → save"
lifecycle, then per-tab deep dives for Encounter HPI, Dictation HPI,
Sick Visit, Well Visit, SOAP, Hospital Course, Chart Review, and
Personal Notes. Includes the helper trio (refine / billing-codes /
don't-miss).
3. **[ed-encounters.md](ed-encounters.md)** — The ED encounter feature
(multi-stage notes, per-stage don't-miss, consolidate→MDM finalize).
Newest, most explicit explanation of how a clinical workflow gets
composed in this codebase. Read this for a worked example.
4. **[bedside-and-calculators.md](bedside-and-calculators.md)** —
Bedside emergencies module (the one ES-module pocket of the
frontend), the pediatric calculators (BP percentile, Fenton growth,
bilirubin nomograms, etc.), the PE Guide, vax schedule, milestones.
Includes the suture selector. **Important:** lists every clinical
formula that must NOT be modified without test vectors.
5. **[ai-and-voice.md](ai-and-voice.md)** — The 5-provider AI routing
(`callAI`), the centralized `PROMPTS` object with DB overrides, the
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
routing (Whisper / AWS Transcribe / Vertex / LiteLLM), browser
Whisper, the AudioRecorder. Voice/STT plumbing is **sacred** — the
doc describes it without proposing changes.
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
(local + OIDC SSO + 2FA), session management, OpenBao secret loading
at container start, the Admin panel (model allowlist, prompt
overrides, milestone editor), and the Learning Hub (AI-authored
quizzes / outlines / Marp presentations).
## What's NOT here
- **Reference data details.** Every clinical formula's *math* lives in
the source files; this doc series points to the formula and explains
*what it does* but doesn't reproduce the lookup tables.
- **API endpoint signatures.** See [`../api-reference.md`](../api-reference.md).
- **Operational runbooks.** See [`../deployment.md`](../deployment.md),
[`../configuration.md`](../configuration.md).
- **Recent change history.** See git log + the rollback tags
(`pre-ts-migration-2026-04-26`, `pre-ed-encounters-2026-04-26`, etc.).
## Voice + conventions
Each doc follows the same structure:
- **Overview** — what this part is and why it exists
- **User flow** — what the physician does and sees
- **Data flow** — what HTTP calls happen, what the server does
- **File map** — which files do what
- **Key design decisions***why* it works the way it does
- **Sacred zones** — what NOT to refactor without explicit approval
- **How to extend** — concrete recipes for adding a new X
When a doc mentions a sacred zone, it means there's a project-memory
rule that this code must not be refactored without per-change approval
from Daniel. The full sacred-zone roster:
| Zone | Why |
|---|---|
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
| Voice/STT plumbing (`audioBackup.js`, `speechRecognition.js`, `browserWhisper.js`, `voicePreferences.js`, `transcriptionSettings.js`, recorder paths in each clinical tab) | Recording UX has been hardened against many edge cases; refactor only with smallest-diff bug fixes. |
| Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. |
| Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. |
| MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. |
## Total size
~8,300 lines of new application-logic documentation across 6 files. If
that feels like a lot, remember: the codebase is ~33,000 lines of
frontend JS + ~14,000 lines of backend JS. The docs are dense by design
— "200% detailed" was the explicit ask. Search them like a reference;
don't try to read end to end.
## Cross-cutting topics
A few topics span multiple docs. Use these as your jump-off points:
| Topic | Where to look |
|---|---|
| The IIFE pattern + `window.x = y` cross-file globals | architecture.md §2-3 |
| Lazy tab loading (`loadComponent`, `tabChanged` event) | architecture.md §3-4 |
| `getUserMemoryContext` → templates feeding into AI prompts | clinical-notes.md §6, ed-encounters.md §9 |
| The helper trio: `refineDocument`, `suggestBillingCodes`, `suggestDontMiss` | ai-and-voice.md §12, clinical-notes.md §5 |
| `wrapUserText` + `INJECTION_GUARD` prompt-injection defense | ai-and-voice.md §5 |
| `saveEncounter` API + optimistic locking + idempotency keys | architecture.md §13, clinical-notes.md §4, ed-encounters.md §5 |
| `cryptoUtil.encryptString` / `encryptBuffer` "enc1:" format | architecture.md §12 |
| 5-provider AI routing (`callAI`) | ai-and-voice.md §2-3 |
| 2023 AMA E/M MDM rubric | ed-encounters.md §6 |
| User templates (`user_memories` table, `template_*` categories) | clinical-notes.md §6, ed-encounters.md §9 |
## How to keep these docs current
Each doc has a date implicit in the most recent feature it describes.
When you add a feature, update the relevant doc in the same commit.
When you remove a feature (e.g., the Dragon-style AI corrections
removal in late April 2026), remove its section + leave a one-line
historical note in the relevant doc.
When you write a new doc, follow the same structure as these (Overview /
User flow / Data flow / File map / Design decisions / Sacred zones /
How to extend) and add it to this index.

1128
docs/logic/ai-and-voice.md Normal file

File diff suppressed because it is too large Load diff

2166
docs/logic/architecture.md Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1546
docs/logic/clinical-notes.md Normal file

File diff suppressed because it is too large Load diff

821
docs/logic/ed-encounters.md Normal file
View file

@ -0,0 +1,821 @@
# ED Encounters — Application Logic
> Multi-stage emergency department documentation with per-stage AI generation,
> "don't miss" tooltips per stage, and a final consolidate→MDM pipeline at
> Save & Done. Lives in its own tab between **Dictation HPI** and the
> **Notes** sidebar group.
This is the deepest, freshest doc in the `logic/` series — the feature was
built and revised across one focused session in late April 2026 and most of
the architectural decisions are explicitly motivated below. Read this first if
you want to understand how a clinical workflow gets composed in this codebase.
---
## 1. What this is
An ED encounter is structurally different from every other clinical note in
the app:
- A sick visit, well visit, SOAP note, or HPI is **one transcript → one
generated note**. The physician records, clicks Generate, edits, saves.
- An ED encounter is **multiple successive recordings → multiple successive
notes → one final consolidated note + MDM**. The physician dictates the
initial assessment, generates a note. Labs come back, they record more,
generate again. After consult, more dictation, generate again. When done
(could be after 1, 2, 3, or N stages), they click Save & Done and the
server consolidates everything into one polished note plus a 2023 AMA E/M
Medical Decision-Making block for billing.
The user-visible model: each generated stage stays on screen as its own
editable card with its own "Don't Miss" panel. The physician can edit any
stage at any time. Whatever's on screen at finalize time is what gets sent
to the consolidate step.
Physicians can also include direct asides in their dictation
("include normal cardiac exam", "assessment is viral URI") and the AI is
explicitly instructed to route those to the right note section instead of
quoting them.
---
## 2. The user flow, end to end
1. **Open the tab.** Sidebar → **ED Encounter**. Tab is lazy-loaded (the
`<section id="ed-tab" data-component="ed-encounter">` placeholder in
`public/index.html` triggers a fetch of `public/components/ed-encounter.html`
on first activation).
2. **Enter patient info.** Label (required for save), age, gender, chief
complaint (required for generation), and a model dropdown (the same
`class="tab-model-select"` pattern every clinical tab uses; auto-populated
by `app.js` against the admin's allowed-models list).
3. **Record or type Stage 1 dictation.** Standard recorder (`AudioRecorder` from
`public/js/audioBackup.js`) + browser SpeechRecognition for live transcript
preview + final server STT pass on stop. Same plumbing as every other
clinical tab.
4. **Click "Generate Stage 1 Note".** Frontend POSTs to
`/api/ed-encounters/generate` with `{stage: 1, transcript, chiefComplaint,
patientAge, patientGender, physicianMemories, model}`. Server returns
`{success, note, dontMiss[], model}`. Note appears as **Stage 1** card.
Don't-miss items appear as a yellow/orange section embedded in that same
card.
5. **Edit if needed.** Each stage's note element is `contenteditable`. Type
freely; edits persist to localStorage on each input event.
6. **Refine the latest stage** (optional). The "Refine latest" textarea +
button at the bottom of the stage list calls `/api/refine` with the
latest stage's text + your instruction. The latest stage's text is
replaced inline with the refined version.
7. **Add another stage** (optional). Click "Add more (next stage)". Stage 1
card stays visible. Transcript box clears. Badge changes to "Stage 2
(recording)" — yellow background — meaning we've advanced but no Stage 2
note has been generated yet.
8. **Repeat** for as many stages as you need.
9. **Click "Save & Done (with MDM)"** at any point.
- Server runs `edConsolidate` → produces one polished final note that
integrates every stage chronologically.
- Server then runs `edFinalize` → produces a 2023 E/M MDM block as JSON.
- Both come back in one HTTP response.
- Frontend renders a **"Final Consolidated Note"** card (blue left border)
and a **"Medical Decision Making (2023 E/M)"** card (green left border)
below the stage cards.
- Stage cards become read-only.
- The whole thing persists to `saved_encounters` with `status='final'`.
- Local draft cleared.
---
## 3. State model
Lives in a closure variable in `public/js/ed-encounters.js`:
```js
_state = {
stage: 1, // current stage number (what the recorder is for)
stages: [ // per-stage history — one entry per generated stage
{ transcript, note, dontMiss[], model, generatedAt }
],
finalized: false,
finalNote: null, // consolidated final note from /finalize
mdm: null // 2023 E/M MDM block from /finalize
}
```
### Invariants
- `_state.stage` increments monotonically. It only goes up via the user
clicking "Add more". It is the **target** stage of the recorder/generate
button.
- `_state.stages.length` is the number of stages **already generated**. The
array is indexed 0..N-1; stage 1 is at index 0, stage 2 at index 1, etc.
- The relationship `_state.stages.length === _state.stage` means "the
current stage has been generated, ready to finalize or advance."
- The relationship `_state.stages.length === _state.stage - 1` means
"we're recording into a new stage that hasn't been generated yet" (the
yellow `Stage N (recording)` badge state).
- `_state.finalized` flips to `true` only when finalize succeeds. Once true,
Add more / Generate / Refine all reject with a toast.
### Why this shape
Earlier iterations stored a single `currentNote` string and rotated stages
out of view on each generation. Daniel's clarification was explicit: every
stage's note must remain visible and editable; the final MDM should reflect
whatever's on screen, including any inline physician edits to earlier stages.
The `stages[]` array is the source of truth and `gatherCurrentNotes()`
re-syncs it from the DOM before any operation that needs current text.
---
## 4. The badge — accurate state communication
Top-right of the save bar. Exactly four states:
| Condition | Text | Background |
|---|---|---|
| `_state.stages.length >= _state.stage` (current stage has been generated) | `Stage N` | Gray |
| `_state.stages.length < _state.stage` (advanced past last generation; no note for stage N yet) | `Stage N (recording)` | Yellow |
| `_state.finalized && !_state.mdm` (transient) | (not reached — finalize is atomic) | — |
| `_state.finalized` | `Finalized` | Green |
This badge was the source of the most confusing UX bug in v1: clicking
"Add more" used to immediately flip the badge to "Stage 2" even though
no Stage 2 note existed yet. The fix isn't subtle — `updateBadge()` derives
the label from the relationship between `_state.stages.length` and
`_state.stage`, with explicit color coding so the difference is
unmistakable.
---
## 5. Frontend file map
### `public/components/ed-encounter.html`
The static markup. Roughly:
- **Save bar** (`#ed-save-bar`) — label input, badge (`#ed-stage-badge`),
Save draft / Load / New buttons, plus a Load popover for saved drafts.
- **Patient Info card** — age (`#ed-age`), gender (`#ed-gender`), chief
complaint (`#ed-cc`), and the model select (`#ed-model-select` with the
`tab-model-select` class).
- **Recording card** — header showing `Stage <span id="ed-rec-stage-num">N</span>
Recording / Dictation`, the Listen In / Pause buttons, the recording
indicator with timer, and a contenteditable transcript box (`#ed-transcript`).
- **Generate button** (`#btn-ed-generate`) — `Generate Stage <span id="ed-gen-stage-num">N</span> Note`.
- **`#ed-stages-container`** — empty div. JS appends one card per stage here.
- **`#ed-tail-controls`** — refine bar (textarea + Refine latest + Shorter)
+ stage-control row (Add more, Save & Done). Hidden until at least one
stage exists. Hidden again after finalize (encounter is locked).
- **`#ed-mdm-card`** — the green-bordered MDM card. Hidden until finalize.
The blue-bordered "Final Consolidated Note" card is **created
dynamically** by `renderFinalNote()` and inserted before the MDM card.
### `public/js/ed-encounters.js`
Single IIFE module. Key functions:
| Function | What it does |
|---|---|
| `freshState()` | Returns a clean `_state` object — used at module load and `resetEncounter()` |
| `gatherCurrentNotes()` | Walks the DOM stage cards (`#ed-stage-text-N` elements) and writes their current text back into `_state.stages[N].note`. Called before persist, advance, finalize, generate (the last because the AI prompt for stage N+1 needs the latest text of stage N as `previousNote`). |
| `persistLocal()` | Debounced 300ms localStorage save under key `ped_ed_draft_v1`. Snapshot includes `_state` plus the current label/age/gender/CC/transcript box content. |
| `loadLocal()` | Reverse of `persistLocal()` — restores state on tab open if a draft exists. |
| `renderStages()` | Rebuilds `#ed-stages-container` from `_state.stages[]`. Each card gets a unique id `ed-stage-text-N`. Cards become `contenteditable=false` after finalize. |
| `buildStageCard(idx, stage)` | Constructs one card's DOM. Includes the editable note + an embedded yellow "Don't Miss — Stage N" section if `stage.dontMiss` is non-empty. |
| `renderFinalNote(note)` | Lazily creates `#ed-final-note-card` (blue border) and inserts it before the MDM card. |
| `renderMdm(mdm)` | Fills `#ed-mdm-card` with structured MDM HTML (problems / data / risk paragraphs + suggested level + rationale + disclaimer). |
| `updateBadge()` | Sets `#ed-stage-badge` text + background color based on state. |
| `initRecording()` | Wires the record / pause buttons, browser SpeechRecognition, AudioRecorder, transcribe-on-stop. Identical pattern to `sickVisit.js` — copy-pasted because the recording paths are sacred and shouldn't be factored into a shared helper. |
| `generateStage()` | Validates inputs, calls `gatherCurrentNotes()`, fetches user templates via `getUserMemoryContext()`, POSTs `/api/ed-encounters/generate`, pushes the result into `_state.stages[stage-1]`, re-renders, autoSaves a draft to the DB. |
| `advanceStage()` | Validates current stage exists, gathers edits, increments `_state.stage`, clears the transcript box, updates the badge to "(recording)", scrolls to the recorder. **Does NOT touch any displayed cards.** |
| `finalize()` | Validates label + at least one stage, gathers edits, POSTs `/api/ed-encounters/finalize` with the full stages array, on success renders Final Note + MDM cards, marks finalized, calls `saveEncounter` with `status='final'`, clears localStorage. |
| `composeFinalNoteForSave(note, mdm)` | Concatenates the final note + MDM block into a single text blob written to `saved_encounters.generated_note` (so the saved encounter has a single coherent stored note for any downstream consumer). |
| `autoSaveDraft()` | Best-effort — saves a `status='draft'` row to `saved_encounters` if a label is set. Called after each stage generation. Silent if no label. |
| `resetEncounter()` | "New" button — wipes state, removes stage cards, hides Final Note + MDM, clears localStorage, drops the saved-encounter id. |
| `refineLatestStage()` / `shortenLatestStage()` | Resolve the latest stage's text element id (`stageTextElId(stages.length - 1)`) and call the global `refineDocument` / `shortenDocument` helpers from `app.js`. |
### How the file integrates with `encounters.js`
`public/js/encounters.js` is **sacred** (per the project memory file —
don't refactor without per-change approval, especially the save/idempotency
logic). ED encounters needed exactly two minimal touches to it:
1. Line 98: `'ed'` added to the sessionStorage restore array so the
`_savedEncId_ed` value survives page refresh.
2. Lines 305-310: `ed: 'ed'` added to the `tabMap` in `resumeEncounter` so
the saved-encounters list can navigate to the ED tab when a user
clicks a saved ED row.
That's it. Save logic, idempotency, optimistic versioning — all reused
unchanged via `window.saveEncounter()`. ED rows store with `enc_type='ed'`
and `partial_data` containing the full `{stages, finalNote, mdm, finalized}`
JSON for resume.
A `registerEncounterLoadHandler('ed', fn)` call near the bottom of
`ed-encounters.js` registers the resume handler with `encounters.js`. When
a user clicks an ED row in the Load popover, `encounters.js` invokes that
handler with the decrypted row, and the handler restores `_state` and
re-renders.
---
## 6. Backend file map
### `src/routes/edEncounters.js`
Two endpoints, both auth-gated.
#### `POST /api/ed-encounters/generate`
Per-stage note generation. Body:
| Field | Type | Notes |
|---|---|---|
| `stage` | number | Informational; the prompt is told this is "Stage N" |
| `transcript` | string | **Required.** This stage's raw dictation. |
| `chiefComplaint` | string | **Required.** Same as in other tabs. |
| `patientAge` | string | Optional but strongly preferred — drives "don't miss" tailoring |
| `patientGender` | string | Optional |
| `previousNote` | string | Stage 2+ only. The previous stage's current text (after edits). |
| `physicianMemories` | string | Concatenated user templates from `/api/memories/context` |
| `model` | string | Optional override. Validated by callAI's allowlist. |
Returns:
```json
{ "success": true, "note": "<plain-text note>", "dontMiss": [{"point","why"}], "model": "<id>" }
```
The route assembles a structured user message:
```
ED ENCOUNTER — STAGE N
Patient: <age>, <gender>
Chief Complaint: <wrapped>
CURRENT STAGE TRANSCRIPT (may include direct physician asides — preserve and route them per the prompt rules):
<wrapped transcript>
PREVIOUS-STAGE NOTE (baseline to integrate on top of — do not start fresh):
<wrapped previous note> [only stage 2+]
PHYSICIAN TEMPLATES AND PREFERENCES: [if any]
<wrapped templates>
```
Calls `callAI` with `PROMPTS.edEncounterStaged + INJECTION_GUARD` as system
and the assembled user message. Parses the response with `extractJson`.
**Recovery logic:** if the model returns plain prose instead of JSON
(model occasionally shortcuts), the route treats the entire response as
the note and returns an empty don't-miss list rather than 500-ing. The
physician still gets a usable note.
`dontMiss` is filtered to entries with non-empty `point` and trimmed.
Audit + apiCall logs are written.
#### `POST /api/ed-encounters/finalize`
Two-call server-side pipeline. Body:
| Field | Type | Notes |
|---|---|---|
| `stages` | `[{transcript, note}]` | **Required.** Array in chronological order. Empty stages are filtered. |
| `chiefComplaint` | string | Optional but strongly preferred |
| `patientAge` | string | Optional |
| `patientGender` | string | Optional |
| `model` | string | Optional override |
The route:
1. **Step 1 — consolidate.** Builds a context with chief complaint, demographics,
and a labeled `=== STAGE N ===` block for each stage (transcript +
working note). System prompt is `PROMPTS.edConsolidate`. Returns the
model's plain-text response as `finalNote`.
2. **Step 2 — MDM.** Builds a context with the consolidated `finalNote` plus
the full transcript across all stages. System prompt is
`PROMPTS.edFinalize`. Parses JSON for `{mdm: {...}}`.
Returns:
```json
{
"success": true,
"finalNote": "<plain-text consolidated note>",
"mdm": {
"problemsAddressed": "minimal|low|moderate|high",
"problemsNarrative": "...",
"dataReviewed": "minimal|limited|moderate|extensive",
"dataNarrative": "...",
"risk": "minimal|low|moderate|high",
"riskNarrative": "...",
"suggestedLevel": "99281|99282|99283|99284|99285",
"levelRationale": "..."
},
"model": "<id>"
}
```
**Why two calls instead of one combined prompt:** each task has a
focused rubric (the consolidate prompt enforces section structure and
chronological integration; the MDM prompt enforces the 2023 AMA element
definitions). Asking for both in one JSON tends to make the model
shortcut one or the other. Two calls cost ~2x latency at the very end of
the encounter — acceptable since finalize is a one-time terminal action.
If the MDM step's JSON parse fails, the route returns 502 but **still
includes the finalNote** in the error payload so the client doesn't lose
work. (The client doesn't currently surface this case to the user — TODO
to render the partial result with a "MDM failed, retry" affordance.)
Token usage from both calls is summed for the apiCall log.
### `src/utils/prompts.js` — the three ED prompts
#### `edEncounterStaged`
System prompt for per-stage generation. Returns strict JSON `{note, dontMiss[]}`.
Key instructions:
- Note structure is **fixed**: Chief Complaint, HPI (OLDCARTS, historian
noted), ROS (per ROS_PE_RULES), PE (per ROS_PE_RULES), ED Course (only
when present), Assessment and Plan.
- Don't-miss list is **uncapped** for ED (unlike the global `dontMissTooltip`
prompt which hard-caps at 5 for sick visit / encounter HPI). Quality
over quantity. Tailored strictly to age + chief complaint.
- **PRESERVE INSTRUCTIONS WITHIN DICTATION** — explicit instruction that
physician asides like "include normal cardiac exam" or "assessment is
viral URI" are first-class clinical input. Route exam findings to PE,
assessment statements to A&P, plan statements to A&P. Never echo as
quoted speech.
- **Templates** — the user's templates (especially `template_ed`, but also
matching `template_hpi`/`template_soap`/`template_sickvisit`) are
delivered in the user message as PHYSICIAN TEMPLATES AND PREFERENCES.
Apply matching template sections; never copy clinical content from a
template — only formatting/structure.
- **Previous-stage note** — explicit instruction: integrate the new
transcript on top of the previous note, do not start fresh. Drop
don't-miss items that have been addressed.
The prompt is appended with `INJECTION_GUARD` from `promptSafe.js` to
defend against prompt-injection attempts inside the dictation.
#### `edConsolidate`
System prompt for the consolidate step at finalize. **Plain text output**
(no JSON wrapper).
Key instructions:
- Same fixed note structure as the staged prompt.
- **Integration rules**: use the latest stage as the structural baseline
(it already integrates earlier stages); use earlier stages and
transcripts to fill gaps. ED Course should reflect chronological
progression. Resolve contradictions by using the later value AND
noting the change in ED Course (e.g., "now afebrile after antipyretic").
- Preserve every clinical fact; never drop information; never invent.
#### `edFinalize`
System prompt for the MDM step. Returns strict JSON `{mdm: {...}}`.
Includes a **full inline rubric** of the 2023 AMA E/M MDM table so the
model has clear definitions to score against:
- **Element 1 — Problems addressed**: minimal / low / moderate / high
with explicit definitions (e.g., "high = chronic illness with severe
exacerbation, OR acute illness/injury that poses threat to life or
bodily function").
- **Element 2 — Data reviewed**: categories (tests reviewed, tests
ordered, independent interpretation, discussion with another physician,
external records, independent historian) and counting rules for
minimal / limited / moderate / extensive.
- **Element 3 — Risk**: minimal / low / moderate / high with concrete
examples per level (drug therapy requiring intensive monitoring,
decision regarding hospitalization, etc.).
- **Level mapping** (2 of 3 elements must meet the level): 99281 through
99285 with descriptions of the typical encounter at each level
(99284 = "MODERATE complexity MDM, most common ED visit with workup,
labs, or imaging and prescription decisions"; 99285 = "HIGH complexity
MDM, admission for high-acuity care").
Critical rules at the bottom:
- Use only information present in the note (and transcript if provided).
- Never invent.
- Conservative when ambiguous — pick the lower level.
- `levelRationale` must reference specific elements actually documented.
This prompt is the most important to get right — it's what determines the
suggested billing level. Daniel called this out explicitly: "make sure mdm
is configured well." The full element rubric is inline so the model isn't
relying on its training to remember the 2023 guidelines correctly.
---
## 7. Persistence — three layers
### Layer 1 — localStorage (`ped_ed_draft_v1`)
Debounced 300ms after every input event. Snapshot includes the entire
`_state` plus the current label / age / gender / CC / transcript box
content. Survives page refresh, browser restart, signing out + back in.
Cleared on `resetEncounter()` and on successful finalize.
This is the **fast** layer — captures every keystroke without round-trips.
### Layer 2 — saved_encounters DB row, status='draft'
Best-effort auto-save after each stage generation. Requires a label to be
set; silent no-op otherwise. Uses `window.saveEncounter` from
`encounters.js` with:
- `enc_type: 'ed'`
- `status: 'draft'`
- `generated_note`: the latest stage's note (so the saved-encounters list
has something to preview)
- `partial_data`: encrypted JSON containing `{stages, finalized: false}`
- `idempotency_key: 'ed-draft-' + savedId`
The same row gets updated on each subsequent generation (by passing
the saved id back to `saveEncounter`).
This is the **durable** layer — survives device loss because it's on the
server, encrypted at rest with the app key.
### Layer 3 — saved_encounters DB row, status='final'
Written exactly once on successful finalize. Includes:
- `generated_note`: the **final consolidated note + MDM block** combined
via `composeFinalNoteForSave()`, so any downstream consumer (export,
copy, print) gets a single coherent text.
- `partial_data`: `{stages, finalNote, mdm, finalized: true}` — full
fidelity for resume / audit / future re-render.
- `idempotency_key: 'ed-final-' + Date.now()` — unique per finalize
attempt so a network retry doesn't create a duplicate row.
After finalize, localStorage is cleared. The encounter is locked from
further edits in-app (stage cards become `contenteditable=false`, tail
controls hidden). The user can still load the row later for review;
editing requires loading then unlocking by some manual workflow that
doesn't yet exist (TODO if requested).
---
## 8. The "Don't Miss" tooltip — per-stage
Each stage's response from `/api/ed-encounters/generate` includes a
`dontMiss[]` array of `{point, why}` objects. Same JSON call as the
note — no second AI request. The stage card embeds these as a
yellow/orange section beneath the note text:
```
┌─────────────────────────────────────────┐
│ Stage 2 Note [model] [Copy] │
├─────────────────────────────────────────┤
│ Chief Complaint: ... │
│ HPI: ... │
│ ... │
├─────────────────────────────────────────┤ ← yellow background
│ ⚠ Don't Miss — Stage 2 │
│ • Document hydration status │
│ (tachycardia + 4-day vomiting) │
│ • Consider DKA workup │
│ (polyuria + weight loss in HPI) │
│ • ... │
└─────────────────────────────────────────┘
```
ED don't-miss is **uncapped** by design — Daniel wanted no limit ("just like
remember to ask this, do this, keep this in mind etc."). Compare with
`/api/dont-miss` (used by sick visit + encounter HPI) which caps at 5
both in the prompt and via server-side `.slice(0, 5)`.
The same-call design (note + don't-miss in one JSON) was a deliberate
choice to keep per-stage latency down. The risk (model occasionally
shortcuts the don't-miss list) is acceptable per Daniel since don't-miss
is informational, not load-bearing.
---
## 9. Templates — `template_ed`
When a physician saves a template under category `template_ed`
(Settings → Templates), it gets included in the `physicianMemories` string
that the frontend fetches via `getUserMemoryContext()` and passes to
`/api/ed-encounters/generate`.
The flow:
1. User saves a template named e.g. "ED Pearls" with category `template_ed`.
2. Server stores it in `user_memories` (encrypted name + content).
3. On next ED note generation, `ed-encounters.js` calls
`getUserMemoryContext()` (defined in `public/js/memories.js`).
4. That function fetches `/api/memories/context` which returns a single
string containing every active template (correction_* rows are
filtered out at the SQL level — the AI corrections feature was
removed in late April 2026).
5. The string is included in the user message under the
"PHYSICIAN TEMPLATES AND PREFERENCES" header.
6. The system prompt's "PHYSICIAN TEMPLATES" section instructs the model
to apply matching sections from any template (especially `template_ed`,
but also matching HPI/SOAP/sickvisit templates).
`template_ed` was added to `VALID_CATEGORIES` in `src/routes/memories.js`
and to the dropdown in `public/components/settings.html`.
---
## 10. Why the design looks like this
Each major decision, with the constraint that motivated it.
### Why per-stage cards (vs. one rolling note element)
**Daniel's clarification.** The first build used one `#ed-note-text`
element that got replaced on each generation. After demo: "every stage
note should be shown, if AI is told to modify that particular note then
the modified version is used in final mdm." The cards model is the
direct response — every stage stays visible, every stage is editable,
edits flow into finalize.
### Why the badge has an explicit "(recording)" state
**Bug from first user test.** Clicking "Add more" used to flip the badge
to "Stage 2" immediately, before any Stage 2 note existed. Daniel: "the
title changes to stage 2 even without a new recording and generate being
hit." The fix isn't subtle — `updateBadge()` derives state from the
relationship between `stages.length` and `_state.stage`, with explicit
color coding so the difference is unmistakable.
### Why finalize is two server-side AI calls instead of one
**Quality concern.** A single combined "produce finalNote AND mdm in one
JSON" prompt makes the model cut corners on one of the two tasks
(usually the MDM rubric gets compressed). Two focused calls each get
their own dedicated system prompt with no competing pressure. Cost: ~2x
latency at finalize. Justification: finalize is a one-time terminal
action, not a per-stage hot path.
### Why edConsolidate returns plain text instead of JSON
**Reliability + simplicity.** The consolidate step produces ONE thing —
a clinical note. JSON wrapping adds parse-failure surface area for zero
benefit. The text is rendered directly into a `contenteditable` element.
The MDM step does need JSON because it has structured fields the UI
displays in a table layout.
### Why the MDM prompt has the full 2023 AMA rubric inline
**Daniel's directive: "make sure mdm is configured well."** Models'
training data includes pre-2023 guidelines mixed with 2023; relying on
"you know the AMA E/M MDM table" produces drift. The prompt now
includes element-by-element definitions (problems / data / risk),
counting rules for data, and concrete examples per level. The level
rationale must reference specific findings.
### Why the recorder code is copy-pasted from sickVisit.js
**Project memory: "Voice/STT is sacred — Don't refactor recorder/transcribe
plumbing; fix only named bugs in smallest diff."** The recording paths
in every clinical tab look almost identical; refactoring to a shared
helper is a textbook clean-code move that has burned this project before
(silent breakage of recording when the abstraction ate an edge case).
The deliberate non-DRY duplication is the safer choice.
### Why finalize sends the whole stages array instead of just stage texts
The consolidate prompt benefits from seeing each stage's transcript
(physician's actual dictation) AND each stage's note (which may include
physician edits). The transcripts let the AI catch facts that didn't
make it into the working notes; the notes show physician interpretation.
Both together produce a more faithful consolidation.
### Why `previousNote` is sent during stage 2+ generation, not just the transcript
The per-stage AI is told to "integrate the new transcript on top of the
previous note as baseline, do not start fresh." Without the previous
note as input, stage 2 would have to regenerate everything from
transcripts alone (slower, less faithful to physician edits made between
stages).
---
## 11. Sacred / fragile zones
These are not refactor-without-permission lines.
### `public/js/encounters.js`
Per project memory: don't touch without per-change approval; even
pre-approved changes get rejected if they refactor save/idempotency. The
ED feature touched it in exactly two places (sessionStorage array and
tabMap) and that's it. **All ED save/load goes through `window.saveEncounter`
and `registerEncounterLoadHandler` — established interfaces. Do not
add new methods to encounters.js or modify the save body shape.**
### Recorder + transcribe paths
`public/js/audioBackup.js` (the AudioRecorder class), the
`transcribeAudio` global function, the SpeechRecognition wrapper from
`public/js/speechRecognition.js`. The ED tab's recording logic in
`initRecording()` was copied from `sickVisit.js` deliberately. Don't
factor it out into a shared `record-and-transcribe-helper.js`.
### The MDM prompt rubric
The 2023 AMA E/M element definitions and level mapping in
`PROMPTS.edFinalize` are load-bearing for billing accuracy. Don't trim
them to "save tokens" — the cost of a miscoded encounter to a real
practice is much higher than the prompt overhead. Update only with
explicit billing/coding source citation.
---
## 12. How to extend — concrete recipes
### Add a new prompt key
1. Add the entry to `PROMPTS` in `src/utils/prompts.js`. Use the same
`${CORE_RULES}` and (if relevant) `${ROS_PE_RULES}` preambles other
prompts use.
2. The DB-override system (`loadFromDb` in the same file) auto-picks up
the new key on next startup, so admins can override it from the
Admin → Prompts UI without code changes.
### Tweak the MDM rubric
Edit `PROMPTS.edFinalize` in `src/utils/prompts.js`. Cite the source
(2023 AMA E/M Office or Other Outpatient guideline updates, or AMA
errata) in the commit message. The rubric structure is stable —
changes are usually wording refinements, not category rewrites.
### Add a new field to the stage card (e.g., timestamp)
1. The data is already in `_state.stages[i].generatedAt`.
2. In `buildStageCard(idx, stage)` in `public/js/ed-encounters.js`,
add a small `<div>` next to the model tag in the card header.
3. No backend change needed; `generatedAt` is already saved in
`partial_data`.
### Add per-stage refine (instead of "refine latest")
1. In `buildStageCard()`, render a refine input + button for every
stage card.
2. Update the refine button click handler to read `data-stage-idx` from
the clicked button and pass `stageTextElId(idx)` to `refineDocument`.
3. Be aware: physicians editing earlier stages then refining them then
regenerating later stages creates a complex causality chain. Daniel's
current call is "refine targets the latest stage" to avoid this.
### Add a new ED-specific output (e.g., a discharge instructions card)
1. Decide if it's per-stage or once-per-encounter. Per-encounter is
simpler — generate it on finalize.
2. Add a third server-side AI call in `/api/ed-encounters/finalize`
between consolidate and MDM. Add the result to the response payload.
3. Add a new card to `ed-encounter.html` (or create dynamically like
`renderFinalNote`).
4. Render it in the finalize success handler.
### Unlock a finalized encounter for editing (TODO — not implemented)
Currently no UI for this. Would require:
1. A new endpoint `POST /api/ed-encounters/:id/unlock` that flips
`status` from `'final'` back to `'draft'` and clears `partial_data.finalized`.
2. An "Unlock for editing" button on the load popover for finalized
rows.
3. UI logic in `ed-encounters.js` to handle the unlocked state
(re-enable editing on stage cards, re-show tail controls).
### Add a fourth stage type (currently the prompt is generic)
The current design treats all stages identically — same prompt, same
structure. If there's a value in distinguishing "initial assessment"
vs "post-workup" vs "post-consult" stages with different prompts, that's
a meaningful shift. Probable plan:
1. Add a `stageType` field to each stage entry.
2. Branch on `stageType` in the route to pick a prompt variant.
3. UI: dropdown next to the recorder that defaults to "Initial /
Workup / Consult / Disposition" based on stage number.
This isn't currently planned — the generic stage works because the
physician's dictation is what differentiates stages, not a metadata tag.
---
## 13. Testing pointers
There are currently **no Playwright e2e tests** for ED encounters — flagged
as TODO in the session that built the feature. A reasonable first batch:
1. **Stage 1 happy path.** Open tab, fill label/age/gender/CC, type
transcript, click Generate, assert Stage 1 card appears with note
text and don't-miss section.
2. **Multi-stage flow.** Stage 1 → Add more → badge says "Stage 2
(recording)" with yellow background → type Stage 2 transcript →
Generate → both Stage 1 and Stage 2 cards visible.
3. **Edit-then-finalize.** Generate Stage 1 → edit the note text inline
→ Save & Done → assert the consolidate step received the edited text
(mock `/api/ed-encounters/finalize`, inspect the request body).
4. **Finalize renders both cards.** Mock `/finalize` to return
`{finalNote, mdm}` → assert blue Final Note card AND green MDM card
appear → assert stage cards become read-only.
5. **Resume from saved.** Save a draft, reload, click Load, pick the
ED row → all stages reappear with their don't-miss sections.
The mocking pattern is in `e2e/fixtures.js``mockAI(page, overrides)`.
Add `'**/api/ed-encounters/generate'` and `'**/api/ed-encounters/finalize'`
to the routes table with canned responses.
---
## 14. Known issues / TODOs
- No e2e coverage (above).
- No "unlock" UI for finalized encounters.
- The MDM-step partial-success case (consolidate succeeded, MDM failed)
returns 502 with `finalNote` in the error payload, but the client
doesn't render the partial result. Currently the user sees a generic
error toast and loses the consolidate work.
- Per-stage refine isn't supported — only "refine latest." If the user
wants to refine an earlier stage, they edit it inline (works) but
don't get an AI-assisted refine for that specific stage.
- The "(recording)" badge color (yellow) might be confusing in the
dark theme if one is added — currently the app is light-only.
- The consolidate step uses the configured default model unless the
user picks a specific one in the dropdown. There's no way to use one
model for per-stage generation and a different model for finalize.
Probably fine; flag if a user wants this.
- `extractJson` is defined locally in `src/routes/edEncounters.js` and
duplicated from `notes.js`. Candidate for `src/utils/jsonRecover.js`
if a third route ever needs it.
---
## 15. Quick reference — the ED encounter at a glance
```
┌───────────────────────────────────────────────────────────────────┐
│ ED ENCOUNTER TAB │
│ [Patient label] [Stage N badge] │
│ Age | Gender | Chief Complaint | Model dropdown │
├───────────────────────────────────────────────────────────────────┤
│ Stage N Recording — [Listen In] [Pause] [00:23 indicator] │
│ [contenteditable transcript box] │
├───────────────────────────────────────────────────────────────────┤
│ [✨ Generate Stage N Note] │
├───────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ Stage 1 Note [model] [Copy] │ │
│ │ [editable note text] │ │
│ │ ──────────────────────────────────────────────── │ │
│ │ ⚠ Don't Miss — Stage 1 │ │
│ │ • point 1 │ │
│ │ • point 2 │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Stage 2 Note [model] [Copy] │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ [Refine input] [Refine latest] [Shorter] │
│ [+ Add more (next stage)] [✓ Save & Done (with MDM)] │
├───────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ (after │
│ │ 📋 Final Consolidated Note [Copy] │ finalize) │
│ │ [polished single note from edConsolidate] │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 💵 Medical Decision Making (2023 E/M) [99284] │ │
│ │ Problems Addressed (moderate): ... │ │
│ │ Data Reviewed (moderate): ... │ │
│ │ Risk (moderate): ... │ │
│ │ Suggested Level: 99284 — rationale... │ │
│ └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
```
---
**Files involved** (so a reader can map this to the codebase):
| Path | Role |
|---|---|
| `public/components/ed-encounter.html` | Tab markup |
| `public/js/ed-encounters.js` | All client logic (~500 lines) |
| `public/js/encounters.js` | Sacred — saveEncounter, sessionStorage, tabMap (2-string touch only) |
| `src/routes/edEncounters.js` | `/generate` + `/finalize` endpoints |
| `src/utils/prompts.js` | `edEncounterStaged`, `edConsolidate`, `edFinalize` keys |
| `src/utils/promptSafe.js` | `wrapUserText` + `INJECTION_GUARD` |
| `src/routes/encounters.js` | Generic save infrastructure (saved_encounters table) |
| `src/routes/memories.js` | `template_ed` category in `VALID_CATEGORIES` |
| `public/js/memories.js` | `template_ed: 'ED Template'` label + `getUserMemoryContext` |
| `public/components/settings.html` | `<option value="template_ed">` in the category dropdown |
| `public/index.html` | Tab button, lazy-load section, script tag |
| `server.js` | Mounts `edEncounters` route on `/api` |

View file

@ -227,7 +227,7 @@ LITELLM_API_KEY=optional
### Browser Whisper stuck at "Initializing"
- **Cause:** Models not loaded or network blocked during initial download
- **Fix:** See BROWSER_WHISPER_TROUBLESHOOTING.md
- **Fix:** See [browser-whisper-troubleshooting.md](browser-whisper-troubleshooting.md)
### Server transcription returns "No provider"
- **Cause:** API keys not configured

View file

@ -0,0 +1,24 @@
<div class="module-header">
<h2><i class="fas fa-book" style="color:#2563eb;"></i> Documentation</h2>
<p>Project docs served from the docs/ folder. Admin only. Updates with each container rebuild.</p>
</div>
<div class="docs-shell">
<!-- Sidebar — file tree generated from /api/admin/docs/tree -->
<aside class="docs-sidebar">
<div class="docs-sidebar-head">
<input type="text" id="docs-filter" class="docs-filter" placeholder="Filter (e.g. ed, auth)" autocomplete="off">
</div>
<div id="docs-tree" class="docs-tree">
<div class="docs-loading">Loading…</div>
</div>
</aside>
<!-- Reader — server-rendered markdown HTML -->
<section id="docs-reader" class="docs-reader">
<div id="docs-reader-meta" class="docs-reader-meta"></div>
<article id="docs-reader-body" class="docs-reader-body">
<p style="color:var(--g500);font-size:13px;">Pick a doc from the sidebar to read it.</p>
</article>
</section>
</div>

View file

@ -52,40 +52,25 @@
<button id="btn-ed-generate" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate Stage <span id="ed-gen-stage-num">1</span> Note</button>
<!-- Output: note + don't-miss panel + stage controls -->
<div id="ed-note-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> ED Note <span id="ed-note-stage-tag" class="model-tag" style="background:var(--g100);color:var(--g600);margin-left:6px;">Stage 1</span></h3>
<div class="output-actions">
<span id="ed-note-model-tag" class="model-tag"></span>
<button class="btn-sm btn-primary" data-action="copy" data-target="ed-note-text"><i class="fas fa-copy"></i> Copy</button>
<button class="btn-sm btn-ghost" data-action="speak" data-target="ed-note-text"><i class="fas fa-volume-high"></i> Read</button>
</div>
</div>
<div id="ed-note-text" class="output-text" contenteditable="true"></div>
<!-- Per-stage cards rendered here by ed-encounters.js renderStages().
Each card: editable note + embedded "don't miss" panel for that stage.
Stages persist on screen — physician can edit any stage; the latest-on-screen
version of each stage is what the finalize call sends to MDM. -->
<div id="ed-stages-container"></div>
<!-- Tail controls — always operate on the LATEST stage. Hidden until first generation. -->
<div id="ed-tail-controls" class="card hidden" style="margin-top:10px;">
<div class="refine-bar">
<textarea id="ed-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'add that patient received Zofran')"></textarea>
<button class="btn-sm btn-primary" id="ed-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<textarea id="ed-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify the latest stage's note (e.g., 'add that patient received Zofran')"></textarea>
<button class="btn-sm btn-primary" id="ed-refine-btn"><i class="fas fa-edit"></i> Refine latest</button>
<button class="btn-sm btn-ghost" id="ed-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
</div>
<!-- Stage controls — appear after note generation -->
<div id="ed-stage-controls" style="padding:12px 16px;border-top:1px solid var(--g100);display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button id="btn-ed-add-more" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add more (next stage)</button>
<button id="btn-ed-finalize" class="btn-sm" style="background:var(--green);color:white;border:none;"><i class="fas fa-check-double"></i> Save &amp; Done (with MDM)</button>
<span style="font-size:11px;color:var(--g500);">Stages auto-save as drafts. "Done" generates the MDM block and locks the encounter.</span>
</div>
</div>
<!-- Don't-miss panel (rendered next to the note via ed-encounters.js) -->
<div id="ed-dontmiss-card" class="card hidden" style="margin-top:10px;border-left:3px solid #f59e0b;">
<div class="card-header">
<h3><i class="fas fa-triangle-exclamation" style="color:#f59e0b;"></i> Don't Miss</h3>
<span style="font-size:11px;color:var(--g500);">High-yield items tailored to the chief complaint and age. Suggestions only.</span>
</div>
<div id="ed-dontmiss-list" style="padding:8px 16px;"></div>
</div>
<!-- MDM block (rendered after finalize) -->
<div id="ed-mdm-card" class="card hidden" style="margin-top:10px;border-left:3px solid var(--green);">
<div class="card-header output-header">

View file

@ -1045,3 +1045,104 @@ textarea.full-input{resize:vertical;}
/* In trash view, list items aren't clickable as drafts — make that visible */
.notes-list[data-pane="trash"] .notes-list-item{cursor:default;opacity:0.85;}
/* ─── Extensions / Pagers cards ─────────────────────────────────────── */
/* Entrance: gentle fade-up. Stagger via inline --ext-stagger CSS var
set per-card in renderCard() so cards appear in a wave. */
@keyframes extCardIn{
from{opacity:0;transform:translateY(6px);}
to {opacity:1;transform:translateY(0);}
}
.ext-card{
opacity:0;
animation:extCardIn .32s cubic-bezier(.2,.8,.2,1) forwards;
animation-delay:var(--ext-stagger,0ms);
transition:transform .15s ease, box-shadow .2s ease, border-color .15s ease;
will-change:transform;
}
.ext-card:hover{
border-color:var(--g300)!important;
box-shadow:0 4px 12px -2px rgba(0,0,0,.08), 0 2px 4px -1px rgba(0,0,0,.04);
transform:translateY(-1px);
}
/* Action buttons: dim by default, pop to full opacity on card hover or
when keyboard focus enters. Keeps the visual weight on the number. */
.ext-card .ext-action{padding:4px 8px;opacity:.55;transition:opacity .15s ease, background .15s ease;}
.ext-card:hover .ext-action,
.ext-card:focus-within .ext-action{opacity:1;}
.ext-card .ext-action:hover{opacity:1;background:var(--g100);}
/* Click-to-copy number: button reset + the copied-flash class. */
.ext-number{transition:color .2s ease;}
.ext-number:hover{text-decoration:underline;text-decoration-style:dotted;text-underline-offset:3px;text-decoration-thickness:1px;}
.ext-number:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:4px;}
@keyframes extCopyFlash{
0% {color:#10b981;transform:scale(1.04);}
100%{color:inherit;transform:scale(1);}
}
.ext-number.ext-copied{animation:extCopyFlash .55s ease;}
.ext-number.ext-copied::after{
content:" ✓ copied";
font-size:11px;
font-weight:600;
color:#10b981;
letter-spacing:.5px;
margin-left:6px;
animation:extCopyFlash .55s ease;
}
/* Reduced motion respect the OS preference. Skip the entrance + hover
transform; keep only color transitions. */
@media (prefers-reduced-motion:reduce){
.ext-card{animation:none;opacity:1;}
.ext-card:hover{transform:none;}
.ext-number.ext-copied{animation:none;}
}
/* ─── Admin Docs viewer ─────────────────────────────────────────────── */
.docs-shell{display:flex;gap:14px;height:calc(100vh - 200px);min-height:500px;}
.docs-sidebar{flex:0 0 280px;display:flex;flex-direction:column;background:var(--g50);border:1px solid var(--g200);border-radius:10px;overflow:hidden;}
.docs-sidebar-head{padding:10px;border-bottom:1px solid var(--g200);background:white;}
.docs-filter{width:100%;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
.docs-filter:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-light);}
.docs-tree{flex:1;overflow-y:auto;padding:6px 4px;font-size:13px;}
.docs-loading,.docs-empty{padding:20px;text-align:center;color:var(--g500);font-size:12px;}
.docs-node{margin:1px 0;}
.docs-dir>.docs-children{margin-left:14px;border-left:1px dashed var(--g200);padding-left:4px;}
.docs-dir-toggle,.docs-file-btn{display:flex;align-items:center;gap:6px;width:100%;padding:5px 8px;background:none;border:0;border-radius:6px;font-family:inherit;font-size:13px;color:var(--g700);cursor:pointer;text-align:left;transition:background .12s ease, color .12s ease;}
.docs-dir-toggle:hover,.docs-file-btn:hover{background:white;color:var(--g900);}
.docs-file-btn.active{background:var(--blue-light);color:var(--blue-dark);font-weight:600;}
.docs-icon{font-size:11px;color:var(--g500);width:14px;text-align:center;flex-shrink:0;}
.docs-file-btn.active .docs-icon{color:var(--blue);}
.docs-arrow{font-size:9px;color:var(--g400);width:10px;text-align:center;flex-shrink:0;transition:transform .12s ease;}
.docs-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.docs-file-parent{font-size:10px;color:var(--g400);margin-left:6px;}
.docs-reader{flex:1;min-width:0;background:white;border:1px solid var(--g200);border-radius:10px;overflow-y:auto;display:flex;flex-direction:column;}
.docs-reader-meta{padding:8px 18px;border-bottom:1px solid var(--g100);background:var(--g50);font-size:11px;color:var(--g500);font-family:ui-monospace,SFMono-Regular,monospace;}
.docs-reader-body{flex:1;padding:24px 32px;line-height:1.7;color:var(--g800);font-size:14px;overflow-x:hidden;}
.docs-reader-body h1,.docs-reader-body h2,.docs-reader-body h3,.docs-reader-body h4{color:var(--g900);font-weight:700;margin:1.4em 0 .6em;line-height:1.3;}
.docs-reader-body h1{font-size:24px;border-bottom:1px solid var(--g200);padding-bottom:8px;}
.docs-reader-body h2{font-size:20px;}
.docs-reader-body h3{font-size:17px;}
.docs-reader-body h4{font-size:15px;color:var(--g700);}
.docs-reader-body p{margin:0 0 1em;}
.docs-reader-body ul,.docs-reader-body ol{margin:0 0 1em;padding-left:1.6em;}
.docs-reader-body li{margin:.25em 0;}
.docs-reader-body code{background:var(--g100);padding:1px 5px;border-radius:4px;font-size:.88em;font-family:ui-monospace,SFMono-Regular,monospace;color:#be185d;}
.docs-reader-body pre{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow-x:auto;margin:0 0 1em;font-size:12px;line-height:1.55;}
.docs-reader-body pre code{background:none;padding:0;color:inherit;font-size:inherit;}
.docs-reader-body blockquote{border-left:3px solid var(--blue);background:var(--blue-light);padding:8px 14px;margin:0 0 1em;border-radius:0 6px 6px 0;color:var(--g700);}
.docs-reader-body blockquote p:last-child{margin-bottom:0;}
.docs-reader-body table{border-collapse:collapse;margin:0 0 1em;font-size:13px;display:block;overflow-x:auto;}
.docs-reader-body th,.docs-reader-body td{border:1px solid var(--g200);padding:6px 10px;text-align:left;}
.docs-reader-body th{background:var(--g50);font-weight:600;}
.docs-reader-body a{color:var(--blue);text-decoration:none;}
.docs-reader-body a:hover{text-decoration:underline;}
.docs-reader-body hr{border:0;border-top:1px solid var(--g200);margin:1.6em 0;}
@media (max-width:900px){
.docs-shell{flex-direction:column;height:auto;}
.docs-sidebar{flex:0 0 auto;max-height:240px;}
.docs-reader{min-height:60vh;}
}

View file

@ -264,6 +264,10 @@
<i class="fas fa-pen-to-square"></i>
<span>Content Manager</span>
</button>
<button class="tab-btn hidden" data-tab="docs" id="docs-tab-btn">
<i class="fas fa-book"></i>
<span>Docs</span>
</button>
<span class="sidebar-section-label">Account</span>
<button class="tab-btn" data-tab="settings">
<i class="fas fa-cog"></i>
@ -312,6 +316,7 @@
<section id="notes-tab" class="tab-content" data-component="notes"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
<section id="docs-tab" class="tab-content" data-component="admin-docs"></section>
<section id="admin-tab" class="tab-content" data-component="admin"></section>
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
<section id="sickvisit-tab" class="tab-content" data-component="sickvisit"></section>
@ -472,6 +477,7 @@
<script defer src="/js/ed-encounters.js"></script>
<script defer src="/js/encounters.js"></script>
<script defer src="/js/memories.js"></script>
<script defer src="/js/admin-docs.js"></script>
<script defer src="/js/documents.js"></script>
<script defer src="/js/calc-math.js"></script>
<script defer src="/js/drugs-loader.js"></script>

233
public/js/admin-docs.js Normal file
View file

@ -0,0 +1,233 @@
// ============================================================
// admin-docs.js — admin-only docs viewer.
//
// Lazy-loads /api/admin/docs/tree once on first tab activation,
// renders a collapsible file tree in the sidebar. Clicking a .md
// node fetches /api/admin/docs/file?path=X and renders the
// pre-sanitised HTML returned by the server (server uses `marked`).
//
// The route is gated to role=admin server-side. The sidebar tab
// button (#docs-tab-btn) is hidden by default and revealed by
// auth.js after login when user.role === 'admin'.
// ============================================================
(function () {
'use strict';
var _inited = false;
var _treeLoaded = false;
var _tree = [];
var _flatFiles = []; // flat list of {name, path, parent} for filter
var _expanded = {}; // map of dir-path → bool, persisted in UIState
function $(id) { return document.getElementById(id); }
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// Pretty label from a file/dir basename. Drops .md, replaces dashes
// with spaces, title-cases the first letter of each word. README is
// shown as "Index" so the entry-point doc is more obvious in the tree.
function prettyName(name, isDir) {
if (!isDir && /^readme\.md$/i.test(name)) return 'Index';
var base = name.replace(/\.md$/i, '');
return base.replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
}
// Recursively render the tree into HTML.
function renderNode(node, depth) {
if (node.type === 'dir') {
var open = _expanded[node.path] !== false; // default open
var arrow = open ? 'fa-chevron-down' : 'fa-chevron-right';
var label = prettyName(node.name, true);
var html =
'<div class="docs-node docs-dir" data-path="' + escHtml(node.path) + '" style="--depth:' + depth + ';">' +
'<button type="button" class="docs-dir-toggle" data-toggle="' + escHtml(node.path) + '">' +
'<i class="fas ' + arrow + ' docs-arrow"></i>' +
'<i class="fas fa-folder docs-icon"></i>' +
'<span class="docs-label">' + escHtml(label) + '</span>' +
'</button>' +
'<div class="docs-children" ' + (open ? '' : 'hidden') + '>' +
(node.children || []).map(function (c) { return renderNode(c, depth + 1); }).join('') +
'</div>' +
'</div>';
return html;
}
// File node
return '<div class="docs-node docs-file" data-path="' + escHtml(node.path) + '" style="--depth:' + depth + ';">' +
'<button type="button" class="docs-file-btn" data-file="' + escHtml(node.path) + '" title="' + escHtml(node.path) + '">' +
'<i class="fas fa-file-lines docs-icon"></i>' +
'<span class="docs-label">' + escHtml(prettyName(node.name, false)) + '</span>' +
'</button>' +
'</div>';
}
// Flatten the tree into _flatFiles for the filter to search across.
function flatten(nodes, parentLabel) {
nodes.forEach(function (n) {
if (n.type === 'dir') {
flatten(n.children || [], (parentLabel ? parentLabel + ' / ' : '') + prettyName(n.name, true));
} else {
_flatFiles.push({
name: prettyName(n.name, false),
path: n.path,
parent: parentLabel || ''
});
}
});
}
function renderTree() {
var host = $('docs-tree');
if (!host) return;
if (!_tree.length) {
host.innerHTML = '<div class="docs-empty">No docs found.</div>';
return;
}
host.innerHTML = _tree.map(function (n) { return renderNode(n, 0); }).join('');
}
function renderFilterResults(q) {
var host = $('docs-tree');
if (!host) return;
var lc = q.toLowerCase();
var matches = _flatFiles.filter(function (f) {
return f.name.toLowerCase().indexOf(lc) !== -1
|| f.path.toLowerCase().indexOf(lc) !== -1
|| f.parent.toLowerCase().indexOf(lc) !== -1;
});
if (matches.length === 0) {
host.innerHTML = '<div class="docs-empty">No matches for "' + escHtml(q) + '".</div>';
return;
}
host.innerHTML = matches.map(function (f) {
var parent = f.parent ? '<span class="docs-file-parent">' + escHtml(f.parent) + '</span>' : '';
return '<div class="docs-node docs-file" data-path="' + escHtml(f.path) + '" style="--depth:0;">' +
'<button type="button" class="docs-file-btn" data-file="' + escHtml(f.path) + '" title="' + escHtml(f.path) + '">' +
'<i class="fas fa-file-lines docs-icon"></i>' +
'<span class="docs-label">' + escHtml(f.name) + '</span>' +
parent +
'</button>' +
'</div>';
}).join('');
}
function loadTree() {
if (_treeLoaded) return;
fetch('/api/admin/docs/tree', { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) {
$('docs-tree').innerHTML = '<div class="docs-empty">' + escHtml((data && data.error) || 'Tree load failed') + '</div>';
return;
}
_tree = data.tree || [];
_flatFiles = [];
flatten(_tree, '');
// Restore expanded state from UIState before render
try {
var saved = window.UIState && window.UIState.get('docs.expanded');
if (saved) _expanded = JSON.parse(saved);
} catch (e) {}
renderTree();
_treeLoaded = true;
// Auto-load the top README if available, else the first file
var first = _flatFiles[0];
if (first) {
var savedPath = (window.UIState && window.UIState.get('docs.lastPath')) || first.path;
loadFile(savedPath);
markActive(savedPath);
}
})
.catch(function (err) {
$('docs-tree').innerHTML = '<div class="docs-empty">Tree load failed: ' + escHtml(err.message || String(err)) + '</div>';
});
}
function loadFile(relPath) {
var body = $('docs-reader-body');
var meta = $('docs-reader-meta');
if (!body) return;
body.innerHTML = '<div class="docs-loading">Loading…</div>';
fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data || !data.success) {
body.innerHTML = '<p style="color:var(--red);">' + escHtml((data && data.error) || 'Load failed') + '</p>';
return;
}
body.innerHTML = data.html || '';
if (meta) {
meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : '');
}
// Persist last opened
if (window.UIState) window.UIState.set('docs.lastPath', relPath);
// Scroll content area to top so deep-link readers don't land mid-doc
var reader = $('docs-reader');
if (reader) reader.scrollTop = 0;
})
.catch(function (err) {
body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>';
});
}
function markActive(relPath) {
document.querySelectorAll('.docs-file-btn').forEach(function (b) {
b.classList.toggle('active', b.dataset.file === relPath);
});
}
function persistExpanded() {
if (!window.UIState) return;
try { window.UIState.set('docs.expanded', JSON.stringify(_expanded)); } catch (e) {}
}
// ── Wire events ────────────────────────────────────────────────────
function init() {
document.addEventListener('click', function (e) {
var fileBtn = e.target.closest('.docs-file-btn');
if (fileBtn) {
var p = fileBtn.dataset.file;
if (p) { loadFile(p); markActive(p); }
return;
}
var dirToggle = e.target.closest('.docs-dir-toggle');
if (dirToggle) {
var dir = dirToggle.dataset.toggle;
var node = dirToggle.closest('.docs-dir');
var children = node && node.querySelector('.docs-children');
var arrow = dirToggle.querySelector('.docs-arrow');
var willOpen = !(_expanded[dir] !== false); // toggle
_expanded[dir] = willOpen;
if (children) {
if (willOpen) children.removeAttribute('hidden');
else children.setAttribute('hidden', '');
}
if (arrow) {
arrow.classList.toggle('fa-chevron-down', willOpen);
arrow.classList.toggle('fa-chevron-right', !willOpen);
}
persistExpanded();
}
});
var filter = $('docs-filter');
if (filter) {
var t = null;
filter.addEventListener('input', function () {
clearTimeout(t);
t = setTimeout(function () {
var q = filter.value.trim();
if (!q) renderTree();
else renderFilterResults(q);
}, 120);
});
}
}
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'docs') return;
if (!_inited) { _inited = true; init(); }
loadTree();
});
console.log('Admin docs viewer loaded');
})();

View file

@ -181,6 +181,13 @@ document.addEventListener('DOMContentLoaded', function() {
else adminTabBtn.classList.add('hidden');
}
// Show Docs tab for admins only (same gate as Admin)
var docsTabBtn = document.getElementById('docs-tab-btn');
if (docsTabBtn) {
if (user.role === 'admin') docsTabBtn.classList.remove('hidden');
else docsTabBtn.classList.add('hidden');
}
// Show Content Manager tab for admin + moderator
var cmsTabBtn = document.getElementById('cms-tab-btn');
if (cmsTabBtn) {
@ -221,6 +228,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (mainApp) mainApp.style.display = 'none';
var adminTabBtn = document.getElementById('admin-tab-btn');
if (adminTabBtn) adminTabBtn.classList.add('hidden');
var docsTabBtn = document.getElementById('docs-tab-btn');
if (docsTabBtn) docsTabBtn.classList.add('hidden');
showLoginForm();
// Clear fields after browser autofill has had a chance to run
setTimeout(function() {

View file

@ -1,30 +1,39 @@
// ============================================================
// ED ENCOUNTER TAB — multi-stage emergency note with don't-miss
// tooltips and 2023 E/M MDM finalize.
// ED ENCOUNTER TAB — multi-stage emergency note with per-stage
// don't-miss tooltips and 2023 E/M MDM finalize.
//
// State model:
// _state = {
// stage: 1|2|..., // current stage number
// stages: [ // per-stage history
// stage: 1|2|..., // current stage number (what the recorder is for)
// stages: [ // per-stage history — one entry per generated stage
// { transcript, note, dontMiss, model, generatedAt }
// ],
// finalized: false,
// finalNote: null, // consolidated final note from /finalize
// mdm: null
// }
//
// Persistence: localStorage on every mutation (debounced) + auto-save
// to saved_encounters DB row (status='draft') on stage transitions.
// "Save & Done" generates MDM, marks status='final', clears localStorage.
// UX rules:
// - Every generated stage stays on screen as its own editable card with
// its own don't-miss panel. The physician can edit any stage at any
// time; gatherCurrentNotes() reads the live DOM into _state.stages
// before any operation that depends on the current text.
// - The badge under the patient label reflects exactly where you are:
// "Stage N (recording)" — advanced to stage N but no note yet
// "Stage N" — stage N's note has been generated
// "Finalized" — Save & Done done
// - Save & Done sends every stage's current text to the server, which
// consolidates them into one polished final note and then generates
// MDM from that. Stages become read-only after finalize.
//
// Sacred-file note: this file owns ALL ED logic. encounters.js gets
// a 2-string touch only (sessionStorage array + tabMap).
// Persistence: localStorage on every input (debounced) + auto-save
// draft row to saved_encounters on stage transitions.
// ============================================================
(function() {
'use strict';
var LS_KEY = 'ped_ed_draft_v1';
var AUTOSAVE_MS = 1500;
var _state = freshState();
var _saveTimer = null;
@ -36,58 +45,7 @@
var _liveTranscript = '';
function freshState() {
return { stage: 1, stages: [], finalized: false, mdm: null };
}
// ── Persistence ──────────────────────────────────────────────────────
function persistLocal() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(function() {
try {
var snap = {
state: _state,
label: getVal('ed-label'),
age: getVal('ed-age'),
gender: getVal('ed-gender'),
cc: getVal('ed-cc'),
currentTranscript: getText('ed-transcript'),
editedNote: getText('ed-note-text')
};
localStorage.setItem(LS_KEY, JSON.stringify(snap));
} catch (e) {}
}, 300);
}
function loadLocal() {
try {
var raw = localStorage.getItem(LS_KEY);
if (!raw) return;
var snap = JSON.parse(raw);
if (!snap || !snap.state) return;
_state = snap.state;
setVal('ed-label', snap.label || '');
setVal('ed-age', snap.age || '');
setVal('ed-gender', snap.gender || '');
setVal('ed-cc', snap.cc || '');
setText('ed-transcript', snap.currentTranscript || '');
// Render last generated stage if present
var last = lastStage();
if (last) {
setText('ed-note-text', snap.editedNote || last.note || '');
renderDontMiss(last.dontMiss || []);
showOutput();
updateStageTags();
}
if (_state.finalized && _state.mdm) renderMdm(_state.mdm);
} catch (e) {}
}
function clearLocal() {
try { localStorage.removeItem(LS_KEY); } catch (e) {}
}
function lastStage() {
return _state.stages.length ? _state.stages[_state.stages.length - 1] : null;
return { stage: 1, stages: [], finalized: false, finalNote: null, mdm: null };
}
// ── DOM helpers ──────────────────────────────────────────────────────
@ -101,28 +59,202 @@
function setText(id, v) {
var el = document.getElementById(id);
if (!el) return;
if (typeof setOutputText === 'function' && id === 'ed-note-text') setOutputText(el, v);
else el.textContent = v;
el.textContent = v;
}
function showEl(id) { var el = document.getElementById(id); if (el) el.classList.remove('hidden'); }
function hideEl(id) { var el = document.getElementById(id); if (el) el.classList.add('hidden'); }
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function updateStageTags() {
var s = _state.stage;
function stageTextElId(idx) { return 'ed-stage-text-' + idx; }
// Read the current contenteditable text from each rendered stage card
// and write it back into _state.stages. Call before any operation
// (advance, finalize, persist) that needs the latest user edits.
function gatherCurrentNotes() {
for (var i = 0; i < _state.stages.length; i++) {
var el = document.getElementById(stageTextElId(i));
if (el) {
var t = (el.innerText || el.textContent || '').trim();
if (t) _state.stages[i].note = t;
}
}
}
// ── Persistence ──────────────────────────────────────────────────────
function persistLocal() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(function() {
try {
gatherCurrentNotes();
var snap = {
state: _state,
label: getVal('ed-label'),
age: getVal('ed-age'),
gender: getVal('ed-gender'),
cc: getVal('ed-cc'),
currentTranscript: getText('ed-transcript')
};
localStorage.setItem(LS_KEY, JSON.stringify(snap));
} catch (e) {}
}, 300);
}
function loadLocal() {
try {
var raw = localStorage.getItem(LS_KEY);
if (!raw) return;
var snap = JSON.parse(raw);
if (!snap || !snap.state) return;
_state = snap.state;
// Backfill new fields if a snapshot pre-dates this version
if (typeof _state.finalNote === 'undefined') _state.finalNote = null;
setVal('ed-label', snap.label || '');
setVal('ed-age', snap.age || '');
setVal('ed-gender', snap.gender || '');
setVal('ed-cc', snap.cc || '');
setText('ed-transcript', snap.currentTranscript || '');
renderStages();
if (_state.finalNote) renderFinalNote(_state.finalNote);
if (_state.finalized && _state.mdm) renderMdm(_state.mdm);
updateBadge();
} catch (e) {}
}
function clearLocal() {
try { localStorage.removeItem(LS_KEY); } catch (e) {}
}
// ── Stage rendering ──────────────────────────────────────────────────
function renderStages() {
var container = document.getElementById('ed-stages-container');
if (!container) return;
container.innerHTML = '';
if (_state.stages.length === 0) {
hideEl('ed-tail-controls');
return;
}
for (var i = 0; i < _state.stages.length; i++) {
container.appendChild(buildStageCard(i, _state.stages[i]));
}
// Tail controls visible once at least one stage exists.
// Hidden when finalized — encounter is locked.
if (_state.finalized) hideEl('ed-tail-controls');
else showEl('ed-tail-controls');
}
function buildStageCard(idx, stage) {
var card = document.createElement('div');
card.className = 'card output-card stage-card';
card.dataset.stageIdx = String(idx);
card.style.marginBottom = '10px';
var modelShort = (stage.model || '').split('/').pop();
var editable = _state.finalized ? 'false' : 'true';
var dontMissHtml = '';
if (stage.dontMiss && stage.dontMiss.length) {
dontMissHtml =
'<div style="border-top:1px solid var(--g100);padding:10px 14px;background:#fffbeb;">' +
'<div style="font-size:12px;font-weight:700;color:#92400e;margin-bottom:6px;">' +
'<i class="fas fa-triangle-exclamation" style="color:#f59e0b;margin-right:6px;"></i>' +
'Don\'t Miss — Stage ' + (idx + 1) +
'</div>' +
stage.dontMiss.map(function(it) {
var why = it.why ? '<div style="font-size:11px;color:var(--g500);margin-top:1px;">' + escHtml(it.why) + '</div>' : '';
return '<div style="padding:4px 0;font-size:13px;color:var(--g800);">' +
'<i class="fas fa-circle-exclamation" style="color:#f59e0b;font-size:10px;margin-right:6px;"></i>' +
escHtml(it.point) +
why +
'</div>';
}).join('') +
'</div>';
}
card.innerHTML =
'<div class="card-header output-header">' +
'<h3>' +
'<i class="fas fa-file-medical"></i> Stage ' + (idx + 1) + ' Note' +
(modelShort ? ' <span class="model-tag" style="margin-left:6px;">' + escHtml(modelShort) + '</span>' : '') +
'</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + stageTextElId(idx) + '"><i class="fas fa-copy"></i> Copy</button>' +
'</div>' +
'</div>' +
'<div id="' + stageTextElId(idx) + '" class="output-text" contenteditable="' + editable + '"></div>' +
dontMissHtml;
// Set the note text via textContent so contenteditable shows the correct content
// (and so any HTML in the model output is treated as plain text, not parsed).
var textEl = card.querySelector('#' + stageTextElId(idx));
if (textEl) textEl.textContent = stage.note || '';
return card;
}
function renderFinalNote(note) {
var container = document.getElementById('ed-final-note-card');
if (!container) {
// Create the final-note card lazily and insert before the MDM card
container = document.createElement('div');
container.id = 'ed-final-note-card';
container.className = 'card output-card';
container.style.cssText = 'margin-top:10px;border-left:3px solid #2563eb;';
var mdm = document.getElementById('ed-mdm-card');
if (mdm && mdm.parentNode) mdm.parentNode.insertBefore(container, mdm);
else document.body.appendChild(container);
}
container.innerHTML =
'<div class="card-header output-header">' +
'<h3><i class="fas fa-file-medical-alt" style="color:#2563eb;"></i> Final Consolidated Note</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="ed-final-note-text"><i class="fas fa-copy"></i> Copy</button>' +
'</div>' +
'</div>' +
'<div id="ed-final-note-text" class="output-text"></div>';
var t = container.querySelector('#ed-final-note-text');
if (t) t.textContent = note;
container.classList.remove('hidden');
container.scrollIntoView({ behavior: 'smooth' });
}
function renderMdm(mdm) {
var card = document.getElementById('ed-mdm-card');
var text = document.getElementById('ed-mdm-text');
var tag = document.getElementById('ed-mdm-level-tag');
if (!card || !text || !mdm) return;
if (tag) tag.textContent = mdm.suggestedLevel || '';
text.innerHTML =
'<div style="margin-bottom:6px;"><strong>Problems Addressed</strong> (' + escHtml(mdm.problemsAddressed) + ')<br>' + escHtml(mdm.problemsNarrative) + '</div>' +
'<div style="margin-bottom:6px;"><strong>Data Reviewed</strong> (' + escHtml(mdm.dataReviewed) + ')<br>' + escHtml(mdm.dataNarrative) + '</div>' +
'<div style="margin-bottom:6px;"><strong>Risk</strong> (' + escHtml(mdm.risk) + ')<br>' + escHtml(mdm.riskNarrative) + '</div>' +
'<div style="padding-top:8px;border-top:1px solid var(--g100);"><strong>Suggested Level: ' + escHtml(mdm.suggestedLevel) + '</strong><br>' +
'<span style="font-size:12px;color:var(--g600);">' + escHtml(mdm.levelRationale) + '</span></div>' +
'<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestion only. Verify against your institution\'s coding guidelines.</p>';
card.classList.remove('hidden');
}
// Badge accurately reflects state.
// - "Stage N (recording)" — advanced past last generation; no note yet for stage N
// - "Stage N" — stage N's note has been generated
// - "Finalized" — Save & Done complete
function updateBadge() {
var badge = document.getElementById('ed-stage-badge');
var rec = document.getElementById('ed-rec-stage-num');
var gen = document.getElementById('ed-gen-stage-num');
var tag = document.getElementById('ed-note-stage-tag');
if (badge) badge.textContent = _state.finalized ? 'Finalized' : 'Stage ' + s;
if (rec) rec.textContent = String(s);
if (gen) gen.textContent = String(s);
if (tag) tag.textContent = 'Stage ' + s;
}
function showOutput() {
var card = document.getElementById('ed-note-output');
if (card) { card.classList.remove('hidden'); card.scrollIntoView({ behavior: 'smooth' }); }
if (rec) rec.textContent = String(_state.stage);
if (gen) gen.textContent = String(_state.stage);
if (!badge) return;
if (_state.finalized) {
badge.textContent = 'Finalized';
badge.style.background = '#d1fae5';
badge.style.color = '#047857';
return;
}
var generatedHere = _state.stages.length >= _state.stage;
if (generatedHere) {
badge.textContent = 'Stage ' + _state.stage;
badge.style.background = 'var(--g100)';
badge.style.color = 'var(--g600)';
} else {
badge.textContent = 'Stage ' + _state.stage + ' (recording)';
badge.style.background = '#fef3c7';
badge.style.color = '#92400e';
}
}
// ── Recording ────────────────────────────────────────────────────────
@ -223,11 +355,16 @@
// ── Generate (per-stage) ─────────────────────────────────────────────
function generateStage() {
if (_state.finalized) { showToast('Encounter is finalized — start a new one', 'error'); return; }
var cc = getVal('ed-cc');
if (!cc.trim()) { showToast('Enter a chief complaint first', 'error'); return; }
var transcript = getText('ed-transcript');
if (!transcript) { showToast('No transcript — record or type the dictation first', 'error'); return; }
// Capture any in-place edits to earlier stages so the AI sees them as the
// baseline for the new stage.
gatherCurrentNotes();
var modelEl = document.getElementById('ed-model-select');
var selectedModel = modelEl ? modelEl.value : '';
@ -236,7 +373,7 @@
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
var prevNote = (_state.stages.length > 0) ? _state.stages[_state.stages.length - 1].note : '';
var prevNote = _state.stages.length > 0 ? _state.stages[_state.stages.length - 1].note : '';
return fetch('/api/ed-encounters/generate', {
method: 'POST',
headers: getAuthHeaders(),
@ -257,108 +394,96 @@
hideBusy();
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
_state.stages.push({
// Place this stage at index (_state.stage - 1). If the user is regenerating
// an existing stage (shouldn't happen via UI, but defensively), overwrite.
var idx = _state.stage - 1;
_state.stages[idx] = {
transcript: transcript,
note: data.note || '',
dontMiss: data.dontMiss || [],
model: data.model || '',
generatedAt: new Date().toISOString()
});
};
setText('ed-note-text', data.note || '');
var modelTag = document.getElementById('ed-note-model-tag');
if (modelTag) modelTag.textContent = (data.model || '').split('/').pop();
renderDontMiss(data.dontMiss || []);
updateStageTags();
showOutput();
renderStages();
updateBadge();
persistLocal();
autoSaveDraft(); // best-effort DB draft save on stage completion
showToast('Stage ' + _state.stage + ' generated. Save & Done, or Add more.', 'success');
autoSaveDraft();
// Scroll to the newly added card
var card = document.querySelector('.stage-card[data-stage-idx="' + idx + '"]');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
showToast('Stage ' + _state.stage + ' generated. Edit, add more, or Save & Done.', 'success');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
// ── Don't-miss rendering ─────────────────────────────────────────────
function renderDontMiss(items) {
var card = document.getElementById('ed-dontmiss-card');
var list = document.getElementById('ed-dontmiss-list');
if (!card || !list) return;
if (!items || !items.length) {
card.classList.add('hidden');
list.innerHTML = '';
// ── Add another stage — DOES NOT change displayed cards ─────────────
function advanceStage() {
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
if (_state.stages.length < _state.stage) {
showToast('Generate the current stage before advancing', 'error');
return;
}
list.innerHTML = items.map(function(it) {
var why = it.why ? '<div style="font-size:11px;color:var(--g500);margin-top:2px;">' + escHtml(it.why) + '</div>' : '';
return '<div style="padding:6px 0;border-bottom:1px solid var(--g100);">' +
'<div style="font-size:13px;color:var(--g800);"><i class="fas fa-circle-exclamation" style="color:#f59e0b;font-size:11px;margin-right:6px;"></i>' + escHtml(it.point) + '</div>' +
why + '</div>';
}).join('');
card.classList.remove('hidden');
}
// ── Add another stage ────────────────────────────────────────────────
function advanceStage() {
// Lock in any physician edits to the displayed note before moving on
var edited = getText('ed-note-text');
if (edited && _state.stages.length) {
_state.stages[_state.stages.length - 1].note = edited;
}
gatherCurrentNotes();
_state.stage += 1;
setText('ed-transcript', '');
_liveTranscript = '';
updateStageTags();
updateBadge();
persistLocal();
showToast('Ready for Stage ' + _state.stage + ' — record or type additional findings.', 'info');
var rec = document.getElementById('ed-record-btn');
if (rec) rec.scrollIntoView({ behavior: 'smooth' });
if (rec) rec.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── Finalize → MDM + persist as final ────────────────────────────────
// ── Finalize → consolidate + MDM + persist as final ──────────────────
function finalize() {
var noteEl = document.getElementById('ed-note-text');
var note = noteEl ? (noteEl.innerText || noteEl.textContent || '').trim() : '';
if (!note) { showToast('Generate a note first', 'error'); return; }
if (_state.finalized) { showToast('Already finalized', 'info'); return; }
if (_state.stages.length === 0) { showToast('Generate at least one stage first', 'error'); return; }
var label = getVal('ed-label');
if (!label.trim()) { showToast('Enter a patient label before finalizing', 'error'); return; }
// Lock in physician edits
if (_state.stages.length) _state.stages[_state.stages.length - 1].note = note;
gatherCurrentNotes();
var modelEl = document.getElementById('ed-model-select');
var selectedModel = modelEl ? modelEl.value : '';
var fullTranscript = _state.stages.map(function(s, i) {
return '--- Stage ' + (i + 1) + ' ---\n' + (s.transcript || '');
}).join('\n\n');
showBusy('Generating MDM and finalizing...');
showBusy('Consolidating note + generating MDM...');
fetch('/api/ed-encounters/finalize', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
note: note,
transcript: fullTranscript,
stages: _state.stages.map(function(s) {
return { transcript: s.transcript || '', note: s.note || '' };
}),
chiefComplaint: getVal('ed-cc'),
patientAge: getVal('ed-age'),
patientGender: getVal('ed-gender'),
model: selectedModel || undefined
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { hideBusy(); showToast(data.error || 'MDM generation failed', 'error'); return; }
if (!data.success) { hideBusy(); showToast(data.error || 'Finalize failed', 'error'); return; }
_state.finalNote = data.finalNote || '';
_state.mdm = data.mdm;
_state.finalized = true;
renderMdm(data.mdm);
renderStages(); // re-render with read-only stage cards
renderFinalNote(_state.finalNote);
renderMdm(_state.mdm);
updateBadge();
var fullTranscript = _state.stages.map(function(s, i) {
return '--- Stage ' + (i + 1) + ' ---\n' + (s.transcript || '');
}).join('\n\n');
var partial = { stages: _state.stages, finalNote: _state.finalNote, mdm: _state.mdm, finalized: true };
// Final save: encounter row with status='final', full transcript +
// note + MDM bundled into partial_data so resume works.
var partial = { stages: _state.stages, mdm: data.mdm, finalized: true };
if (typeof saveEncounter === 'function') {
saveEncounter({
id: window._savedEncId_ed || null,
label: label,
enc_type: 'ed',
transcript: fullTranscript,
generated_note: composeFinalNote(note, data.mdm),
generated_note: composeFinalNoteForSave(_state.finalNote, _state.mdm),
partial_data: partial,
status: 'final',
idempotency_key: 'ed-final-' + Date.now(),
@ -367,7 +492,6 @@
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
clearLocal();
hideBusy();
updateStageTags();
showToast('Encounter finalized and saved.', 'success');
}
});
@ -378,9 +502,9 @@
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function composeFinalNote(noteText, mdm) {
function composeFinalNoteForSave(noteText, mdm) {
if (!mdm) return noteText;
var lines = [
return [
noteText,
'',
'Medical Decision Making (2023 E/M):',
@ -388,41 +512,24 @@
'Data Reviewed (' + (mdm.dataReviewed || '') + '): ' + (mdm.dataNarrative || ''),
'Risk (' + (mdm.risk || '') + '): ' + (mdm.riskNarrative || ''),
'Suggested Level: ' + (mdm.suggestedLevel || '') + ' — ' + (mdm.levelRationale || '')
];
return lines.join('\n');
].join('\n');
}
function renderMdm(mdm) {
var card = document.getElementById('ed-mdm-card');
var text = document.getElementById('ed-mdm-text');
var tag = document.getElementById('ed-mdm-level-tag');
if (!card || !text || !mdm) return;
if (tag) tag.textContent = mdm.suggestedLevel || '';
var html =
'<div style="margin-bottom:6px;"><strong>Problems Addressed</strong> (' + escHtml(mdm.problemsAddressed) + ')<br>' + escHtml(mdm.problemsNarrative) + '</div>' +
'<div style="margin-bottom:6px;"><strong>Data Reviewed</strong> (' + escHtml(mdm.dataReviewed) + ')<br>' + escHtml(mdm.dataNarrative) + '</div>' +
'<div style="margin-bottom:6px;"><strong>Risk</strong> (' + escHtml(mdm.risk) + ')<br>' + escHtml(mdm.riskNarrative) + '</div>' +
'<div style="padding-top:8px;border-top:1px solid var(--g100);"><strong>Suggested Level: ' + escHtml(mdm.suggestedLevel) + '</strong><br>' +
'<span style="font-size:12px;color:var(--g600);">' + escHtml(mdm.levelRationale) + '</span></div>' +
'<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestion only. Verify against your institution\'s coding guidelines.</p>';
text.innerHTML = html;
card.classList.remove('hidden');
card.scrollIntoView({ behavior: 'smooth' });
}
// ── Save draft (manual / auto on stage advance) ──────────────────────
// ── Save draft (manual / auto on stage transition) ──────────────────
function autoSaveDraft() {
var label = getVal('ed-label');
if (!label.trim()) return; // need a label to save; skip silently
if (!label.trim()) return;
if (typeof saveEncounter !== 'function') return;
gatherCurrentNotes();
var fullTranscript = _state.stages.map(function(s) { return s.transcript || ''; }).join('\n\n');
var partial = { stages: _state.stages, finalized: false };
var lastNote = _state.stages.length ? _state.stages[_state.stages.length - 1].note : '';
saveEncounter({
id: window._savedEncId_ed || null,
label: label,
enc_type: 'ed',
transcript: fullTranscript,
generated_note: lastStage() ? lastStage().note : '',
generated_note: lastNote,
partial_data: partial,
status: 'draft',
idempotency_key: 'ed-draft-' + (window._savedEncId_ed || 'new'),
@ -447,20 +554,37 @@
setVal('ed-gender', '');
setVal('ed-cc', '');
setText('ed-transcript', '');
setText('ed-note-text', '');
_liveTranscript = '';
hideEl('ed-note-output');
hideEl('ed-dontmiss-card');
var container = document.getElementById('ed-stages-container');
if (container) container.innerHTML = '';
var finalCard = document.getElementById('ed-final-note-card');
if (finalCard) finalCard.remove();
hideEl('ed-tail-controls');
hideEl('ed-mdm-card');
var modelTag = document.getElementById('ed-note-model-tag');
if (modelTag) modelTag.textContent = '';
window._savedEncId_ed = null;
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
clearLocal();
updateStageTags();
updateBadge();
showToast('ED encounter cleared for new patient', 'info');
}
// ── Refine the latest stage ──────────────────────────────────────────
function refineLatestStage() {
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
if (_state.stages.length === 0) { showToast('Generate a stage first', 'error'); return; }
var idx = _state.stages.length - 1;
var elId = stageTextElId(idx);
if (typeof refineDocument === 'function') refineDocument(elId, 'ed-refine-input');
}
function shortenLatestStage() {
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
if (_state.stages.length === 0) { showToast('Generate a stage first', 'error'); return; }
var idx = _state.stages.length - 1;
var elId = stageTextElId(idx);
if (typeof shortenDocument === 'function') shortenDocument(elId);
}
// ── Wire click handlers ──────────────────────────────────────────────
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-ed-generate')) generateStage();
@ -468,15 +592,19 @@
if (e.target.closest('#btn-ed-finalize')) finalize();
if (e.target.closest('#btn-ed-new')) resetEncounter();
if (e.target.closest('#btn-ed-save')) manualSaveDraft();
if (e.target.closest('#ed-refine-btn')) refineDocument('ed-note-text', 'ed-refine-input');
if (e.target.closest('#ed-shorten-btn')) shortenDocument('ed-note-text');
if (e.target.closest('#ed-refine-btn')) refineLatestStage();
if (e.target.closest('#ed-shorten-btn')) shortenLatestStage();
});
// Persist on input changes
// Persist on input — capture stage edits via gatherCurrentNotes inside persistLocal
document.addEventListener('input', function(e) {
if (!e.target) return;
var id = e.target.id;
if (id === 'ed-label' || id === 'ed-age' || id === 'ed-gender' || id === 'ed-cc' || id === 'ed-transcript' || id === 'ed-note-text') {
var t = e.target;
if (t.id === 'ed-label' || t.id === 'ed-age' || t.id === 'ed-gender' || t.id === 'ed-cc' || t.id === 'ed-transcript') {
persistLocal();
return;
}
if (t.id && t.id.indexOf('ed-stage-text-') === 0) {
persistLocal();
}
});
@ -484,7 +612,6 @@
// ── Load handler (resume from saved encounters list) ────────────────
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('ed', function(enc) {
// Reset and restore from the row
_state = freshState();
setVal('ed-label', enc.label || '');
var partial = enc.partial_data;
@ -493,20 +620,14 @@
_state.stages = partial.stages;
_state.stage = partial.stages.length || 1;
_state.finalized = !!partial.finalized;
_state.finalNote = partial.finalNote || null;
_state.mdm = partial.mdm || null;
var last = lastStage();
if (last) {
setText('ed-note-text', last.note || '');
renderDontMiss(last.dontMiss || []);
showOutput();
}
if (_state.mdm) renderMdm(_state.mdm);
} else if (enc.generated_note) {
setText('ed-note-text', enc.generated_note);
showOutput();
}
if (enc.transcript) setText('ed-transcript', enc.transcript);
updateStageTags();
renderStages();
if (_state.finalNote) renderFinalNote(_state.finalNote);
if (_state.mdm) renderMdm(_state.mdm);
if (enc.transcript && _state.stages.length === 0) setText('ed-transcript', enc.transcript);
updateBadge();
});
}
@ -517,7 +638,7 @@
_inited = true;
initRecording();
loadLocal();
updateStageTags();
updateBadge();
});
console.log('ED encounters module loaded');

View file

@ -84,10 +84,15 @@
function render() {
var listEl = document.getElementById('ext-list');
if (_items.length === 0) {
var msg = _mode === 'trash' ? 'Trash is empty.' : 'No entries yet. Click Add to create your first one.';
var icon = _mode === 'trash' ? 'fa-trash-can' : 'fa-phone-slash';
var msg = _mode === 'trash' ? 'Trash is empty' : 'No entries yet';
var q = document.getElementById('ext-search').value.trim();
if (q) msg = 'No matches for "' + esc(q) + '".';
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">' + msg + '</p>';
if (q) { icon = 'fa-magnifying-glass'; msg = 'No matches for "' + esc(q) + '"'; }
listEl.innerHTML =
'<div style="text-align:center;padding:60px 20px;color:var(--g400);">' +
'<div style="font-size:48px;margin-bottom:14px;opacity:0.4;"><i class="fas ' + icon + '"></i></div>' +
'<div style="font-size:14px;font-weight:500;color:var(--g500);">' + msg + '</div>' +
'</div>';
return;
}
@ -109,7 +114,7 @@
html += '<div style="margin-left:4px;">';
html += '<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--g500);font-weight:600;margin:6px 0 4px;">' + (type === 'pager' ? '📟 Pagers' : '☎️ Extensions') + '</div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;">';
arr.forEach(function (x) { html += renderCard(x); });
arr.forEach(function (x, i) { html += renderCard(x, i); });
html += '</div></div>';
});
@ -129,37 +134,83 @@
if (action === 'purge') return confirmPurge(id);
});
});
// Click-to-copy on the number itself. Visual feedback is a brief
// green flash via the .ext-copied class — no toast spam since the
// user is doing this repeatedly while phoning around.
listEl.querySelectorAll('.ext-number[data-copy]').forEach(function (btn) {
btn.addEventListener('click', function () {
var v = btn.dataset.copy || '';
if (!v) return;
var done = function () {
btn.classList.add('ext-copied');
setTimeout(function () { btn.classList.remove('ext-copied'); }, 600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).then(done).catch(function () {
// Clipboard blocked — fall back to a textarea hack
var ta = document.createElement('textarea');
ta.value = v; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
});
} else {
done();
}
});
});
}
function renderCard(x) {
function renderCard(x, idx) {
var typeColor = x.type === 'pager' ? '#7c3aed' : '#2563eb';
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
var typeIcon = x.type === 'pager' ? 'fa-pager' : 'fa-phone';
var trashed = x.trashed_at != null;
var bg = trashed ? '#fafaf9' : 'var(--white, #fff)';
// Alternating subtle backgrounds — every other card gets a slight
// off-white tint so a long list reads as distinct rows instead of a
// wall of identical white boxes. Trashed rows use their own muted bg.
var bg = trashed
? '#fafaf9'
: ((idx % 2 === 0) ? 'var(--white, #fff)' : '#fafbfc');
var border = trashed ? 'var(--g300)' : 'var(--g200)';
// Stagger entrance — each card delayed slightly so they appear in a
// gentle wave instead of a single jarring pop. Capped at 12 to keep
// long lists snappy.
var stagger = Math.min(idx == null ? 0 : idx, 12) * 35;
var actions;
if (trashed) {
actions =
'<button class="btn-sm btn-ghost" data-ext-action="restore" data-ext-id="' + x.id + '" title="Restore"><i class="fas fa-rotate-left"></i></button>' +
'<button class="btn-sm btn-ghost" data-ext-action="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
'<button class="btn-sm btn-ghost ext-action" data-ext-action="restore" data-ext-id="' + x.id + '" title="Restore"><i class="fas fa-rotate-left"></i></button>' +
'<button class="btn-sm btn-ghost ext-action" data-ext-action="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
} else {
actions =
'<button class="btn-sm btn-ghost" data-ext-action="edit" data-ext-id="' + x.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
'<button class="btn-sm btn-ghost" data-ext-action="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
'<button class="btn-sm btn-ghost ext-action" data-ext-action="edit" data-ext-id="' + x.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
'<button class="btn-sm btn-ghost ext-action" data-ext-action="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
}
var html = '';
html += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px;display:flex;gap:12px;align-items:center;transition:border-color 0.15s,box-shadow 0.15s;">';
// Card layout:
// - uniform 1px border (no left stripe — too visually bulky)
// - phone/pager icon + colored number = type signal
// - number is click-to-copy with a green flash (data-copy)
// - hover lift + shadow grow via .ext-card:hover in styles.css
// - staggered fade-in (--ext-stagger CSS var consumed by keyframes)
html += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px 14px;display:flex;gap:12px;align-items:flex-start;--ext-stagger:' + stagger + 'ms;">';
html += ' <div style="flex:1;min-width:0;">';
html += ' <div style="display:flex;align-items:baseline;gap:8px;">';
html += ' <div style="font-size:22px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;">' + esc(x.number) + '</div>';
html += ' <span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:6px;background:' + typeColor + '22;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.5px;">' + typeLabel + '</span>';
html += ' <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0;">';
html += ' <i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:14px;align-self:center;flex-shrink:0;"></i>';
html += ' <button type="button" class="ext-number" data-copy="' + esc(x.number) + '" title="Click to copy" style="font-size:20px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;line-height:1.2;word-break:break-all;min-width:0;background:none;border:0;padding:0;cursor:pointer;font-feature-settings:\'tnum\' 1;text-align:left;">' + esc(x.number) + '</button>';
html += ' <span style="font-size:10px;font-weight:600;padding:2px 7px;border-radius:6px;background:' + typeColor + '15;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.6px;flex-shrink:0;">' + typeLabel + '</span>';
html += ' </div>';
html += ' <div style="font-size:13px;color:var(--g700);margin-top:2px;">' + esc(x.name) + '</div>';
if (x.notes) html += ' <div style="font-size:11px;color:var(--g500);margin-top:2px;">' + esc(x.notes) + '</div>';
// Name: scan-target. Larger, bolder, darker, slightly tighter
// letter spacing for that "headline" feel. The number is the action;
// the name is what your eye locks onto when scrolling a list of 50.
html += ' <div class="ext-name" style="font-size:14.5px;color:var(--g900);margin-top:6px;word-break:break-word;font-weight:700;letter-spacing:-0.012em;line-height:1.35;">' + esc(x.name) + '</div>';
if (x.notes) html += ' <div style="font-size:11.5px;color:var(--g500);margin-top:3px;word-break:break-word;line-height:1.45;">' + esc(x.notes) + '</div>';
html += ' </div>';
html += ' <div style="display:flex;gap:2px;flex-shrink:0;">' + actions + '</div>';
html += ' <div style="display:flex;gap:4px;flex-shrink:0;align-self:flex-start;">' + actions + '</div>';
html += '</div>';
return html;
}

View file

@ -236,6 +236,7 @@ app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
app.use('/api/admin', require('./src/routes/admin'));
app.use('/api/admin', require('./src/routes/adminConfig'));
app.use('/api/admin', require('./src/routes/adminMilestones'));
app.use('/api/admin/docs', require('./src/routes/adminDocs'));
// Public endpoints — must come BEFORE any router that applies authMiddleware to /api/*
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');

124
src/routes/adminDocs.js Normal file
View file

@ -0,0 +1,124 @@
// ============================================================
// ADMIN DOCS ROUTE — serves the project docs/ folder as a
// browseable, admin-only site inside the app.
//
// Endpoints (both require req.user.role === 'admin'):
// GET /api/admin/docs/tree — returns nested JSON of docs/
// GET /api/admin/docs/file?path=X — returns rendered HTML for one .md
//
// Path safety: every requested path is normalised + forced to live
// under /app/docs (or wherever DOCS_ROOT resolves). Any attempt to
// traverse out (../, absolute paths, symlinks pointing elsewhere) is
// rejected with 400.
// ============================================================
var express = require('express');
var router = express.Router();
var fs = require('fs');
var path = require('path');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { marked } = require('marked');
router.use(authMiddleware);
// Admin gate. Returns 403 with a generic message — does not leak
// whether the path exists or not.
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin only' });
}
next();
}
// Resolve the docs folder relative to the project root. server.js lives
// at the repo root and __dirname here is src/routes, so go up two.
var DOCS_ROOT = path.resolve(__dirname, '..', '..', 'docs');
// Resolve a user-supplied path safely. Returns null if the path tries
// to escape DOCS_ROOT or doesn't exist.
function safeResolve(relPath) {
if (typeof relPath !== 'string' || !relPath) return null;
// Strip any leading slashes so path.join can't be tricked into an
// absolute path on POSIX.
var trimmed = relPath.replace(/^[\/\\]+/, '');
var abs = path.resolve(DOCS_ROOT, trimmed);
// Must stay inside DOCS_ROOT after resolution (defends against
// ../ traversal and on Windows backslash variants).
if (abs !== DOCS_ROOT && !abs.startsWith(DOCS_ROOT + path.sep)) return null;
if (!fs.existsSync(abs)) return null;
return abs;
}
// Recursively walk DOCS_ROOT into a JSON tree the client can render
// as a sidebar. Skips dotfiles and non-.md files inside leaf nodes.
function buildTree(dir, baseRel) {
var entries = fs.readdirSync(dir, { withFileTypes: true })
.filter(function (d) { return !d.name.startsWith('.'); })
.sort(function (a, b) {
// README always first inside its folder so the index is the obvious landing.
if (a.name.toLowerCase() === 'readme.md') return -1;
if (b.name.toLowerCase() === 'readme.md') return 1;
// Folders before files within the same level.
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
var out = [];
entries.forEach(function (e) {
var rel = baseRel ? (baseRel + '/' + e.name) : e.name;
if (e.isDirectory()) {
out.push({
type: 'dir',
name: e.name,
path: rel,
children: buildTree(path.join(dir, e.name), rel)
});
} else if (/\.md$/i.test(e.name)) {
out.push({
type: 'file',
name: e.name,
path: rel
});
}
});
return out;
}
// ── GET /tree (mounted at /api/admin/docs) ─────────────────────────
router.get('/tree', requireAdmin, function (req, res) {
try {
if (!fs.existsSync(DOCS_ROOT)) {
return res.json({ success: true, tree: [], root: 'docs' });
}
var tree = buildTree(DOCS_ROOT, '');
res.json({ success: true, tree: tree, root: 'docs' });
} catch (e) {
logger.error('[adminDocs] tree failed', e.message);
res.status(500).json({ error: 'Tree build failed' });
}
});
// ── GET /file?path=X (mounted at /api/admin/docs) ──────────────────
router.get('/file', requireAdmin, function (req, res) {
try {
var raw = req.query.path;
var abs = safeResolve(raw);
if (!abs) return res.status(400).json({ error: 'Invalid path' });
var stat = fs.statSync(abs);
if (!stat.isFile()) return res.status(400).json({ error: 'Not a file' });
if (!/\.md$/i.test(abs)) return res.status(400).json({ error: 'Only .md files served' });
var src = fs.readFileSync(abs, 'utf8');
// marked: GitHub-flavoured rendering with automatic line breaks off
// (so authors can wrap prose without inserting <br> everywhere) and
// header IDs on so the client can deep-link via #anchor.
var html = marked.parse(src, { gfm: true, breaks: false, headerIds: true });
res.json({ success: true, html: html, path: raw, bytes: stat.size });
} catch (e) {
logger.error('[adminDocs] file failed', e.message);
res.status(500).json({ error: 'Read failed' });
}
});
module.exports = router;

View file

@ -109,49 +109,100 @@ router.post('/ed-encounters/generate', async function (req, res) {
}
});
// ── POST /ed-encounters/finalize — generate 2023 E/M MDM block ─────
// ── POST /ed-encounters/finalize — consolidate stages + generate MDM ─
// Two-step server-side flow:
// 1. Consolidate every stage's note + transcript into one polished
// final clinical note (PROMPTS.edConsolidate, plain text out).
// 2. Generate the 2023 AMA E/M MDM block from that consolidated note
// (PROMPTS.edFinalize, JSON out).
// Two AI calls so each task gets its own focused prompt — more reliable
// than asking the model for both in one response.
// Body:
// note — final clinical note (required)
// transcript — concatenated transcript across all stages (optional but recommended)
// stages — array of { transcript, note } in chronological order (required)
// chiefComplaint — string, optional but strongly preferred
// patientAge — string, optional
// patientGender — string, optional
// model — optional override
// Returns: { success, mdm: {...}, model }
// Returns: { success, finalNote, mdm, model }
router.post('/ed-encounters/finalize', async function (req, res) {
var start = Date.now();
try {
var { note, transcript, patientAge, model } = req.body;
var { stages, chiefComplaint, patientAge, patientGender, model } = req.body;
if (!note || !note.trim()) {
return res.status(400).json({ error: 'Final note is required' });
if (!Array.isArray(stages) || stages.length === 0) {
return res.status(400).json({ error: 'stages array is required' });
}
var validStages = stages.filter(function (s) { return s && (s.note || s.transcript); });
if (validStages.length === 0) {
return res.status(400).json({ error: 'at least one stage must have a note or transcript' });
}
var context = 'PATIENT: ' + (patientAge || 'Unknown age') + '\n\n';
context += 'FINAL CLINICAL NOTE:\n' + wrapUserText('note', note) + '\n\n';
if (transcript && transcript.trim()) {
context += 'FULL ENCOUNTER TRANSCRIPT (all stages):\n' + wrapUserText('transcript', transcript) + '\n';
// ── Step 1: consolidate ──────────────────────────────────────────
var consolidateContext = 'PATIENT: ' + (patientAge || 'Unknown age')
+ (patientGender ? ', ' + patientGender : '') + '\n';
if (chiefComplaint && chiefComplaint.trim()) {
consolidateContext += 'CHIEF COMPLAINT: ' + wrapUserText('chief_complaint', chiefComplaint) + '\n';
}
consolidateContext += '\nSTAGE-BY-STAGE NOTES AND TRANSCRIPTS (chronological):\n\n';
validStages.forEach(function (s, i) {
consolidateContext += '=== STAGE ' + (i + 1) + ' ===\n';
if (s.transcript && s.transcript.trim()) {
consolidateContext += 'Transcript:\n' + wrapUserText('transcript_' + i, s.transcript) + '\n';
}
if (s.note && s.note.trim()) {
consolidateContext += 'Working note (may include physician edits):\n' + wrapUserText('note_' + i, s.note) + '\n';
}
consolidateContext += '\n';
});
var consolidateResult = await callAI([
{ role: 'system', content: PROMPTS.edConsolidate + INJECTION_GUARD },
{ role: 'user', content: consolidateContext }
], { model: model, maxTokens: 4000 });
var finalNote = String(consolidateResult.content || '').trim();
if (!finalNote) {
return res.status(502).json({ error: 'Consolidate step returned empty note' });
}
var result = await callAI([
// ── Step 2: MDM ──────────────────────────────────────────────────
var fullTranscript = validStages.map(function (s, i) {
return '--- Stage ' + (i + 1) + ' ---\n' + (s.transcript || '');
}).join('\n\n');
var mdmContext = 'PATIENT: ' + (patientAge || 'Unknown age') + '\n\n';
mdmContext += 'FINAL CLINICAL NOTE:\n' + wrapUserText('note', finalNote) + '\n\n';
mdmContext += 'FULL ENCOUNTER TRANSCRIPT (all stages):\n' + wrapUserText('transcript', fullTranscript) + '\n';
var mdmResult = await callAI([
{ role: 'system', content: PROMPTS.edFinalize + INJECTION_GUARD },
{ role: 'user', content: context }
{ role: 'user', content: mdmContext }
], { model: model, maxTokens: 2000 });
var parsed = extractJson(result.content);
var parsed = extractJson(mdmResult.content);
if (!parsed || !parsed.mdm) {
return res.status(502).json({ error: 'AI did not return a parseable MDM block', raw: result.content });
return res.status(502).json({
error: 'MDM step did not return a parseable JSON block',
finalNote: finalNote, // still return the consolidated note so the client doesn't lose work
raw: mdmResult.content
});
}
var dur = Date.now() - start;
var inTokens = ((consolidateResult.usage && consolidateResult.usage.input_tokens) || 0)
+ ((mdmResult.usage && mdmResult.usage.input_tokens) || 0);
var outTokens = ((consolidateResult.usage && consolidateResult.usage.output_tokens) || 0)
+ ((mdmResult.usage && mdmResult.usage.output_tokens) || 0);
logger.apiCall(req.user.id, '/api/ed-encounters/finalize', {
model: result.model,
tokensInput: result.usage && result.usage.input_tokens,
tokensOutput: result.usage && result.usage.output_tokens,
model: mdmResult.model,
tokensInput: inTokens,
tokensOutput: outTokens,
duration: dur,
statusCode: 200
});
logger.audit(req.user.id, 'finalize_ed_encounter', 'ED encounter MDM finalized', req, { category: 'clinical' });
logger.audit(req.user.id, 'finalize_ed_encounter', 'ED encounter consolidated + MDM (' + validStages.length + ' stages)', req, { category: 'clinical' });
res.json({ success: true, mdm: parsed.mdm, model: result.model });
res.json({ success: true, finalNote: finalNote, mdm: parsed.mdm, model: mdmResult.model });
} catch (e) {
logger.error('[edEncounters] finalize failed', e.message);
res.status(500).json({ error: 'Request failed' });

View file

@ -419,36 +419,85 @@ PREVIOUS-STAGE NOTE (only present on Stage 2+):
- The previous note is the established baseline. Integrate the new transcript on top revise sections, add new findings, refine A&P. Do NOT start fresh.
- Preserve the don't-miss items still relevant; drop items that have been addressed.`,
// ======================== ED CONSOLIDATE — final note from all stages ========================
// Takes every stage's note + transcript and produces ONE polished final
// clinical note. Plain text output (no JSON wrapper). Called by /finalize
// before the MDM step so the MDM works against a clean, complete note.
edConsolidate: `You are an expert pediatric emergency-medicine physician finalizing the encounter note. You receive multiple "stage" notes and transcripts from the same encounter — each represents the physician's documentation at a successive point in time (e.g. initial assessment, after labs/imaging, after specialist consult, after disposition decision).
${CORE_RULES}
${ROS_PE_RULES}
YOUR JOB: produce ONE polished, complete final ED note that integrates all the stage notes and transcripts.
NOTE STRUCTURE (plain text, follow CORE RULES NO markdown):
Chief Complaint:
History of Present Illness: (OLDCARTS, pertinent positives and negatives, historian noted)
Review of Systems: (per ROS/PE rules)
Physical Examination: (per ROS/PE rules)
ED Course: (chronological account of interventions, diagnostics ordered and resulted, response to treatment, evolution across all stages)
Assessment and Plan: (problem-oriented; brief differential reasoning where dictated; final disposition admit / observation / discharge; follow-up; return precautions)
INTEGRATION RULES:
- Use the LATEST stage note as the structural baseline (it already integrates earlier stages); use earlier stage notes and transcripts to fill gaps or add detail
- ED Course should reflect the chronological progression across stages (initial assessment workup response disposition decision)
- Resolve contradictions: if a later stage updates a finding (e.g. "now afebrile after antipyretic"), use the updated value AND note the change in ED Course
- Preserve every clinical fact present in any stage; never drop information
- Never invent findings, labs, medications, or diagnoses
- Output the FINAL NOTE TEXT ONLY plain prose with the section headers above. No JSON, no preamble, no commentary.`,
// ======================== ED FINALIZE — MDM ========================
// Takes the consolidated final note and produces the 2023 AMA E/M MDM
// block as JSON. Called second in the /finalize endpoint chain.
edFinalize: `You are an expert pediatric emergency-medicine physician finalizing the medical decision-making (MDM) section for billing per the 2023 AMA E/M guidelines for emergency department visits (CPT 9928199285).
${CORE_RULES}
The 2023 E/M MDM table for ED has THREE elements; the level is set by the HIGHEST level met by AT LEAST 2 OF THE 3.
ELEMENT 1 PROBLEMS ADDRESSED (number AND complexity):
- Minimal: 1 self-limited or minor problem
- Low: 2+ self-limited problems; OR 1 stable chronic illness; OR 1 acute uncomplicated illness/injury
- Moderate: 1+ chronic illness with exacerbation; 2+ stable chronic illnesses; undiagnosed new problem with uncertain prognosis; acute illness with systemic symptoms; OR acute complicated injury
- High: 1+ chronic illness with severe exacerbation or threat to function; OR acute or chronic illness/injury that poses threat to life or bodily function
ELEMENT 2 AMOUNT/COMPLEXITY OF DATA REVIEWED OR ANALYZED:
Categories: tests reviewed (prior labs, imaging), tests ordered (new labs, ECG, imaging), independent interpretation of a test, discussion with another physician, external records reviewed, independent historian.
- Minimal: minimal or no data reviewed/ordered
- Limited: any 2 from one category combination (e.g. review prior note + order one test); OR independent historian
- Moderate: any 3 from across categories; OR independent interpretation of a test (not separately reported); OR discussion with another physician
- Extensive: extensive across multiple categories; multiple specialists involved; complex independent interpretation
ELEMENT 3 RISK OF COMPLICATIONS, MORBIDITY, OR MORTALITY OF PATIENT MANAGEMENT:
- Minimal: routine OTC, rest, reassurance
- Low: prescription drug management of stable problem
- Moderate: prescription drug management of acute problem; minor surgery; social determinants of health affecting management; diagnostic test with identified risk
- High: drug therapy requiring intensive monitoring for toxicity; emergent major surgery; decision regarding hospitalization vs discharge for high-acuity problem; decision not to resuscitate or to de-escalate care
LEVEL MAPPING (2 of 3 elements must meet the level):
- 99281: presenting problem may not require provider; minimal MDM (rare in modern ED)
- 99282: straightforward MDM minimal/low across the three elements
- 99283: low complexity MDM
- 99284: MODERATE complexity MDM (most common ED visit with workup, labs, or imaging and prescription decisions)
- 99285: HIGH complexity MDM (admission for high-acuity care, life-threatening presentation, critical decision-making)
OUTPUT FORMAT STRICT JSON ONLY, no preamble, no code fences, no commentary:
{
"mdm": {
"problemsAddressed": "minimal|low|moderate|high",
"problemsNarrative": "<one short paragraph: number and complexity of problems addressed>",
"problemsNarrative": "<one short paragraph naming the specific problems and their complexity>",
"dataReviewed": "minimal|limited|moderate|extensive",
"dataNarrative": "<one short paragraph: tests ordered/reviewed, independent interpretation, external records, discussion with other professionals>",
"dataNarrative": "<one short paragraph: tests ordered/reviewed, independent interpretation, external records, discussion>",
"risk": "minimal|low|moderate|high",
"riskNarrative": "<one short paragraph: risk of complications, morbidity, mortality of patient management; medications considered or prescribed>",
"riskNarrative": "<one short paragraph: risk of patient management decisions; medications; admit/discharge decision>",
"suggestedLevel": "99281|99282|99283|99284|99285",
"levelRationale": "<one sentence: which 2 of the 3 elements (problems, data, risk) drive this level>"
"levelRationale": "<one sentence — which 2 of the 3 elements drive this level, quoting the specific findings>"
}
}
LEVEL MAPPING (2 of 3 elements must meet):
- 99281: minimal problems, minimal/no data, minimal risk
- 99282: low problems, limited data, low risk
- 99283: moderate problems, limited data, moderate risk
- 99284: moderate-to-high problems, moderate data, moderate-to-high risk (typical ED visit with workup)
- 99285: high problems, extensive data, high risk (life-threat, critical decision, admission for high-acuity care)
CRITICAL RULES:
- Use ONLY information present in the note and transcript
- Use ONLY information present in the note (and transcript if provided)
- Do NOT invent data points, interventions, or diagnoses
- Be conservative when ambiguous pick the lower level
- The levelRationale must reference specific elements actually documented in the encounter`,
- levelRationale must reference specific elements actually documented in the encounter`,
// ======================== DON'T-MISS TOOLTIP (post-note) ========================
// Used after sick visit / encounter HPI generation. Hard cap of 5 points.