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)
|
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
|
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
|
## 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)
|
- [API Reference](docs/api-reference.md)
|
||||||
- [Database Schema](docs/database.md)
|
- [Database Schema](docs/database.md)
|
||||||
- [Authentication & Security](docs/authentication.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)
|
- [Learning Hub & CMS](docs/learning-hub.md)
|
||||||
- [Configuration Reference](docs/configuration.md)
|
- [Configuration Reference](docs/configuration.md)
|
||||||
- [Deployment Guide](docs/deployment.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
|
```bash
|
||||||
# ── Required ──────────────────────────────────────────────────
|
# ── 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
|
JWT_SECRET=change-this-to-a-random-64-char-string
|
||||||
|
|
||||||
# ── AI Provider (choose one or let it default to OpenRouter) ──
|
# ── 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"
|
### Browser Whisper stuck at "Initializing"
|
||||||
- **Cause:** Models not loaded or network blocked during initial download
|
- **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"
|
### Server transcription returns "No provider"
|
||||||
- **Cause:** API keys not configured
|
- **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>
|
<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 -->
|
<!-- Per-stage cards rendered here by ed-encounters.js renderStages().
|
||||||
<div id="ed-note-output" class="card output-card hidden">
|
Each card: editable note + embedded "don't miss" panel for that stage.
|
||||||
<div class="card-header output-header">
|
Stages persist on screen — physician can edit any stage; the latest-on-screen
|
||||||
<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>
|
version of each stage is what the finalize call sends to MDM. -->
|
||||||
<div class="output-actions">
|
<div id="ed-stages-container"></div>
|
||||||
<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>
|
<!-- Tail controls — always operate on the LATEST stage. Hidden until first generation. -->
|
||||||
<button class="btn-sm btn-ghost" data-action="speak" data-target="ed-note-text"><i class="fas fa-volume-high"></i> Read</button>
|
<div id="ed-tail-controls" class="card hidden" style="margin-top:10px;">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="ed-note-text" class="output-text" contenteditable="true"></div>
|
|
||||||
<div class="refine-bar">
|
<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>
|
<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</button>
|
<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>
|
<button class="btn-sm btn-ghost" id="ed-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
</div>
|
</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;">
|
<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-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>
|
<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>
|
||||||
</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) -->
|
<!-- MDM block (rendered after finalize) -->
|
||||||
<div id="ed-mdm-card" class="card hidden" style="margin-top:10px;border-left:3px solid var(--green);">
|
<div id="ed-mdm-card" class="card hidden" style="margin-top:10px;border-left:3px solid var(--green);">
|
||||||
<div class="card-header output-header">
|
<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 */
|
/* 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;}
|
.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>
|
<i class="fas fa-pen-to-square"></i>
|
||||||
<span>Content Manager</span>
|
<span>Content Manager</span>
|
||||||
</button>
|
</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>
|
<span class="sidebar-section-label">Account</span>
|
||||||
<button class="tab-btn" data-tab="settings">
|
<button class="tab-btn" data-tab="settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
|
|
@ -312,6 +316,7 @@
|
||||||
<section id="notes-tab" class="tab-content" data-component="notes"></section>
|
<section id="notes-tab" class="tab-content" data-component="notes"></section>
|
||||||
<section id="learning-tab" class="tab-content" data-component="learning"></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="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="admin-tab" class="tab-content" data-component="admin"></section>
|
||||||
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
|
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
|
||||||
<section id="sickvisit-tab" class="tab-content" data-component="sickvisit"></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/ed-encounters.js"></script>
|
||||||
<script defer src="/js/encounters.js"></script>
|
<script defer src="/js/encounters.js"></script>
|
||||||
<script defer src="/js/memories.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/documents.js"></script>
|
||||||
<script defer src="/js/calc-math.js"></script>
|
<script defer src="/js/calc-math.js"></script>
|
||||||
<script defer src="/js/drugs-loader.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');
|
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
|
// Show Content Manager tab for admin + moderator
|
||||||
var cmsTabBtn = document.getElementById('cms-tab-btn');
|
var cmsTabBtn = document.getElementById('cms-tab-btn');
|
||||||
if (cmsTabBtn) {
|
if (cmsTabBtn) {
|
||||||
|
|
@ -221,6 +228,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (mainApp) mainApp.style.display = 'none';
|
if (mainApp) mainApp.style.display = 'none';
|
||||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||||
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
||||||
|
var docsTabBtn = document.getElementById('docs-tab-btn');
|
||||||
|
if (docsTabBtn) docsTabBtn.classList.add('hidden');
|
||||||
showLoginForm();
|
showLoginForm();
|
||||||
// Clear fields after browser autofill has had a chance to run
|
// Clear fields after browser autofill has had a chance to run
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,39 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ED ENCOUNTER TAB — multi-stage emergency note with don't-miss
|
// ED ENCOUNTER TAB — multi-stage emergency note with per-stage
|
||||||
// tooltips and 2023 E/M MDM finalize.
|
// don't-miss tooltips and 2023 E/M MDM finalize.
|
||||||
//
|
//
|
||||||
// State model:
|
// State model:
|
||||||
// _state = {
|
// _state = {
|
||||||
// stage: 1|2|..., // current stage number
|
// stage: 1|2|..., // current stage number (what the recorder is for)
|
||||||
// stages: [ // per-stage history
|
// stages: [ // per-stage history — one entry per generated stage
|
||||||
// { transcript, note, dontMiss, model, generatedAt }
|
// { transcript, note, dontMiss, model, generatedAt }
|
||||||
// ],
|
// ],
|
||||||
// finalized: false,
|
// finalized: false,
|
||||||
|
// finalNote: null, // consolidated final note from /finalize
|
||||||
// mdm: null
|
// mdm: null
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// Persistence: localStorage on every mutation (debounced) + auto-save
|
// UX rules:
|
||||||
// to saved_encounters DB row (status='draft') on stage transitions.
|
// - Every generated stage stays on screen as its own editable card with
|
||||||
// "Save & Done" generates MDM, marks status='final', clears localStorage.
|
// 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
|
// Persistence: localStorage on every input (debounced) + auto-save
|
||||||
// a 2-string touch only (sessionStorage array + tabMap).
|
// draft row to saved_encounters on stage transitions.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var LS_KEY = 'ped_ed_draft_v1';
|
var LS_KEY = 'ped_ed_draft_v1';
|
||||||
var AUTOSAVE_MS = 1500;
|
|
||||||
|
|
||||||
var _state = freshState();
|
var _state = freshState();
|
||||||
var _saveTimer = null;
|
var _saveTimer = null;
|
||||||
|
|
@ -36,58 +45,7 @@
|
||||||
var _liveTranscript = '';
|
var _liveTranscript = '';
|
||||||
|
|
||||||
function freshState() {
|
function freshState() {
|
||||||
return { stage: 1, stages: [], finalized: false, mdm: null };
|
return { stage: 1, stages: [], finalized: false, finalNote: null, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DOM helpers ──────────────────────────────────────────────────────
|
// ── DOM helpers ──────────────────────────────────────────────────────
|
||||||
|
|
@ -101,28 +59,202 @@
|
||||||
function setText(id, v) {
|
function setText(id, v) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (typeof setOutputText === 'function' && id === 'ed-note-text') setOutputText(el, v);
|
el.textContent = v;
|
||||||
else el.textContent = v;
|
|
||||||
}
|
}
|
||||||
function showEl(id) { var el = document.getElementById(id); if (el) el.classList.remove('hidden'); }
|
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 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 escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
|
||||||
function updateStageTags() {
|
function stageTextElId(idx) { return 'ed-stage-text-' + idx; }
|
||||||
var s = _state.stage;
|
|
||||||
|
// 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 badge = document.getElementById('ed-stage-badge');
|
||||||
var rec = document.getElementById('ed-rec-stage-num');
|
var rec = document.getElementById('ed-rec-stage-num');
|
||||||
var gen = document.getElementById('ed-gen-stage-num');
|
var gen = document.getElementById('ed-gen-stage-num');
|
||||||
var tag = document.getElementById('ed-note-stage-tag');
|
if (rec) rec.textContent = String(_state.stage);
|
||||||
if (badge) badge.textContent = _state.finalized ? 'Finalized' : 'Stage ' + s;
|
if (gen) gen.textContent = String(_state.stage);
|
||||||
if (rec) rec.textContent = String(s);
|
if (!badge) return;
|
||||||
if (gen) gen.textContent = String(s);
|
if (_state.finalized) {
|
||||||
if (tag) tag.textContent = 'Stage ' + s;
|
badge.textContent = 'Finalized';
|
||||||
}
|
badge.style.background = '#d1fae5';
|
||||||
|
badge.style.color = '#047857';
|
||||||
function showOutput() {
|
return;
|
||||||
var card = document.getElementById('ed-note-output');
|
}
|
||||||
if (card) { card.classList.remove('hidden'); card.scrollIntoView({ behavior: 'smooth' }); }
|
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 ────────────────────────────────────────────────────────
|
// ── Recording ────────────────────────────────────────────────────────
|
||||||
|
|
@ -223,11 +355,16 @@
|
||||||
|
|
||||||
// ── Generate (per-stage) ─────────────────────────────────────────────
|
// ── Generate (per-stage) ─────────────────────────────────────────────
|
||||||
function generateStage() {
|
function generateStage() {
|
||||||
|
if (_state.finalized) { showToast('Encounter is finalized — start a new one', 'error'); return; }
|
||||||
var cc = getVal('ed-cc');
|
var cc = getVal('ed-cc');
|
||||||
if (!cc.trim()) { showToast('Enter a chief complaint first', 'error'); return; }
|
if (!cc.trim()) { showToast('Enter a chief complaint first', 'error'); return; }
|
||||||
var transcript = getText('ed-transcript');
|
var transcript = getText('ed-transcript');
|
||||||
if (!transcript) { showToast('No transcript — record or type the dictation first', 'error'); return; }
|
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 modelEl = document.getElementById('ed-model-select');
|
||||||
var selectedModel = modelEl ? modelEl.value : '';
|
var selectedModel = modelEl ? modelEl.value : '';
|
||||||
|
|
||||||
|
|
@ -236,7 +373,7 @@
|
||||||
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||||
|
|
||||||
memoriesPromise.then(function(memCtx) {
|
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', {
|
return fetch('/api/ed-encounters/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
|
|
@ -257,108 +394,96 @@
|
||||||
hideBusy();
|
hideBusy();
|
||||||
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
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,
|
transcript: transcript,
|
||||||
note: data.note || '',
|
note: data.note || '',
|
||||||
dontMiss: data.dontMiss || [],
|
dontMiss: data.dontMiss || [],
|
||||||
model: data.model || '',
|
model: data.model || '',
|
||||||
generatedAt: new Date().toISOString()
|
generatedAt: new Date().toISOString()
|
||||||
});
|
};
|
||||||
|
|
||||||
setText('ed-note-text', data.note || '');
|
renderStages();
|
||||||
var modelTag = document.getElementById('ed-note-model-tag');
|
updateBadge();
|
||||||
if (modelTag) modelTag.textContent = (data.model || '').split('/').pop();
|
|
||||||
renderDontMiss(data.dontMiss || []);
|
|
||||||
updateStageTags();
|
|
||||||
showOutput();
|
|
||||||
persistLocal();
|
persistLocal();
|
||||||
autoSaveDraft(); // best-effort DB draft save on stage completion
|
autoSaveDraft();
|
||||||
showToast('Stage ' + _state.stage + ' generated. Save & Done, or Add more.', 'success');
|
// 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'); });
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Don't-miss rendering ─────────────────────────────────────────────
|
// ── Add another stage — DOES NOT change displayed cards ─────────────
|
||||||
function renderDontMiss(items) {
|
function advanceStage() {
|
||||||
var card = document.getElementById('ed-dontmiss-card');
|
if (_state.finalized) { showToast('Encounter is finalized', 'error'); return; }
|
||||||
var list = document.getElementById('ed-dontmiss-list');
|
if (_state.stages.length < _state.stage) {
|
||||||
if (!card || !list) return;
|
showToast('Generate the current stage before advancing', 'error');
|
||||||
if (!items || !items.length) {
|
|
||||||
card.classList.add('hidden');
|
|
||||||
list.innerHTML = '';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = items.map(function(it) {
|
gatherCurrentNotes();
|
||||||
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;
|
|
||||||
}
|
|
||||||
_state.stage += 1;
|
_state.stage += 1;
|
||||||
setText('ed-transcript', '');
|
setText('ed-transcript', '');
|
||||||
_liveTranscript = '';
|
_liveTranscript = '';
|
||||||
updateStageTags();
|
updateBadge();
|
||||||
persistLocal();
|
persistLocal();
|
||||||
showToast('Ready for Stage ' + _state.stage + ' — record or type additional findings.', 'info');
|
showToast('Ready for Stage ' + _state.stage + ' — record or type additional findings.', 'info');
|
||||||
var rec = document.getElementById('ed-record-btn');
|
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() {
|
function finalize() {
|
||||||
var noteEl = document.getElementById('ed-note-text');
|
if (_state.finalized) { showToast('Already finalized', 'info'); return; }
|
||||||
var note = noteEl ? (noteEl.innerText || noteEl.textContent || '').trim() : '';
|
if (_state.stages.length === 0) { showToast('Generate at least one stage first', 'error'); return; }
|
||||||
if (!note) { showToast('Generate a note first', 'error'); return; }
|
|
||||||
var label = getVal('ed-label');
|
var label = getVal('ed-label');
|
||||||
if (!label.trim()) { showToast('Enter a patient label before finalizing', 'error'); return; }
|
if (!label.trim()) { showToast('Enter a patient label before finalizing', 'error'); return; }
|
||||||
|
|
||||||
// Lock in physician edits
|
gatherCurrentNotes();
|
||||||
if (_state.stages.length) _state.stages[_state.stages.length - 1].note = note;
|
|
||||||
|
|
||||||
var modelEl = document.getElementById('ed-model-select');
|
var modelEl = document.getElementById('ed-model-select');
|
||||||
var selectedModel = modelEl ? modelEl.value : '';
|
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', {
|
fetch('/api/ed-encounters/finalize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
note: note,
|
stages: _state.stages.map(function(s) {
|
||||||
transcript: fullTranscript,
|
return { transcript: s.transcript || '', note: s.note || '' };
|
||||||
|
}),
|
||||||
|
chiefComplaint: getVal('ed-cc'),
|
||||||
patientAge: getVal('ed-age'),
|
patientAge: getVal('ed-age'),
|
||||||
|
patientGender: getVal('ed-gender'),
|
||||||
model: selectedModel || undefined
|
model: selectedModel || undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.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.mdm = data.mdm;
|
||||||
_state.finalized = true;
|
_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') {
|
if (typeof saveEncounter === 'function') {
|
||||||
saveEncounter({
|
saveEncounter({
|
||||||
id: window._savedEncId_ed || null,
|
id: window._savedEncId_ed || null,
|
||||||
label: label,
|
label: label,
|
||||||
enc_type: 'ed',
|
enc_type: 'ed',
|
||||||
transcript: fullTranscript,
|
transcript: fullTranscript,
|
||||||
generated_note: composeFinalNote(note, data.mdm),
|
generated_note: composeFinalNoteForSave(_state.finalNote, _state.mdm),
|
||||||
partial_data: partial,
|
partial_data: partial,
|
||||||
status: 'final',
|
status: 'final',
|
||||||
idempotency_key: 'ed-final-' + Date.now(),
|
idempotency_key: 'ed-final-' + Date.now(),
|
||||||
|
|
@ -367,7 +492,6 @@
|
||||||
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
try { sessionStorage.setItem('_savedEncId_ed', id); } catch(e) {}
|
||||||
clearLocal();
|
clearLocal();
|
||||||
hideBusy();
|
hideBusy();
|
||||||
updateStageTags();
|
|
||||||
showToast('Encounter finalized and saved.', 'success');
|
showToast('Encounter finalized and saved.', 'success');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -378,9 +502,9 @@
|
||||||
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function composeFinalNote(noteText, mdm) {
|
function composeFinalNoteForSave(noteText, mdm) {
|
||||||
if (!mdm) return noteText;
|
if (!mdm) return noteText;
|
||||||
var lines = [
|
return [
|
||||||
noteText,
|
noteText,
|
||||||
'',
|
'',
|
||||||
'Medical Decision Making (2023 E/M):',
|
'Medical Decision Making (2023 E/M):',
|
||||||
|
|
@ -388,41 +512,24 @@
|
||||||
'Data Reviewed (' + (mdm.dataReviewed || '') + '): ' + (mdm.dataNarrative || ''),
|
'Data Reviewed (' + (mdm.dataReviewed || '') + '): ' + (mdm.dataNarrative || ''),
|
||||||
'Risk (' + (mdm.risk || '') + '): ' + (mdm.riskNarrative || ''),
|
'Risk (' + (mdm.risk || '') + '): ' + (mdm.riskNarrative || ''),
|
||||||
'Suggested Level: ' + (mdm.suggestedLevel || '') + ' — ' + (mdm.levelRationale || '')
|
'Suggested Level: ' + (mdm.suggestedLevel || '') + ' — ' + (mdm.levelRationale || '')
|
||||||
];
|
].join('\n');
|
||||||
return lines.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMdm(mdm) {
|
// ── Save draft (manual / auto on stage transition) ──────────────────
|
||||||
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) ──────────────────────
|
|
||||||
function autoSaveDraft() {
|
function autoSaveDraft() {
|
||||||
var label = getVal('ed-label');
|
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;
|
if (typeof saveEncounter !== 'function') return;
|
||||||
|
gatherCurrentNotes();
|
||||||
var fullTranscript = _state.stages.map(function(s) { return s.transcript || ''; }).join('\n\n');
|
var fullTranscript = _state.stages.map(function(s) { return s.transcript || ''; }).join('\n\n');
|
||||||
var partial = { stages: _state.stages, finalized: false };
|
var partial = { stages: _state.stages, finalized: false };
|
||||||
|
var lastNote = _state.stages.length ? _state.stages[_state.stages.length - 1].note : '';
|
||||||
saveEncounter({
|
saveEncounter({
|
||||||
id: window._savedEncId_ed || null,
|
id: window._savedEncId_ed || null,
|
||||||
label: label,
|
label: label,
|
||||||
enc_type: 'ed',
|
enc_type: 'ed',
|
||||||
transcript: fullTranscript,
|
transcript: fullTranscript,
|
||||||
generated_note: lastStage() ? lastStage().note : '',
|
generated_note: lastNote,
|
||||||
partial_data: partial,
|
partial_data: partial,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
idempotency_key: 'ed-draft-' + (window._savedEncId_ed || 'new'),
|
idempotency_key: 'ed-draft-' + (window._savedEncId_ed || 'new'),
|
||||||
|
|
@ -447,20 +554,37 @@
|
||||||
setVal('ed-gender', '');
|
setVal('ed-gender', '');
|
||||||
setVal('ed-cc', '');
|
setVal('ed-cc', '');
|
||||||
setText('ed-transcript', '');
|
setText('ed-transcript', '');
|
||||||
setText('ed-note-text', '');
|
|
||||||
_liveTranscript = '';
|
_liveTranscript = '';
|
||||||
hideEl('ed-note-output');
|
var container = document.getElementById('ed-stages-container');
|
||||||
hideEl('ed-dontmiss-card');
|
if (container) container.innerHTML = '';
|
||||||
|
var finalCard = document.getElementById('ed-final-note-card');
|
||||||
|
if (finalCard) finalCard.remove();
|
||||||
|
hideEl('ed-tail-controls');
|
||||||
hideEl('ed-mdm-card');
|
hideEl('ed-mdm-card');
|
||||||
var modelTag = document.getElementById('ed-note-model-tag');
|
|
||||||
if (modelTag) modelTag.textContent = '';
|
|
||||||
window._savedEncId_ed = null;
|
window._savedEncId_ed = null;
|
||||||
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
|
try { sessionStorage.removeItem('_savedEncId_ed'); } catch(e) {}
|
||||||
clearLocal();
|
clearLocal();
|
||||||
updateStageTags();
|
updateBadge();
|
||||||
showToast('ED encounter cleared for new patient', 'info');
|
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 ──────────────────────────────────────────────
|
// ── Wire click handlers ──────────────────────────────────────────────
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest('#btn-ed-generate')) generateStage();
|
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-finalize')) finalize();
|
||||||
if (e.target.closest('#btn-ed-new')) resetEncounter();
|
if (e.target.closest('#btn-ed-new')) resetEncounter();
|
||||||
if (e.target.closest('#btn-ed-save')) manualSaveDraft();
|
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-refine-btn')) refineLatestStage();
|
||||||
if (e.target.closest('#ed-shorten-btn')) shortenDocument('ed-note-text');
|
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) {
|
document.addEventListener('input', function(e) {
|
||||||
if (!e.target) return;
|
if (!e.target) return;
|
||||||
var id = e.target.id;
|
var t = e.target;
|
||||||
if (id === 'ed-label' || id === 'ed-age' || id === 'ed-gender' || id === 'ed-cc' || id === 'ed-transcript' || id === 'ed-note-text') {
|
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();
|
persistLocal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -484,7 +612,6 @@
|
||||||
// ── Load handler (resume from saved encounters list) ────────────────
|
// ── Load handler (resume from saved encounters list) ────────────────
|
||||||
if (typeof registerEncounterLoadHandler === 'function') {
|
if (typeof registerEncounterLoadHandler === 'function') {
|
||||||
registerEncounterLoadHandler('ed', function(enc) {
|
registerEncounterLoadHandler('ed', function(enc) {
|
||||||
// Reset and restore from the row
|
|
||||||
_state = freshState();
|
_state = freshState();
|
||||||
setVal('ed-label', enc.label || '');
|
setVal('ed-label', enc.label || '');
|
||||||
var partial = enc.partial_data;
|
var partial = enc.partial_data;
|
||||||
|
|
@ -493,20 +620,14 @@
|
||||||
_state.stages = partial.stages;
|
_state.stages = partial.stages;
|
||||||
_state.stage = partial.stages.length || 1;
|
_state.stage = partial.stages.length || 1;
|
||||||
_state.finalized = !!partial.finalized;
|
_state.finalized = !!partial.finalized;
|
||||||
|
_state.finalNote = partial.finalNote || null;
|
||||||
_state.mdm = partial.mdm || 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);
|
renderStages();
|
||||||
updateStageTags();
|
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;
|
_inited = true;
|
||||||
initRecording();
|
initRecording();
|
||||||
loadLocal();
|
loadLocal();
|
||||||
updateStageTags();
|
updateBadge();
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('ED encounters module loaded');
|
console.log('ED encounters module loaded');
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,15 @@
|
||||||
function render() {
|
function render() {
|
||||||
var listEl = document.getElementById('ext-list');
|
var listEl = document.getElementById('ext-list');
|
||||||
if (_items.length === 0) {
|
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();
|
var q = document.getElementById('ext-search').value.trim();
|
||||||
if (q) msg = 'No matches for "' + esc(q) + '".';
|
if (q) { icon = 'fa-magnifying-glass'; msg = 'No matches for "' + esc(q) + '"'; }
|
||||||
listEl.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">' + msg + '</p>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +114,7 @@
|
||||||
html += '<div style="margin-left:4px;">';
|
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="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;">';
|
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>';
|
html += '</div></div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,37 +134,83 @@
|
||||||
if (action === 'purge') return confirmPurge(id);
|
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 typeColor = x.type === 'pager' ? '#7c3aed' : '#2563eb';
|
||||||
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
|
var typeLabel = x.type === 'pager' ? 'Pager' : 'Ext';
|
||||||
|
var typeIcon = x.type === 'pager' ? 'fa-pager' : 'fa-phone';
|
||||||
var trashed = x.trashed_at != null;
|
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)';
|
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;
|
var actions;
|
||||||
if (trashed) {
|
if (trashed) {
|
||||||
actions =
|
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 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" 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="purge" data-ext-id="' + x.id + '" title="Delete permanently" style="color:var(--red);"><i class="fas fa-xmark"></i></button>';
|
||||||
} else {
|
} else {
|
||||||
actions =
|
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 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" 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="delete" data-ext-id="' + x.id + '" title="Move to trash"><i class="fas fa-trash-can"></i></button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
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="flex:1;min-width:0;">';
|
||||||
html += ' <div style="display:flex;align-items:baseline;gap:8px;">';
|
html += ' <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0;">';
|
||||||
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 += ' <i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:14px;align-self:center;flex-shrink:0;"></i>';
|
||||||
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 += ' <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>';
|
||||||
html += ' <div style="font-size:13px;color:var(--g700);margin-top:2px;">' + esc(x.name) + '</div>';
|
// Name: scan-target. Larger, bolder, darker, slightly tighter
|
||||||
if (x.notes) html += ' <div style="font-size:11px;color:var(--g500);margin-top:2px;">' + esc(x.notes) + '</div>';
|
// 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>';
|
||||||
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>';
|
html += '</div>';
|
||||||
return html;
|
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/admin'));
|
||||||
app.use('/api/admin', require('./src/routes/adminConfig'));
|
app.use('/api/admin', require('./src/routes/adminConfig'));
|
||||||
app.use('/api/admin', require('./src/routes/adminMilestones'));
|
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/*
|
// Public endpoints — must come BEFORE any router that applies authMiddleware to /api/*
|
||||||
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
|
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:
|
// Body:
|
||||||
// note — final clinical note (required)
|
// stages — array of { transcript, note } in chronological order (required)
|
||||||
// transcript — concatenated transcript across all stages (optional but recommended)
|
// chiefComplaint — string, optional but strongly preferred
|
||||||
// patientAge — string, optional
|
// patientAge — string, optional
|
||||||
|
// patientGender — string, optional
|
||||||
// model — optional override
|
// model — optional override
|
||||||
// Returns: { success, mdm: {...}, model }
|
// Returns: { success, finalNote, mdm, model }
|
||||||
router.post('/ed-encounters/finalize', async function (req, res) {
|
router.post('/ed-encounters/finalize', async function (req, res) {
|
||||||
var start = Date.now();
|
var start = Date.now();
|
||||||
try {
|
try {
|
||||||
var { note, transcript, patientAge, model } = req.body;
|
var { stages, chiefComplaint, patientAge, patientGender, model } = req.body;
|
||||||
|
|
||||||
if (!note || !note.trim()) {
|
if (!Array.isArray(stages) || stages.length === 0) {
|
||||||
return res.status(400).json({ error: 'Final note is required' });
|
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';
|
// ── Step 1: consolidate ──────────────────────────────────────────
|
||||||
context += 'FINAL CLINICAL NOTE:\n' + wrapUserText('note', note) + '\n\n';
|
var consolidateContext = 'PATIENT: ' + (patientAge || 'Unknown age')
|
||||||
if (transcript && transcript.trim()) {
|
+ (patientGender ? ', ' + patientGender : '') + '\n';
|
||||||
context += 'FULL ENCOUNTER TRANSCRIPT (all stages):\n' + wrapUserText('transcript', transcript) + '\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: 'system', content: PROMPTS.edFinalize + INJECTION_GUARD },
|
||||||
{ role: 'user', content: context }
|
{ role: 'user', content: mdmContext }
|
||||||
], { model: model, maxTokens: 2000 });
|
], { model: model, maxTokens: 2000 });
|
||||||
|
|
||||||
var parsed = extractJson(result.content);
|
var parsed = extractJson(mdmResult.content);
|
||||||
if (!parsed || !parsed.mdm) {
|
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 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', {
|
logger.apiCall(req.user.id, '/api/ed-encounters/finalize', {
|
||||||
model: result.model,
|
model: mdmResult.model,
|
||||||
tokensInput: result.usage && result.usage.input_tokens,
|
tokensInput: inTokens,
|
||||||
tokensOutput: result.usage && result.usage.output_tokens,
|
tokensOutput: outTokens,
|
||||||
duration: dur,
|
duration: dur,
|
||||||
statusCode: 200
|
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) {
|
} catch (e) {
|
||||||
logger.error('[edEncounters] finalize failed', e.message);
|
logger.error('[edEncounters] finalize failed', e.message);
|
||||||
res.status(500).json({ error: 'Request failed' });
|
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.
|
- 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.`,
|
- 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 ========================
|
// ======================== 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).
|
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}
|
${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:
|
OUTPUT FORMAT — STRICT JSON ONLY, no preamble, no code fences, no commentary:
|
||||||
{
|
{
|
||||||
"mdm": {
|
"mdm": {
|
||||||
"problemsAddressed": "minimal|low|moderate|high",
|
"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",
|
"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",
|
"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",
|
"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:
|
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
|
- Do NOT invent data points, interventions, or diagnoses
|
||||||
- Be conservative when ambiguous — pick the lower level
|
- 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) ========================
|
// ======================== DON'T-MISS TOOLTIP (post-note) ========================
|
||||||
// Used after sick visit / encounter HPI generation. Hard cap of 5 points.
|
// Used after sick visit / encounter HPI generation. Hard cap of 5 points.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue