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:
parent
560ec43f8b
commit
503f5afaad
28 changed files with 9481 additions and 263 deletions
31
README.md
31
README.md
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
125
docs/logic/README.md
Normal 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
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
2166
docs/logic/architecture.md
Normal file
File diff suppressed because it is too large
Load diff
1281
docs/logic/auth-admin-learning.md
Normal file
1281
docs/logic/auth-admin-learning.md
Normal file
File diff suppressed because it is too large
Load diff
1373
docs/logic/bedside-and-calculators.md
Normal file
1373
docs/logic/bedside-and-calculators.md
Normal file
File diff suppressed because it is too large
Load diff
1546
docs/logic/clinical-notes.md
Normal file
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
821
docs/logic/ed-encounters.md
Normal 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` |
|
||||
|
|
@ -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
|
||||
24
public/components/admin-docs.html
Normal file
24
public/components/admin-docs.html
Normal 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>
|
||||
|
|
@ -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 & 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">
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
233
public/js/admin-docs.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
// 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');
|
||||
})();
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
124
src/routes/adminDocs.js
Normal 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;
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 99281–99285).
|
||||
${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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue