feat: add extension import preview
This commit is contained in:
parent
b7f9da6600
commit
1cbe248450
17 changed files with 557 additions and 179 deletions
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: CI
|
||||
|
||||
# Runs root app tests on every PR and push to main.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Cancel superseded runs on the same ref to save minutes.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Root app tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
30
.github/workflows/security.yml
vendored
Normal file
30
.github/workflows/security.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: Security audit
|
||||
|
||||
# Weekly npm audit at high+ severity for the root app. Reports to the job summary; does NOT fail the build
|
||||
# (advisories appear constantly and a red checkmark train would just get
|
||||
# muted). Re-run on demand via workflow_dispatch.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: npm audit (high+)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Audit root app
|
||||
run: |
|
||||
echo '## Root app advisories' >> "$GITHUB_STEP_SUMMARY"
|
||||
npm audit --audit-level=high --json > legacy-audit.json || true
|
||||
node -e "const a=require('./legacy-audit.json');const m=a.metadata?.vulnerabilities||{};console.log('high:'+(m.high||0)+' critical:'+(m.critical||0));" >> "$GITHUB_STEP_SUMMARY"
|
||||
continue-on-error: true
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -41,7 +41,3 @@ e2e/playwright-report/
|
|||
.firecrawl/
|
||||
|
||||
# Refactored test stack stays local for now
|
||||
.env.refactored
|
||||
.env.refactored.example
|
||||
docker-compose.refactored.yml
|
||||
refactored-ped-ai/
|
||||
|
|
|
|||
|
|
@ -1231,25 +1231,69 @@ Export active entries as `pedscribe-extensions.zip`. The ZIP contains `pedscribe
|
|||
|
||||
### POST /api/extensions/import
|
||||
|
||||
Import entries from a JSON request body. Exact duplicates already owned by the user are skipped.
|
||||
Import entries from a JSON request body. Exact active duplicates are skipped. Exact trashed matches can be restored when requested. Possible duplicates are identified by loose matching (`location + number` or `number + type`) and skipped unless explicitly imported.
|
||||
|
||||
- **Auth required:** Yes
|
||||
- **Request body:** Export payload object or an array of entry objects.
|
||||
- **Request body:** Export payload object, an array of entry objects, or an object with `items` and `options`.
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"location": "ED",
|
||||
"name": "Charge Nurse",
|
||||
"number": "1234",
|
||||
"type": "extension",
|
||||
"notes": "Optional note"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"restoreTrashed": true,
|
||||
"importPossibleDuplicates": false
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"imported": 2,
|
||||
"restored": 1,
|
||||
"skipped": 1,
|
||||
"possibleSkipped": 1,
|
||||
"total": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/extensions/import/preview
|
||||
|
||||
Preview a JSON import before committing it. The preview categorizes entries as new, exact active duplicates, exact trashed matches, or possible duplicates.
|
||||
|
||||
- **Auth required:** Yes
|
||||
- **Request body:** Same JSON shapes accepted by `POST /api/extensions/import`.
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"preview": {
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"new": 1,
|
||||
"exactActive": 1,
|
||||
"exactTrashed": 0,
|
||||
"possible": 1
|
||||
},
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/extensions/import-file
|
||||
|
||||
Import entries from a multipart file upload. Accepts a PedScribe JSON export or a ZIP export containing JSON.
|
||||
Import entries from a multipart file upload. Accepts a PedScribe JSON export or a ZIP export containing JSON. Form fields `restoreTrashed=true` and `importPossibleDuplicates=true` control the same duplicate handling options as JSON import.
|
||||
|
||||
- **Auth required:** Yes
|
||||
- **Request body:** `multipart/form-data` with field `file`.
|
||||
|
|
@ -1258,6 +1302,16 @@ Import entries from a multipart file upload. Accepts a PedScribe JSON export or
|
|||
|
||||
---
|
||||
|
||||
### POST /api/extensions/import-file/preview
|
||||
|
||||
Preview a multipart JSON or ZIP import before committing it.
|
||||
|
||||
- **Auth required:** Yes
|
||||
- **Request body:** `multipart/form-data` with field `file`.
|
||||
- **Response:** Same preview shape as `POST /api/extensions/import/preview`.
|
||||
|
||||
---
|
||||
|
||||
## Nextcloud Integration
|
||||
|
||||
### POST /api/nextcloud/connect
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ Helmet defaults plus:
|
|||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
||||
- Content-Security-Policy:
|
||||
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
|
||||
(`unsafe-eval` is required by @xenova/transformers for in-browser Whisper)
|
||||
(do not add `unsafe-eval` unless a reviewed dependency requires it)
|
||||
- `script-src-attr 'none'` (blocks inline event handlers)
|
||||
- `frame-src 'self' challenges.cloudflare.com`
|
||||
- `object-src 'none'`
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ Maximum 20 corrections retained per category (oldest deleted).
|
|||
| `encounters.js` | Save / load / resume with optimistic lock |
|
||||
| `memories.js` | Physician templates + corrections UI |
|
||||
| `correctionTracker.js` | Captures AI-output edits |
|
||||
| `browserWhisper.js` | In-browser WASM Whisper |
|
||||
| `speechRecognition.js` | Explicit opt-in browser Web Speech support |
|
||||
| `speechRecognition.js` | Web Speech API preview |
|
||||
| `voicePreferences.js` | Per-user STT/TTS override |
|
||||
| `audioBackup.js` | Server + IndexedDB backup retries |
|
||||
|
|
|
|||
|
|
@ -39,11 +39,10 @@ For someone brand new to the codebase:
|
|||
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
|
||||
5. **[ai-and-voice.md](ai-and-voice.md)** — AI provider 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
|
||||
routing, server-side STT, TTS, and the AudioRecorder. Voice/STT plumbing is **sacred** — the
|
||||
doc describes it without proposing changes.
|
||||
|
||||
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
|
||||
|
|
@ -82,19 +81,11 @@ 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. |
|
||||
| Voice/STT plumbing (`audioBackup.js`, `speechRecognition.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:
|
||||
|
|
@ -108,7 +99,7 @@ A few topics span multiple docs. Use these as your jump-off points:
|
|||
| `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 |
|
||||
| AI provider 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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -510,94 +510,9 @@ say, `gemini-2.5-flash` even if the deployment default is
|
|||
|
||||
---
|
||||
|
||||
## 7. Browser-side Whisper (fully offline)
|
||||
## 7. Browser-side Whisper
|
||||
|
||||
Source: `public/js/browserWhisper.js` + `public/js/whisperWorker.js` (and
|
||||
the alternative loader `whisperWorkerV2.js`).
|
||||
|
||||
### 7.1 Why it exists
|
||||
|
||||
Three reasons stack on top of each other:
|
||||
|
||||
1. **Privacy.** Audio never leaves the device. Genuine HIPAA-safe regardless
|
||||
of which AI backend is configured for text generation.
|
||||
2. **Zero CDN dependency.** The Dockerfile bundles
|
||||
`transformers.min.js` and the model weights at image-build time. After
|
||||
the page loads once, transcription works with zero network — even on an
|
||||
air-gapped LAN.
|
||||
3. **No vendor lock-in.** No AWS/GCP/Azure account needed for STT.
|
||||
|
||||
### 7.2 The bundle layout
|
||||
|
||||
(From `whisperWorker.js` lines 9–18)
|
||||
|
||||
```js
|
||||
importScripts('/models/transformers.min.js');
|
||||
env.allowLocalModels = false;
|
||||
env.useBrowserCache = true;
|
||||
env.allowRemoteModels = false; // no CDN fetch
|
||||
env.localModelPath = '/models/';
|
||||
```
|
||||
|
||||
The Dockerfile pulls `@xenova/transformers` and the
|
||||
`Xenova/whisper-tiny.en` (and optionally `whisper-base.en` /
|
||||
`whisper-small.en`) model directories into `/app/public/models/` at image
|
||||
build. Setting `allowRemoteModels = false` is what makes this airgap-safe
|
||||
— transformers.js can only load from `/models/`.
|
||||
|
||||
### 7.3 The orchestrator
|
||||
|
||||
`browserWhisper.js` exposes `window.BrowserWhisper`:
|
||||
|
||||
- `isSupported()` — checks for Worker, AudioContext, WebAssembly.
|
||||
- `isEnabled()` / `setEnabled(val)` — localStorage toggle
|
||||
(`ped_browser_whisper`).
|
||||
- `getModel()` / `setModel(m)` — model id (default
|
||||
`Xenova/whisper-tiny.en`).
|
||||
- `preload(onProgress)` — pre-warm in background. Called from the settings
|
||||
page when the user toggles Browser Whisper on, so the first recording
|
||||
doesn't pay the load cost.
|
||||
- `transcribe(blob)` — the main entry.
|
||||
|
||||
`transcribe(blob)` flow (lines 58–81):
|
||||
|
||||
1. `_blobToFloat32(blob)` decodes the WebM/Opus blob into a Float32Array
|
||||
at 16 kHz mono using `AudioContext` (lines 142–153). 16 kHz mono is
|
||||
what Whisper expects.
|
||||
2. If the worker is already loaded with the same model, post the
|
||||
Float32Array directly (zero-copy via `transferable`).
|
||||
3. Otherwise initialise the worker, then post.
|
||||
|
||||
The worker (`whisperWorker.js`):
|
||||
|
||||
- Listens for `{type: 'load' | 'transcribe'}` messages.
|
||||
- `load(modelName)` invokes `pipeline('automatic-speech-recognition',
|
||||
modelName, { progress_callback })` which is what triggers the model
|
||||
load from `/models/` (or hits the IndexedDB cache after first run).
|
||||
- `transcribe` calls the pipeline with `{ language: 'english', task:
|
||||
'transcribe', chunk_length_s: 30, stride_length_s: 5, return_timestamps:
|
||||
false }`.
|
||||
- Posts back `{type: 'progress'}` events during model download,
|
||||
`{type: 'ready'}` when loaded, `{type: 'result', text}` when done,
|
||||
`{type: 'error', message}` on failure.
|
||||
|
||||
Notable: `whisperWorkerV2.js` is an alternative loader that tries
|
||||
`importScripts(CDN_URL)` first and falls back to telling the main thread
|
||||
to use server transcription. It exists for the case where the bundled
|
||||
worker can't be loaded (CSP or weird network), but the production path is
|
||||
the v1 worker that imports from `/models/`.
|
||||
|
||||
### 7.4 Failure handling
|
||||
|
||||
If the worker fires an `error`, `browserWhisper.js` (lines 114–124):
|
||||
- Logs the message.
|
||||
- Toasts the user: *"Browser Whisper blocked by network/firewall. Using
|
||||
server transcription."*
|
||||
- Rejects the pending promise, which `transcribeAudio` (in `app.js`)
|
||||
catches and falls back to `_serverTranscribe(blob)`.
|
||||
|
||||
This is the silent-fallback chain that keeps recording usable when one
|
||||
layer breaks.
|
||||
Browser-side Whisper has been removed from the runtime. Do not re-add public Whisper workers, Transformers.js browser STT, browser-local model downloads, or settings that imply offline browser Whisper support. Current transcription should route through configured server-side providers, with browser Web Speech available only as an explicit opt-in preview where supported.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -729,8 +644,6 @@ is gone — and the physician has to re-dictate. The backup feature gives
|
|||
paths only**:
|
||||
- `data.success === false` from the server (line 777).
|
||||
- `fetch` rejection / network error (line 786).
|
||||
- Browser Whisper failure before falling back to server (line 752 —
|
||||
label `'browser-whisper-failed'`).
|
||||
|
||||
So the database stores audio only when something went wrong, not on every
|
||||
recording.
|
||||
|
|
@ -1047,25 +960,19 @@ Per `MEMORY.md` rules `feedback_voice_stt_sacred` and the cross-cutting
|
|||
|
||||
- `public/js/audioBackup.js` — server-first, IndexedDB-fallback save
|
||||
path. Touch only if a specific bug is reported.
|
||||
- `public/js/browserWhisper.js` + `whisperWorker.js` (and v2) — the
|
||||
in-browser WASM Whisper bundle. The `transformers.js` env config and
|
||||
the `chunk_length_s/stride_length_s` are tuned; don't change them
|
||||
speculatively.
|
||||
- `public/js/speechRecognition.js` — the live-streaming preview wrapper
|
||||
(and `app.js`'s `createSpeechRecognition`). Both exist for a reason
|
||||
(one is a real opt-in transcript path, one is just a visual preview);
|
||||
unifying them would break tabs.
|
||||
- `public/js/voicePreferences.js` — STT model + TTS voice picker UI.
|
||||
- `public/js/transcriptionSettings.js` — Browser Whisper + Web Speech
|
||||
toggles. Mutual exclusion (turning Web Speech on disables Browser
|
||||
Whisper) is intentional and survives in this file only.
|
||||
- `public/js/transcriptionSettings.js` — explicit Web Speech setting and
|
||||
transcription preference UI.
|
||||
- `public/js/app.js` — the `AudioRecorder` class (lines 659–683) and the
|
||||
`transcribeAudio` / `_serverTranscribe` chain (lines 739–795).
|
||||
|
||||
Specifically: do **not** propose:
|
||||
- Refactoring `transcribeAudio` to use a "TranscriptionRouter" class.
|
||||
- Splitting `AudioRecorder` into separate start/stop modules.
|
||||
- Replacing the v1/v2 worker pair with a single worker.
|
||||
- Changing the 32 kbps Opus / 1-second slice / mono / 16 kHz config.
|
||||
- Centralising the audio-backup save logic with the upload logic.
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ the same Postgres schema with row-level `user_id` scoping.
|
|||
| Auth | JWT (HS256) signed locally + DB-backed `user_sessions` table |
|
||||
| Secrets | argon2id (current), bcrypt (legacy / rehash on next login) |
|
||||
| AI | Provider-agnostic via `src/utils/ai.js` → OpenAI SDK shape |
|
||||
| STT | OpenAI Whisper API / Google Gemini / AWS Transcribe / local whisper.cpp / browser Whisper (transformers.js) |
|
||||
| STT | Server-side providers such as LiteLLM/OpenAI-compatible transcription, Google/Gemini, AWS Transcribe, or local server STT where configured |
|
||||
| TTS | Google Cloud TTS / ElevenLabs / LiteLLM-routed |
|
||||
| Frontend | **Vanilla JS, no framework, no bundler.** Plain `<script defer>` tags + IIFE modules + lazy HTML components |
|
||||
| Mobile | Capacitor 6 wrapper (Android shipping, iOS scaffolded) |
|
||||
|
|
@ -1543,10 +1543,6 @@ Notes:
|
|||
Final image stays slim (~220 MB).
|
||||
- **`npm install --omit=dev`** — devDependencies (jsdom, dompurify
|
||||
for tests) are not in the runtime image.
|
||||
- **Browser Whisper bundle.** Pulls the @xenova/transformers worker
|
||||
build + the whisper-tiny.en model files at image-build time so
|
||||
the running container has zero external dependencies for browser-
|
||||
side transcription. ~42 MB of model files baked in.
|
||||
- **Healthcheck.** Polls `/api/health` every 30 s. Container marked
|
||||
unhealthy after 5 failures.
|
||||
|
||||
|
|
@ -1750,9 +1746,7 @@ named first.
|
|||
|
||||
### Voice / STT plumbing
|
||||
|
||||
`public/js/audioBackup.js`, `public/js/browserWhisper.js`,
|
||||
`public/js/voiceDictation.js`, `public/js/liveEncounter.js`,
|
||||
`public/js/whisperWorker.js`, `public/js/whisperWorkerV2.js`,
|
||||
`public/js/audioBackup.js`, `public/js/voiceDictation.js`, `public/js/liveEncounter.js`,
|
||||
`public/js/transcriptionSettings.js`, `public/js/voicePreferences.js`,
|
||||
`public/js/speechRecognition.js`, plus the server-side
|
||||
`src/routes/transcribe.js` and `src/utils/transcribe*.js`.
|
||||
|
|
@ -1761,8 +1755,7 @@ Don't refactor recorder / transcribe plumbing. Fix only **named bugs**
|
|||
in the **smallest possible diff**. The recorder lifecycle has been
|
||||
tuned over many cycles to handle: tab backgrounding, OS audio
|
||||
interruption, MediaRecorder mime negotiation across Chrome/Firefox/
|
||||
Safari, browser Whisper fallback when network fails, server-side
|
||||
audio backup on transcription failure. Touching anything outside the
|
||||
Safari, server-side transcription, and audio backup on transcription failure. Touching anything outside the
|
||||
named bug is how the silent-cancel branch became always-taken in
|
||||
v6.53.0 (fixed in 66f319e).
|
||||
|
||||
|
|
@ -2079,7 +2072,7 @@ public/
|
|||
js/
|
||||
app.js, auth.js, authFetch.js, secureStorage.js, ui-state.js
|
||||
encounters.js, audioBackup.js, liveEncounter.js, voiceDictation.js,
|
||||
browserWhisper.js, whisperWorker*.js, speechRecognition.js,
|
||||
speechRecognition.js,
|
||||
transcriptionSettings.js, voicePreferences.js # ALL SACRED — STT
|
||||
calculators.js, calc-math.js, drugs-loader.js, peGuide.js # SACRED — formulas
|
||||
bedside/index.js # ES-MODULE POCKET (script type="module")
|
||||
|
|
@ -2089,7 +2082,6 @@ public/
|
|||
nextcloud, learningHub, admin
|
||||
pediatricScheduleData.js, milestonesData.js, e2e-bootstrap.js
|
||||
components/ # one HTML fragment per tab (~20 files)
|
||||
models/ # bundled transformers.js + Whisper-tiny.en
|
||||
|
||||
scripts/
|
||||
lint-references.js, maintenance.js, release.sh, e2e.sh,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,20 @@
|
|||
<button id="ext-trash-btn" class="btn-sm btn-ghost"><i class="fas fa-trash-can"></i> Trash <span id="ext-trash-count" style="color:var(--g500);font-size:11px;"></span></button>
|
||||
</div>
|
||||
|
||||
<div id="ext-import-preview" class="hidden" style="margin:0 16px 14px;padding:12px;border:1px solid var(--g200);border-radius:10px;background:var(--g50);">
|
||||
<div id="ext-import-preview-text" style="font-size:13px;color:var(--g700);line-height:1.5;margin-bottom:10px;"></div>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--g700);margin-bottom:6px;">
|
||||
<input type="checkbox" id="ext-import-restore-trashed"> Restore exact matches currently in trash
|
||||
</label>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--g700);margin-bottom:10px;">
|
||||
<input type="checkbox" id="ext-import-possible"> Import possible duplicates instead of skipping them
|
||||
</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button id="ext-import-confirm" class="btn-sm btn-primary"><i class="fas fa-file-import"></i> Import Selected</button>
|
||||
<button id="ext-import-cancel" class="btn-sm btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ext-form-wrap" class="hidden" style="border-top:1px solid var(--g100);padding:14px 16px;background:var(--g50);">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
|
||||
<div class="demo-field">
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@
|
|||
<option value="template_ed">ED Template</option>
|
||||
</select>
|
||||
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
||||
<a href="/template-guide.md" download="ped-ai-template-guide.md" class="btn-sm btn-ghost" style="text-decoration:none;"><i class="fas fa-download"></i> Template Guide</a>
|
||||
</div>
|
||||
<textarea id="mem-content" rows="5" style="width:100%;font-size:12px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL. Ears: TMs clear. Throat: clear..."></textarea>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
var _mode = 'active'; // 'active' | 'trash'
|
||||
var _editId = null; // null = creating, non-null = editing
|
||||
var _searchDebounce = null;
|
||||
var _pendingImportFile = null;
|
||||
|
||||
document.addEventListener('tabChanged', function (e) {
|
||||
if (e.detail.tab !== 'extensions' || _inited) return;
|
||||
|
|
@ -22,6 +23,8 @@
|
|||
document.getElementById('ext-import-file').click();
|
||||
});
|
||||
document.getElementById('ext-import-file').addEventListener('change', importItems);
|
||||
document.getElementById('ext-import-confirm').addEventListener('click', confirmImportPreview);
|
||||
document.getElementById('ext-import-cancel').addEventListener('click', clearImportPreview);
|
||||
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
|
||||
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
|
||||
|
||||
|
|
@ -357,24 +360,65 @@
|
|||
var input = e.target;
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var count = 'the selected';
|
||||
if (/\.json$/i.test(file.name)) {
|
||||
count = 'the JSON';
|
||||
} else if (/\.zip$/i.test(file.name)) {
|
||||
count = 'the ZIP';
|
||||
}
|
||||
showConfirm(
|
||||
'Import ' + count + ' pager/extension entries into your directory? Existing exact duplicates will be skipped.',
|
||||
function () { submitImportFile(file, input); },
|
||||
{ confirmText: 'Import' }
|
||||
);
|
||||
previewImportFile(file, input);
|
||||
}
|
||||
|
||||
function submitImportFile(file, input) {
|
||||
function previewImportFile(file, input) {
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
var headers = getAuthHeaders();
|
||||
delete headers['Content-Type'];
|
||||
fetch('/api/extensions/import-file/preview', { method: 'POST', headers: headers, body: form })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { showToast(d.error || 'Import preview failed', 'error'); input.value = ''; return; }
|
||||
_pendingImportFile = { file: file, input: input };
|
||||
renderImportPreview(d.preview && d.preview.summary);
|
||||
})
|
||||
.catch(function () { showToast('Import preview failed', 'error'); input.value = ''; });
|
||||
}
|
||||
|
||||
function renderImportPreview(summary) {
|
||||
summary = summary || {};
|
||||
var panel = document.getElementById('ext-import-preview');
|
||||
var text = document.getElementById('ext-import-preview-text');
|
||||
var restore = document.getElementById('ext-import-restore-trashed');
|
||||
var possible = document.getElementById('ext-import-possible');
|
||||
restore.checked = false;
|
||||
possible.checked = false;
|
||||
restore.disabled = !summary.exactTrashed;
|
||||
possible.disabled = !summary.possible;
|
||||
text.innerHTML =
|
||||
'<strong>Import preview:</strong> ' + esc(summary.total || 0) + ' valid entries found. ' +
|
||||
esc(summary.new || 0) + ' new, ' +
|
||||
esc(summary.exactActive || 0) + ' exact active duplicates, ' +
|
||||
esc(summary.exactTrashed || 0) + ' exact matches in trash, ' +
|
||||
esc(summary.possible || 0) + ' possible duplicates by location/number or number/type. ' +
|
||||
'Exact active duplicates are always skipped.';
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearImportPreview() {
|
||||
document.getElementById('ext-import-preview').classList.add('hidden');
|
||||
if (_pendingImportFile && _pendingImportFile.input) _pendingImportFile.input.value = '';
|
||||
_pendingImportFile = null;
|
||||
}
|
||||
|
||||
function confirmImportPreview() {
|
||||
if (!_pendingImportFile) return;
|
||||
submitImportFile(_pendingImportFile.file, _pendingImportFile.input, {
|
||||
restoreTrashed: document.getElementById('ext-import-restore-trashed').checked,
|
||||
importPossibleDuplicates: document.getElementById('ext-import-possible').checked
|
||||
});
|
||||
}
|
||||
|
||||
function submitImportFile(file, input, options) {
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('restoreTrashed', options && options.restoreTrashed ? 'true' : 'false');
|
||||
form.append('importPossibleDuplicates', options && options.importPossibleDuplicates ? 'true' : 'false');
|
||||
var headers = getAuthHeaders();
|
||||
delete headers['Content-Type'];
|
||||
fetch('/api/extensions/import-file', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
|
|
@ -383,7 +427,8 @@
|
|||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) { showToast(d.error || 'Import failed', 'error'); return; }
|
||||
showToast('Imported ' + d.imported + ' entries' + (d.skipped ? ', skipped ' + d.skipped + ' duplicates' : ''), 'success');
|
||||
showToast('Imported ' + d.imported + ' entries' + (d.restored ? ', restored ' + d.restored : '') + (d.skipped ? ', skipped ' + d.skipped : ''), 'success');
|
||||
clearImportPreview();
|
||||
load();
|
||||
loadTrashCount();
|
||||
})
|
||||
|
|
|
|||
130
public/template-guide.md
Normal file
130
public/template-guide.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Ped-AI Template Guide
|
||||
|
||||
Use this guide to write templates that the AI can follow reliably. Save each template in Settings > My Templates under the matching category.
|
||||
|
||||
## General Rules
|
||||
|
||||
- Use clear section headings ending with a colon.
|
||||
- Put one instruction or example per line.
|
||||
- Separate formatting preferences from clinical facts.
|
||||
- Do not include fake patient data unless it is clearly labeled as an example.
|
||||
- Use bracketed placeholders for variable content, such as `[age]`, `[chief complaint]`, or `[follow-up interval]`.
|
||||
- Tell the AI what to omit when data is missing.
|
||||
|
||||
## Good Heading Style
|
||||
|
||||
Chief Complaint:
|
||||
History of Present Illness:
|
||||
Review of Systems:
|
||||
Physical Examination:
|
||||
Assessment:
|
||||
Plan:
|
||||
|
||||
## HPI Template Example
|
||||
|
||||
History of Present Illness:
|
||||
Write one concise paragraph in third person.
|
||||
Start with age, sex, historian, and chief complaint.
|
||||
Use chronology first, then associated symptoms and pertinent negatives.
|
||||
Only include OLDCARTS details if they were provided.
|
||||
Do not invent fever height, duration, sick contacts, intake/output, severity, or home treatments.
|
||||
|
||||
## Sick Visit Template Example
|
||||
|
||||
Chief Complaint:
|
||||
[chief complaint]
|
||||
|
||||
History of Present Illness:
|
||||
One paragraph with onset, duration, progression, treatments tried, response, relevant exposures, and pertinent positives/negatives only if documented.
|
||||
|
||||
Review of Systems:
|
||||
Include systems reviewed. Expand normal systems with brief relevant negatives. Do not list systems not reviewed.
|
||||
|
||||
Physical Examination:
|
||||
Use system headings. Include only examined systems. Expand normal findings with specific exam language.
|
||||
|
||||
Assessment and Plan:
|
||||
1. [diagnosis or clinical assessment]
|
||||
Plan: [medications, supportive care, testing, return precautions, follow-up]
|
||||
|
||||
## Well Visit Template Example
|
||||
|
||||
Chief Complaint:
|
||||
Well child visit.
|
||||
|
||||
Interval History:
|
||||
Parent concerns, development, growth, diet, sleep, elimination, school/daycare, behavior, safety, and interval illness if discussed.
|
||||
|
||||
Review of Systems:
|
||||
Use age-appropriate systems. Only include findings reviewed or documented.
|
||||
|
||||
Physical Examination:
|
||||
Use system headings. Include vitals and measurements if provided.
|
||||
|
||||
Growth Assessment:
|
||||
Comment on growth pattern, BMI or weight-for-length when available, and whether growth is appropriate or needs follow-up.
|
||||
|
||||
Screening Results:
|
||||
Document completed screenings and results only.
|
||||
|
||||
Immunizations:
|
||||
Vaccines given, deferred, or refused if documented.
|
||||
|
||||
Assessment:
|
||||
1. Well child visit - [age]
|
||||
2. Growth: [appropriate/concerning/deferred]
|
||||
3. Development: [appropriate/concerning/deferred]
|
||||
|
||||
Plan:
|
||||
Nutrition/feeding counseling.
|
||||
Age-appropriate anticipatory guidance.
|
||||
Vaccines and screenings.
|
||||
Follow-up and next well visit.
|
||||
|
||||
## ED Template Example
|
||||
|
||||
Chief Complaint:
|
||||
[chief complaint]
|
||||
|
||||
History of Present Illness:
|
||||
Use chronology and relevant positives/negatives. Do not force every OLDCARTS element.
|
||||
|
||||
Physical Examination:
|
||||
Focused exam with abnormal findings first when relevant.
|
||||
|
||||
ED Course:
|
||||
Diagnostics, treatments, reassessments, consults, and response in chronological order.
|
||||
|
||||
Assessment and Plan:
|
||||
Problem-oriented assessment. Include disposition only when documented.
|
||||
|
||||
## Physical Exam Template Example
|
||||
|
||||
General: Well-appearing, alert, interactive, in no acute distress.
|
||||
HEENT: Normocephalic, atraumatic. TMs clear bilaterally. Oropharynx clear without erythema or exudate.
|
||||
Neck: Supple, full range of motion, no lymphadenopathy.
|
||||
Respiratory: Clear to auscultation bilaterally, no wheezes, crackles, or retractions.
|
||||
Cardiovascular: Regular rate and rhythm, no murmur, brisk capillary refill.
|
||||
Abdomen: Soft, non-tender, non-distended, normoactive bowel sounds.
|
||||
Skin: Warm, dry, no rash.
|
||||
Neurologic: Alert, age-appropriate, no focal deficit observed.
|
||||
|
||||
## Assessment And Plan Template Example
|
||||
|
||||
Assessment:
|
||||
1. [Diagnosis or clinical problem]
|
||||
Supporting findings: [brief rationale]
|
||||
|
||||
Plan:
|
||||
Medications:
|
||||
Testing:
|
||||
Supportive care:
|
||||
Return precautions:
|
||||
Follow-up:
|
||||
|
||||
## Common Mistakes To Avoid
|
||||
|
||||
- Do not paste a complete note with fake patient facts and expect the AI to ignore them.
|
||||
- Do not use vague instructions like "make it good" or "standard plan".
|
||||
- Do not mix multiple note types in one template unless that is intentional.
|
||||
- Do not include billing claims or diagnosis certainty that should depend on the encounter.
|
||||
|
|
@ -74,11 +74,24 @@ router.get('/extensions/export', async function (req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
// ── POST /api/extensions/import/preview — preview another user's JSON export
|
||||
router.post('/extensions/import/preview', async function (req, res) {
|
||||
try {
|
||||
var items = transfer.importItemsFromBody(req.body);
|
||||
if (!items.length) return res.status(400).json({ error: 'No valid entries to import' });
|
||||
var analysis = await analyzeImportForUser(req.user.id, items);
|
||||
res.json({ success: true, preview: analysis });
|
||||
} catch (e) {
|
||||
logger.error('POST /extensions/import/preview', e.message);
|
||||
res.status(500).json({ error: 'Import preview failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/extensions/import — import another user's JSON export ─────
|
||||
router.post('/extensions/import', async function (req, res) {
|
||||
try {
|
||||
var items = transfer.importItemsFromBody(req.body);
|
||||
var result = await importItemsForUser(req.user.id, items);
|
||||
var result = await importItemsForUser(req.user.id, items, parseImportOptions(req.body && req.body.options));
|
||||
if (!result.success) return res.status(400).json(result);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
|
|
@ -87,13 +100,28 @@ router.post('/extensions/import', async function (req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
// ── POST /api/extensions/import-file/preview — preview JSON or ZIP file ─
|
||||
router.post('/extensions/import-file/preview', upload.single('file'), async function (req, res) {
|
||||
try {
|
||||
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' });
|
||||
var parsed = transfer.parseImportBuffer(req.file.buffer);
|
||||
var items = transfer.importItemsFromBody(parsed);
|
||||
if (!items.length) return res.status(400).json({ error: 'No valid entries to import' });
|
||||
var analysis = await analyzeImportForUser(req.user.id, items);
|
||||
res.json({ success: true, preview: analysis });
|
||||
} catch (e) {
|
||||
logger.error('POST /extensions/import-file/preview', e.message);
|
||||
res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/extensions/import-file — import JSON or ZIP file ─────────
|
||||
router.post('/extensions/import-file', upload.single('file'), async function (req, res) {
|
||||
try {
|
||||
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' });
|
||||
var parsed = transfer.parseImportBuffer(req.file.buffer);
|
||||
var items = transfer.importItemsFromBody(parsed);
|
||||
var result = await importItemsForUser(req.user.id, items);
|
||||
var result = await importItemsForUser(req.user.id, items, parseImportOptions(req.body));
|
||||
if (!result.success) return res.status(400).json(result);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
|
|
@ -102,34 +130,69 @@ router.post('/extensions/import-file', upload.single('file'), async function (re
|
|||
}
|
||||
});
|
||||
|
||||
async function importItemsForUser(userId, items) {
|
||||
if (!items.length) return { success: false, error: 'No valid entries to import' };
|
||||
function parseImportOptions(raw) {
|
||||
raw = raw || {};
|
||||
return {
|
||||
importPossibleDuplicates: raw.importPossibleDuplicates === true || raw.importPossibleDuplicates === 'true' || raw.importPossibleDuplicates === '1',
|
||||
restoreTrashed: raw.restoreTrashed === true || raw.restoreTrashed === 'true' || raw.restoreTrashed === '1'
|
||||
};
|
||||
}
|
||||
|
||||
var existingRows = await db.all(
|
||||
'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1',
|
||||
async function getExistingRows(userId) {
|
||||
return db.all(
|
||||
'SELECT id, location, name, number, type, notes, trashed_at FROM user_phone_extensions WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
var existing = Object.create(null);
|
||||
existingRows.forEach(function(row) { existing[transfer.itemKey(transfer.normalizeExportItem(row))] = true; });
|
||||
}
|
||||
|
||||
async function analyzeImportForUser(userId, items) {
|
||||
var existingRows = await getExistingRows(userId);
|
||||
return transfer.analyzeImport(items, existingRows);
|
||||
}
|
||||
|
||||
async function importItemsForUser(userId, items, options) {
|
||||
if (!items.length) return { success: false, error: 'No valid entries to import' };
|
||||
options = options || {};
|
||||
|
||||
var existingRows = await getExistingRows(userId);
|
||||
var analysis = transfer.analyzeImport(items, existingRows);
|
||||
|
||||
var imported = 0;
|
||||
var skipped = 0;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var key = transfer.itemKey(item);
|
||||
if (existing[key]) {
|
||||
var restored = 0;
|
||||
var possibleSkipped = 0;
|
||||
for (var i = 0; i < analysis.entries.length; i++) {
|
||||
var entry = analysis.entries[i];
|
||||
var item = entry.item;
|
||||
if (entry.status === 'exact_active') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (entry.status === 'exact_trashed') {
|
||||
if (options.restoreTrashed && entry.exactMatch && entry.exactMatch.id) {
|
||||
await db.run(
|
||||
'UPDATE user_phone_extensions SET trashed_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 AND trashed_at IS NOT NULL',
|
||||
[entry.exactMatch.id, userId]
|
||||
);
|
||||
restored++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.status === 'possible_duplicate' && !options.importPossibleDuplicates) {
|
||||
skipped++;
|
||||
possibleSkipped++;
|
||||
continue;
|
||||
}
|
||||
await db.run(
|
||||
'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||
[userId, item.location, item.name, item.number, item.type, item.notes]
|
||||
);
|
||||
existing[key] = true;
|
||||
imported++;
|
||||
}
|
||||
|
||||
return { success: true, imported: imported, skipped: skipped, total: items.length };
|
||||
return { success: true, imported: imported, restored: restored, skipped: skipped, possibleSkipped: possibleSkipped, total: items.length, preview: analysis.summary };
|
||||
}
|
||||
|
||||
// ── POST /api/extensions ────────────────────────────────────
|
||||
|
|
@ -231,4 +294,4 @@ router.delete('/extensions/:id/purge', async function (req, res) {
|
|||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports._test = { importItemsForUser };
|
||||
module.exports._test = { importItemsForUser, analyzeImportForUser, parseImportOptions };
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@ function itemKey(item) {
|
|||
}).join('\u001f');
|
||||
}
|
||||
|
||||
function looseKeys(item) {
|
||||
var location = String(item && item.location || '').trim().toLowerCase();
|
||||
var number = String(item && item.number || '').trim().toLowerCase();
|
||||
var type = sanitizeType(item && item.type);
|
||||
var keys = [];
|
||||
if (location && number) keys.push('location-number:' + location + '\u001f' + number);
|
||||
if (number && type) keys.push('number-type:' + number + '\u001f' + type);
|
||||
return keys;
|
||||
}
|
||||
|
||||
function exportPayload(rows) {
|
||||
return {
|
||||
format: 'pedscribe-phone-directory',
|
||||
|
|
@ -48,6 +58,68 @@ function importItemsFromBody(body) {
|
|||
});
|
||||
}
|
||||
|
||||
function analyzeImport(items, existingRows) {
|
||||
var exact = Object.create(null);
|
||||
var loose = Object.create(null);
|
||||
(existingRows || []).forEach(function(row) {
|
||||
var normalized = normalizeExportItem(row);
|
||||
var existing = {
|
||||
id: row.id,
|
||||
location: normalized.location,
|
||||
name: normalized.name,
|
||||
number: normalized.number,
|
||||
type: normalized.type,
|
||||
notes: normalized.notes,
|
||||
trashed: row.trashed_at != null
|
||||
};
|
||||
exact[itemKey(normalized)] = existing;
|
||||
looseKeys(normalized).forEach(function(key) {
|
||||
if (!loose[key]) loose[key] = [];
|
||||
loose[key].push(existing);
|
||||
});
|
||||
});
|
||||
|
||||
var summary = { total: items.length, new: 0, exactActive: 0, exactTrashed: 0, possible: 0 };
|
||||
var entries = items.map(function(item, index) {
|
||||
var exactMatch = exact[itemKey(item)];
|
||||
var possibleMap = Object.create(null);
|
||||
looseKeys(item).forEach(function(key) {
|
||||
(loose[key] || []).forEach(function(match) { possibleMap[String(match.id)] = match; });
|
||||
});
|
||||
var possibleMatches = Object.keys(possibleMap).map(function(id) { return possibleMap[id]; });
|
||||
var status = 'new';
|
||||
if (exactMatch && exactMatch.trashed) status = 'exact_trashed';
|
||||
else if (exactMatch) status = 'exact_active';
|
||||
else if (possibleMatches.length) status = 'possible_duplicate';
|
||||
|
||||
if (status === 'exact_active') summary.exactActive++;
|
||||
else if (status === 'exact_trashed') summary.exactTrashed++;
|
||||
else if (status === 'possible_duplicate') summary.possible++;
|
||||
else summary.new++;
|
||||
|
||||
var entry = { index: index, status: status, item: item, exactMatch: exactMatch || null, possibleMatches: possibleMatches.slice(0, 5) };
|
||||
|
||||
var importedCandidate = {
|
||||
id: 'import-' + index,
|
||||
location: item.location,
|
||||
name: item.name,
|
||||
number: item.number,
|
||||
type: item.type,
|
||||
notes: item.notes,
|
||||
trashed: false,
|
||||
incoming: true
|
||||
};
|
||||
looseKeys(item).forEach(function(key) {
|
||||
if (!loose[key]) loose[key] = [];
|
||||
loose[key].push(importedCandidate);
|
||||
});
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return { summary: summary, entries: entries };
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
var table = crc32.table;
|
||||
if (!table) {
|
||||
|
|
@ -149,8 +221,10 @@ module.exports = {
|
|||
normalizeExportItem,
|
||||
isValidImportItem,
|
||||
itemKey,
|
||||
looseKeys,
|
||||
exportPayload,
|
||||
importItemsFromBody,
|
||||
analyzeImport,
|
||||
createJsonZip,
|
||||
parseImportBuffer
|
||||
};
|
||||
|
|
|
|||
|
|
@ -113,19 +113,20 @@ Generate a hospital course organized BY ORGAN SYSTEM for ICU stay.
|
|||
${CORE_RULES}
|
||||
Format:
|
||||
- LOS: [days]
|
||||
- General: overall course summary
|
||||
- Neurologic: mental status, neuro exams
|
||||
- Respiratory: ventilation, O2 support, treatments, CXR findings, ABG/VBG
|
||||
- Cardiovascular: hemodynamics, HR, BP, perfusion, cardiac monitoring
|
||||
- FEN/GI: nutrition, fluids, electrolytes, GI function
|
||||
- Endocrine: if relevant
|
||||
- Hematology: CBC trends, coags, transfusions
|
||||
- GU/Renal: UOP, BUN/Cr, renal function
|
||||
- ID: fever curve, cultures, antibiotics, respiratory panels
|
||||
- Dermatology: if relevant
|
||||
- Psychosocial: family involvement, social work
|
||||
- Include specific values (vital signs, lab values) not vague statements
|
||||
- Be precise with medication names, doses, frequencies`,
|
||||
- General: overall course summary when supported by the source
|
||||
- Neurologic: mental status, neuro exams when relevant/provided
|
||||
- Respiratory: ventilation, O2 support, treatments, CXR findings, ABG/VBG when relevant/provided
|
||||
- Cardiovascular: hemodynamics, HR, BP, perfusion, cardiac monitoring when relevant/provided
|
||||
- FEN/GI: nutrition, fluids, electrolytes, GI function when relevant/provided
|
||||
- Endocrine: only if relevant/provided
|
||||
- Hematology: CBC trends, coags, transfusions when relevant/provided
|
||||
- GU/Renal: UOP, BUN/Cr, renal function when relevant/provided
|
||||
- ID: fever curve, cultures, antibiotics, respiratory panels when relevant/provided
|
||||
- Dermatology: only if relevant/provided
|
||||
- Psychosocial: family involvement, social work, placement barriers when relevant/provided
|
||||
- Include specific values only when provided; do not invent LOS, CXR, blood gas, UOP, cultures, or medication doses
|
||||
- Omit systems with no active issue and no provided data rather than filling with boilerplate
|
||||
- Be precise with medication names, doses, frequencies when documented`,
|
||||
|
||||
hospitalCoursePsych: `You are an expert pediatric documentation specialist for psychiatric admissions.
|
||||
Generate a hospital course for a psychiatric/behavioral health admission.
|
||||
|
|
@ -136,7 +137,8 @@ ${CORE_RULES}
|
|||
- Safety plan and interventions
|
||||
- Family meetings, ACS involvement if any
|
||||
- Include behavioral observations
|
||||
- End with clearance for discharge and disposition`,
|
||||
- Include legal/hold status, sitter/observation level, placement barriers, and pending psychiatric placement only if provided
|
||||
- End with medical clearance, psychiatric clearance, discharge, transfer, or disposition only when documented`,
|
||||
|
||||
// ======================== CHART REVIEW ========================
|
||||
chartReviewOutpatient: `You are an expert at summarizing outpatient medical records for chart review/precharting.
|
||||
|
|
@ -191,7 +193,7 @@ P (Plan): Treatment plan, medications, follow-up, patient education`,
|
|||
${CORE_RULES}
|
||||
Include:
|
||||
- Chief complaint
|
||||
- HPI (OLDCARTS)
|
||||
- HPI using OLDCARTS elements only when clinically relevant and explicitly provided
|
||||
- Relevant ROS
|
||||
- Current medications if mentioned
|
||||
- Allergies if mentioned`,
|
||||
|
|
@ -355,14 +357,15 @@ Assessment:
|
|||
2. Growth: [comment on growth pattern — appropriate/concerning]
|
||||
3. [Any additional diagnoses]
|
||||
Plan:
|
||||
- Nutrition/Feeding: (age-appropriate feeding guidance discussed — if GROWTH/NUTRITION GUIDANCE provided below, summarize what was discussed)
|
||||
- Anticipatory guidance
|
||||
- Nutrition/Feeding: include age-appropriate counseling as a standard well-visit element; if GROWTH/NUTRITION GUIDANCE is provided below, summarize the specific guidance discussed
|
||||
- Anticipatory guidance: include standard age-appropriate topics and any specific counseling documented by the physician
|
||||
- Follow-up, referrals, next well visit
|
||||
Rules:
|
||||
- Do NOT fabricate screening results — only include what was explicitly provided
|
||||
- Use physician templates when supplied
|
||||
- Do not invent exam findings; state "deferred" if not provided
|
||||
- Always include age-appropriate growth assessment and feeding/nutrition counseling in the plan
|
||||
- Clearly distinguish routine anticipatory guidance from patient-specific findings or counseling that was explicitly documented
|
||||
- If growth/nutrition reference data is provided, incorporate relevant guidance into the plan as "anticipatory guidance provided"`,
|
||||
|
||||
wellVisitShort: `You are an expert pediatric physician.
|
||||
|
|
@ -372,7 +375,7 @@ ${ROS_PE_RULES}
|
|||
S: Well child visit, brief interval history, parent concerns
|
||||
O: Vitals/measurements provided. ROS: expand each system with specific pertinent negatives (never just "WNL"). PE: describe specific normal/abnormal findings per system. Include growth assessment and BMI classification for ages ≥2.
|
||||
A: Well child, age-appropriate development, growth status, any additional diagnoses
|
||||
P: Feeding/nutrition guidance discussed, vaccines, screenings, anticipatory guidance, follow-up`,
|
||||
P: Feeding/nutrition counseling and anticipatory guidance as standard well-visit elements, vaccines, screenings, follow-up. Distinguish routine guidance from patient-specific concerns explicitly documented.`,
|
||||
|
||||
// ======================== SICK VISIT NOTE ========================
|
||||
sickVisitNote: `You are an expert pediatrician. Generate a concise, professional sick visit note from the provided data.
|
||||
|
|
@ -385,6 +388,7 @@ Review of Systems: (expand each system per ROS/PE rules — NORMAL systems get 1
|
|||
Physical Examination: (expand each system per ROS/PE rules — NORMAL systems get a brief specific normal exam description, ABNORMAL systems use the physician's notes expanded with the most common presentation in mind. Keep abnormal expansion concise — add at most 1-2 closely related pertinent negatives)
|
||||
Assessment and Plan:
|
||||
- List diagnoses with ICD-10 codes
|
||||
- Include ICD-10 codes only when the diagnosis is clear enough to code; otherwise list the clinical assessment without forcing a code
|
||||
- Treatments and medications prescribed (if mentioned)
|
||||
- Follow-up instructions
|
||||
- Return precautions
|
||||
|
|
@ -407,7 +411,7 @@ OUTPUT FORMAT — STRICT JSON ONLY, no preamble, no code fences, no commentary:
|
|||
|
||||
NOTE STRUCTURE (plain text, follow CORE RULES above — NO markdown):
|
||||
Chief Complaint:
|
||||
History of Present Illness: (OLDCARTS, pertinent positives and negatives MENTIONED, historian noted)
|
||||
History of Present Illness: (use OLDCARTS elements only when provided, include pertinent positives and negatives MENTIONED, historian noted)
|
||||
Review of Systems: (per ROS/PE rules)
|
||||
Physical Examination: (per ROS/PE rules)
|
||||
ED Course: (interventions, response, evolution — only when present in transcript)
|
||||
|
|
@ -428,6 +432,8 @@ PHYSICIAN TEMPLATES:
|
|||
- The user's saved templates (especially any ED templates, but also matching HPI/SOAP/sickvisit templates) are provided as PHYSICIAN TEMPLATES AND PREFERENCES.
|
||||
- Apply matching template sections where relevant. Newer templates take precedence over older ones for the same section.
|
||||
- Never copy clinical content from a template — only formatting and structure.
|
||||
- Do not force every OLDCARTS element; use only elements supported by the transcript.
|
||||
- Do not document billing level, disposition certainty, or final diagnosis unless supported by the transcript.
|
||||
|
||||
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.
|
||||
|
|
@ -445,7 +451,7 @@ YOUR JOB: produce ONE polished, complete final ED note that integrates all the s
|
|||
|
||||
NOTE STRUCTURE (plain text, follow CORE RULES — NO markdown):
|
||||
Chief Complaint:
|
||||
History of Present Illness: (OLDCARTS, pertinent positives and negatives, historian noted)
|
||||
History of Present Illness: (use OLDCARTS elements only when provided, include 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)
|
||||
|
|
@ -511,6 +517,7 @@ CRITICAL RULES:
|
|||
- Use ONLY information present in the note (and transcript if provided)
|
||||
- Do NOT invent data points, interventions, or diagnoses
|
||||
- Be conservative when ambiguous — pick the lower level
|
||||
- If documentation could support multiple levels, choose the lower level unless the higher level is explicitly supported by at least 2 of the 3 MDM elements
|
||||
- levelRationale must reference specific elements actually documented in the encounter`,
|
||||
|
||||
// ======================== DON'T-MISS TOOLTIP (post-note) ========================
|
||||
|
|
@ -530,6 +537,7 @@ OUTPUT FORMAT — STRICT JSON ONLY, no preamble, no code fences, no commentary:
|
|||
HARD CAP: at most 5 points. Quality over quantity. If only 2 high-yield items exist, return 2.
|
||||
|
||||
WHAT TO SURFACE:
|
||||
- Documentation gaps first, then high-yield clinical reasoning gaps
|
||||
- Important questions the physician should clarify (e.g. "Ask about toxic ingestion" for an obtunded toddler)
|
||||
- Documentation gaps a chart-reviewer would flag (e.g. "Document immunization status for this fever in infant <3mo")
|
||||
- High-yield differentials worth ruling out for this age + complaint
|
||||
|
|
|
|||
|
|
@ -41,3 +41,42 @@ test('extension transfer zip round-trips JSON payload', () => {
|
|||
|
||||
assert.deepEqual(parsed.items, payload.items);
|
||||
});
|
||||
|
||||
test('extension import analysis flags exact and loose duplicates', () => {
|
||||
const items = transfer.importItemsFromBody({
|
||||
items: [
|
||||
{ location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' },
|
||||
{ location: 'ED', name: 'Desk', number: '1234', type: 'extension', notes: '' },
|
||||
{ location: 'Ward', name: 'Clerk', number: '1234', type: 'pager', notes: '' },
|
||||
{ location: 'NICU', name: 'Fellow', number: '7777', type: 'pager', notes: '' }
|
||||
]
|
||||
});
|
||||
const analysis = transfer.analyzeImport(items, [
|
||||
{ id: 1, location: ' ed ', name: ' charge ', number: '1234', type: 'pager', notes: 'nights', trashed_at: null },
|
||||
{ id: 2, location: 'NICU', name: 'Fellow', number: '7777', type: 'pager', notes: '', trashed_at: '2026-05-08T00:00:00Z' }
|
||||
]);
|
||||
|
||||
assert.equal(analysis.summary.exactActive, 1);
|
||||
assert.equal(analysis.summary.exactTrashed, 1);
|
||||
assert.equal(analysis.summary.possible, 2);
|
||||
assert.deepEqual(analysis.entries.map(e => e.status), [
|
||||
'exact_active',
|
||||
'possible_duplicate',
|
||||
'possible_duplicate',
|
||||
'exact_trashed'
|
||||
]);
|
||||
});
|
||||
|
||||
test('extension import analysis flags loose duplicates within the same import file', () => {
|
||||
const items = transfer.importItemsFromBody({
|
||||
items: [
|
||||
{ location: 'ED', name: 'Desk', number: '1234', type: 'extension', notes: '' },
|
||||
{ location: 'ED', name: 'Nurse', number: '1234', type: 'extension', notes: 'alternate label' }
|
||||
]
|
||||
});
|
||||
const analysis = transfer.analyzeImport(items, []);
|
||||
|
||||
assert.equal(analysis.summary.new, 1);
|
||||
assert.equal(analysis.summary.possible, 1);
|
||||
assert.deepEqual(analysis.entries.map(e => e.status), ['new', 'possible_duplicate']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue