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/
|
.firecrawl/
|
||||||
|
|
||||||
# Refactored test stack stays local for now
|
# 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
|
### 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
|
- **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:**
|
- **Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"imported": 2,
|
"imported": 2,
|
||||||
|
"restored": 1,
|
||||||
"skipped": 1,
|
"skipped": 1,
|
||||||
|
"possibleSkipped": 1,
|
||||||
"total": 3
|
"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
|
### 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
|
- **Auth required:** Yes
|
||||||
- **Request body:** `multipart/form-data` with field `file`.
|
- **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
|
## Nextcloud Integration
|
||||||
|
|
||||||
### POST /api/nextcloud/connect
|
### POST /api/nextcloud/connect
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ Helmet defaults plus:
|
||||||
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`
|
||||||
- Content-Security-Policy:
|
- Content-Security-Policy:
|
||||||
- `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com challenges.cloudflare.com`
|
- `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)
|
- `script-src-attr 'none'` (blocks inline event handlers)
|
||||||
- `frame-src 'self' challenges.cloudflare.com`
|
- `frame-src 'self' challenges.cloudflare.com`
|
||||||
- `object-src 'none'`
|
- `object-src 'none'`
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,7 @@ Maximum 20 corrections retained per category (oldest deleted).
|
||||||
| `encounters.js` | Save / load / resume with optimistic lock |
|
| `encounters.js` | Save / load / resume with optimistic lock |
|
||||||
| `memories.js` | Physician templates + corrections UI |
|
| `memories.js` | Physician templates + corrections UI |
|
||||||
| `correctionTracker.js` | Captures AI-output edits |
|
| `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 |
|
| `speechRecognition.js` | Web Speech API preview |
|
||||||
| `voicePreferences.js` | Per-user STT/TTS override |
|
| `voicePreferences.js` | Per-user STT/TTS override |
|
||||||
| `audioBackup.js` | Server + IndexedDB backup retries |
|
| `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
|
Includes the suture selector. **Important:** lists every clinical
|
||||||
formula that must NOT be modified without test vectors.
|
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
|
(`callAI`), the centralized `PROMPTS` object with DB overrides, the
|
||||||
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
|
`wrapUserText` + `INJECTION_GUARD` safety pattern, server-side STT
|
||||||
routing (Whisper / AWS Transcribe / Vertex / LiteLLM), browser
|
routing, server-side STT, TTS, and the AudioRecorder. Voice/STT plumbing is **sacred** — the
|
||||||
Whisper, the AudioRecorder. Voice/STT plumbing is **sacred** — the
|
|
||||||
doc describes it without proposing changes.
|
doc describes it without proposing changes.
|
||||||
|
|
||||||
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
|
6. **[auth-admin-learning.md](auth-admin-learning.md)** — Authentication
|
||||||
|
|
@ -82,19 +81,11 @@ from Daniel. The full sacred-zone roster:
|
||||||
| Zone | Why |
|
| Zone | Why |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
|
| `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. |
|
| 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. |
|
| 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. |
|
| 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
|
## Cross-cutting topics
|
||||||
|
|
||||||
A few topics span multiple docs. Use these as your jump-off points:
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
| 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
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -729,8 +644,6 @@ is gone — and the physician has to re-dictate. The backup feature gives
|
||||||
paths only**:
|
paths only**:
|
||||||
- `data.success === false` from the server (line 777).
|
- `data.success === false` from the server (line 777).
|
||||||
- `fetch` rejection / network error (line 786).
|
- `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
|
So the database stores audio only when something went wrong, not on every
|
||||||
recording.
|
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
|
- `public/js/audioBackup.js` — server-first, IndexedDB-fallback save
|
||||||
path. Touch only if a specific bug is reported.
|
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
|
- `public/js/speechRecognition.js` — the live-streaming preview wrapper
|
||||||
(and `app.js`'s `createSpeechRecognition`). Both exist for a reason
|
(and `app.js`'s `createSpeechRecognition`). Both exist for a reason
|
||||||
(one is a real opt-in transcript path, one is just a visual preview);
|
(one is a real opt-in transcript path, one is just a visual preview);
|
||||||
unifying them would break tabs.
|
unifying them would break tabs.
|
||||||
- `public/js/voicePreferences.js` — STT model + TTS voice picker UI.
|
- `public/js/voicePreferences.js` — STT model + TTS voice picker UI.
|
||||||
- `public/js/transcriptionSettings.js` — Browser Whisper + Web Speech
|
- `public/js/transcriptionSettings.js` — explicit Web Speech setting and
|
||||||
toggles. Mutual exclusion (turning Web Speech on disables Browser
|
transcription preference UI.
|
||||||
Whisper) is intentional and survives in this file only.
|
|
||||||
- `public/js/app.js` — the `AudioRecorder` class (lines 659–683) and the
|
- `public/js/app.js` — the `AudioRecorder` class (lines 659–683) and the
|
||||||
`transcribeAudio` / `_serverTranscribe` chain (lines 739–795).
|
`transcribeAudio` / `_serverTranscribe` chain (lines 739–795).
|
||||||
|
|
||||||
Specifically: do **not** propose:
|
Specifically: do **not** propose:
|
||||||
- Refactoring `transcribeAudio` to use a "TranscriptionRouter" class.
|
- Refactoring `transcribeAudio` to use a "TranscriptionRouter" class.
|
||||||
- Splitting `AudioRecorder` into separate start/stop modules.
|
- 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.
|
- Changing the 32 kbps Opus / 1-second slice / mono / 16 kHz config.
|
||||||
- Centralising the audio-backup save logic with the upload logic.
|
- 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 |
|
| Auth | JWT (HS256) signed locally + DB-backed `user_sessions` table |
|
||||||
| Secrets | argon2id (current), bcrypt (legacy / rehash on next login) |
|
| Secrets | argon2id (current), bcrypt (legacy / rehash on next login) |
|
||||||
| AI | Provider-agnostic via `src/utils/ai.js` → OpenAI SDK shape |
|
| 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 |
|
| TTS | Google Cloud TTS / ElevenLabs / LiteLLM-routed |
|
||||||
| Frontend | **Vanilla JS, no framework, no bundler.** Plain `<script defer>` tags + IIFE modules + lazy HTML components |
|
| Frontend | **Vanilla JS, no framework, no bundler.** Plain `<script defer>` tags + IIFE modules + lazy HTML components |
|
||||||
| Mobile | Capacitor 6 wrapper (Android shipping, iOS scaffolded) |
|
| Mobile | Capacitor 6 wrapper (Android shipping, iOS scaffolded) |
|
||||||
|
|
@ -1543,10 +1543,6 @@ Notes:
|
||||||
Final image stays slim (~220 MB).
|
Final image stays slim (~220 MB).
|
||||||
- **`npm install --omit=dev`** — devDependencies (jsdom, dompurify
|
- **`npm install --omit=dev`** — devDependencies (jsdom, dompurify
|
||||||
for tests) are not in the runtime image.
|
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
|
- **Healthcheck.** Polls `/api/health` every 30 s. Container marked
|
||||||
unhealthy after 5 failures.
|
unhealthy after 5 failures.
|
||||||
|
|
||||||
|
|
@ -1750,9 +1746,7 @@ named first.
|
||||||
|
|
||||||
### Voice / STT plumbing
|
### Voice / STT plumbing
|
||||||
|
|
||||||
`public/js/audioBackup.js`, `public/js/browserWhisper.js`,
|
`public/js/audioBackup.js`, `public/js/voiceDictation.js`, `public/js/liveEncounter.js`,
|
||||||
`public/js/voiceDictation.js`, `public/js/liveEncounter.js`,
|
|
||||||
`public/js/whisperWorker.js`, `public/js/whisperWorkerV2.js`,
|
|
||||||
`public/js/transcriptionSettings.js`, `public/js/voicePreferences.js`,
|
`public/js/transcriptionSettings.js`, `public/js/voicePreferences.js`,
|
||||||
`public/js/speechRecognition.js`, plus the server-side
|
`public/js/speechRecognition.js`, plus the server-side
|
||||||
`src/routes/transcribe.js` and `src/utils/transcribe*.js`.
|
`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
|
in the **smallest possible diff**. The recorder lifecycle has been
|
||||||
tuned over many cycles to handle: tab backgrounding, OS audio
|
tuned over many cycles to handle: tab backgrounding, OS audio
|
||||||
interruption, MediaRecorder mime negotiation across Chrome/Firefox/
|
interruption, MediaRecorder mime negotiation across Chrome/Firefox/
|
||||||
Safari, browser Whisper fallback when network fails, server-side
|
Safari, server-side transcription, and audio backup on transcription failure. Touching anything outside the
|
||||||
audio backup on transcription failure. Touching anything outside the
|
|
||||||
named bug is how the silent-cancel branch became always-taken in
|
named bug is how the silent-cancel branch became always-taken in
|
||||||
v6.53.0 (fixed in 66f319e).
|
v6.53.0 (fixed in 66f319e).
|
||||||
|
|
||||||
|
|
@ -2079,7 +2072,7 @@ public/
|
||||||
js/
|
js/
|
||||||
app.js, auth.js, authFetch.js, secureStorage.js, ui-state.js
|
app.js, auth.js, authFetch.js, secureStorage.js, ui-state.js
|
||||||
encounters.js, audioBackup.js, liveEncounter.js, voiceDictation.js,
|
encounters.js, audioBackup.js, liveEncounter.js, voiceDictation.js,
|
||||||
browserWhisper.js, whisperWorker*.js, speechRecognition.js,
|
speechRecognition.js,
|
||||||
transcriptionSettings.js, voicePreferences.js # ALL SACRED — STT
|
transcriptionSettings.js, voicePreferences.js # ALL SACRED — STT
|
||||||
calculators.js, calc-math.js, drugs-loader.js, peGuide.js # SACRED — formulas
|
calculators.js, calc-math.js, drugs-loader.js, peGuide.js # SACRED — formulas
|
||||||
bedside/index.js # ES-MODULE POCKET (script type="module")
|
bedside/index.js # ES-MODULE POCKET (script type="module")
|
||||||
|
|
@ -2089,7 +2082,6 @@ public/
|
||||||
nextcloud, learningHub, admin
|
nextcloud, learningHub, admin
|
||||||
pediatricScheduleData.js, milestonesData.js, e2e-bootstrap.js
|
pediatricScheduleData.js, milestonesData.js, e2e-bootstrap.js
|
||||||
components/ # one HTML fragment per tab (~20 files)
|
components/ # one HTML fragment per tab (~20 files)
|
||||||
models/ # bundled transformers.js + Whisper-tiny.en
|
|
||||||
|
|
||||||
scripts/
|
scripts/
|
||||||
lint-references.js, maintenance.js, release.sh, e2e.sh,
|
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>
|
<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>
|
||||||
|
|
||||||
|
<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 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 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;">
|
||||||
<div class="demo-field">
|
<div class="demo-field">
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@
|
||||||
<option value="template_ed">ED Template</option>
|
<option value="template_ed">ED Template</option>
|
||||||
</select>
|
</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)">
|
<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>
|
</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>
|
<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;">
|
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
var _mode = 'active'; // 'active' | 'trash'
|
var _mode = 'active'; // 'active' | 'trash'
|
||||||
var _editId = null; // null = creating, non-null = editing
|
var _editId = null; // null = creating, non-null = editing
|
||||||
var _searchDebounce = null;
|
var _searchDebounce = null;
|
||||||
|
var _pendingImportFile = null;
|
||||||
|
|
||||||
document.addEventListener('tabChanged', function (e) {
|
document.addEventListener('tabChanged', function (e) {
|
||||||
if (e.detail.tab !== 'extensions' || _inited) return;
|
if (e.detail.tab !== 'extensions' || _inited) return;
|
||||||
|
|
@ -22,6 +23,8 @@
|
||||||
document.getElementById('ext-import-file').click();
|
document.getElementById('ext-import-file').click();
|
||||||
});
|
});
|
||||||
document.getElementById('ext-import-file').addEventListener('change', importItems);
|
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-trash-btn').addEventListener('click', toggleTrashMode);
|
||||||
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
|
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
|
||||||
|
|
||||||
|
|
@ -357,24 +360,65 @@
|
||||||
var input = e.target;
|
var input = e.target;
|
||||||
var file = input.files && input.files[0];
|
var file = input.files && input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
var count = 'the selected';
|
previewImportFile(file, input);
|
||||||
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' }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitImportFile(file, input) {
|
function previewImportFile(file, input) {
|
||||||
var form = new FormData();
|
var form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
var headers = getAuthHeaders();
|
var headers = getAuthHeaders();
|
||||||
delete headers['Content-Type'];
|
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', {
|
fetch('/api/extensions/import-file', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
|
@ -383,7 +427,8 @@
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
if (!d.success) { showToast(d.error || 'Import failed', 'error'); return; }
|
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();
|
load();
|
||||||
loadTrashCount();
|
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 ─────
|
// ── POST /api/extensions/import — import another user's JSON export ─────
|
||||||
router.post('/extensions/import', async function (req, res) {
|
router.post('/extensions/import', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
var items = transfer.importItemsFromBody(req.body);
|
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);
|
if (!result.success) return res.status(400).json(result);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (e) {
|
} 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 ─────────
|
// ── POST /api/extensions/import-file — import JSON or ZIP file ─────────
|
||||||
router.post('/extensions/import-file', upload.single('file'), async function (req, res) {
|
router.post('/extensions/import-file', upload.single('file'), async function (req, res) {
|
||||||
try {
|
try {
|
||||||
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' });
|
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'Import file is required' });
|
||||||
var parsed = transfer.parseImportBuffer(req.file.buffer);
|
var parsed = transfer.parseImportBuffer(req.file.buffer);
|
||||||
var items = transfer.importItemsFromBody(parsed);
|
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);
|
if (!result.success) return res.status(400).json(result);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -102,34 +130,69 @@ router.post('/extensions/import-file', upload.single('file'), async function (re
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importItemsForUser(userId, items) {
|
function parseImportOptions(raw) {
|
||||||
if (!items.length) return { success: false, error: 'No valid entries to import' };
|
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(
|
async function getExistingRows(userId) {
|
||||||
'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1',
|
return db.all(
|
||||||
|
'SELECT id, location, name, number, type, notes, trashed_at FROM user_phone_extensions WHERE user_id = $1',
|
||||||
[userId]
|
[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 imported = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
for (var i = 0; i < items.length; i++) {
|
var restored = 0;
|
||||||
var item = items[i];
|
var possibleSkipped = 0;
|
||||||
var key = transfer.itemKey(item);
|
for (var i = 0; i < analysis.entries.length; i++) {
|
||||||
if (existing[key]) {
|
var entry = analysis.entries[i];
|
||||||
|
var item = entry.item;
|
||||||
|
if (entry.status === 'exact_active') {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
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(
|
await db.run(
|
||||||
'INSERT INTO user_phone_extensions (user_id, location, name, number, type, notes) VALUES ($1,$2,$3,$4,$5,$6)',
|
'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]
|
[userId, item.location, item.name, item.number, item.type, item.notes]
|
||||||
);
|
);
|
||||||
existing[key] = true;
|
|
||||||
imported++;
|
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 ────────────────────────────────────
|
// ── POST /api/extensions ────────────────────────────────────
|
||||||
|
|
@ -231,4 +294,4 @@ router.delete('/extensions/:id/purge', async function (req, res) {
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports._test = { importItemsForUser };
|
module.exports._test = { importItemsForUser, analyzeImportForUser, parseImportOptions };
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ function itemKey(item) {
|
||||||
}).join('\u001f');
|
}).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) {
|
function exportPayload(rows) {
|
||||||
return {
|
return {
|
||||||
format: 'pedscribe-phone-directory',
|
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) {
|
function crc32(buf) {
|
||||||
var table = crc32.table;
|
var table = crc32.table;
|
||||||
if (!table) {
|
if (!table) {
|
||||||
|
|
@ -149,8 +221,10 @@ module.exports = {
|
||||||
normalizeExportItem,
|
normalizeExportItem,
|
||||||
isValidImportItem,
|
isValidImportItem,
|
||||||
itemKey,
|
itemKey,
|
||||||
|
looseKeys,
|
||||||
exportPayload,
|
exportPayload,
|
||||||
importItemsFromBody,
|
importItemsFromBody,
|
||||||
|
analyzeImport,
|
||||||
createJsonZip,
|
createJsonZip,
|
||||||
parseImportBuffer
|
parseImportBuffer
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -113,19 +113,20 @@ Generate a hospital course organized BY ORGAN SYSTEM for ICU stay.
|
||||||
${CORE_RULES}
|
${CORE_RULES}
|
||||||
Format:
|
Format:
|
||||||
- LOS: [days]
|
- LOS: [days]
|
||||||
- General: overall course summary
|
- General: overall course summary when supported by the source
|
||||||
- Neurologic: mental status, neuro exams
|
- Neurologic: mental status, neuro exams when relevant/provided
|
||||||
- Respiratory: ventilation, O2 support, treatments, CXR findings, ABG/VBG
|
- Respiratory: ventilation, O2 support, treatments, CXR findings, ABG/VBG when relevant/provided
|
||||||
- Cardiovascular: hemodynamics, HR, BP, perfusion, cardiac monitoring
|
- Cardiovascular: hemodynamics, HR, BP, perfusion, cardiac monitoring when relevant/provided
|
||||||
- FEN/GI: nutrition, fluids, electrolytes, GI function
|
- FEN/GI: nutrition, fluids, electrolytes, GI function when relevant/provided
|
||||||
- Endocrine: if relevant
|
- Endocrine: only if relevant/provided
|
||||||
- Hematology: CBC trends, coags, transfusions
|
- Hematology: CBC trends, coags, transfusions when relevant/provided
|
||||||
- GU/Renal: UOP, BUN/Cr, renal function
|
- GU/Renal: UOP, BUN/Cr, renal function when relevant/provided
|
||||||
- ID: fever curve, cultures, antibiotics, respiratory panels
|
- ID: fever curve, cultures, antibiotics, respiratory panels when relevant/provided
|
||||||
- Dermatology: if relevant
|
- Dermatology: only if relevant/provided
|
||||||
- Psychosocial: family involvement, social work
|
- Psychosocial: family involvement, social work, placement barriers when relevant/provided
|
||||||
- Include specific values (vital signs, lab values) not vague statements
|
- Include specific values only when provided; do not invent LOS, CXR, blood gas, UOP, cultures, or medication doses
|
||||||
- Be precise with medication names, doses, frequencies`,
|
- 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.
|
hospitalCoursePsych: `You are an expert pediatric documentation specialist for psychiatric admissions.
|
||||||
Generate a hospital course for a psychiatric/behavioral health admission.
|
Generate a hospital course for a psychiatric/behavioral health admission.
|
||||||
|
|
@ -136,7 +137,8 @@ ${CORE_RULES}
|
||||||
- Safety plan and interventions
|
- Safety plan and interventions
|
||||||
- Family meetings, ACS involvement if any
|
- Family meetings, ACS involvement if any
|
||||||
- Include behavioral observations
|
- 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 ========================
|
// ======================== CHART REVIEW ========================
|
||||||
chartReviewOutpatient: `You are an expert at summarizing outpatient medical records for chart review/precharting.
|
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}
|
${CORE_RULES}
|
||||||
Include:
|
Include:
|
||||||
- Chief complaint
|
- Chief complaint
|
||||||
- HPI (OLDCARTS)
|
- HPI using OLDCARTS elements only when clinically relevant and explicitly provided
|
||||||
- Relevant ROS
|
- Relevant ROS
|
||||||
- Current medications if mentioned
|
- Current medications if mentioned
|
||||||
- Allergies if mentioned`,
|
- Allergies if mentioned`,
|
||||||
|
|
@ -355,14 +357,15 @@ Assessment:
|
||||||
2. Growth: [comment on growth pattern — appropriate/concerning]
|
2. Growth: [comment on growth pattern — appropriate/concerning]
|
||||||
3. [Any additional diagnoses]
|
3. [Any additional diagnoses]
|
||||||
Plan:
|
Plan:
|
||||||
- Nutrition/Feeding: (age-appropriate feeding guidance discussed — if GROWTH/NUTRITION GUIDANCE provided below, summarize what was discussed)
|
- 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
|
- Anticipatory guidance: include standard age-appropriate topics and any specific counseling documented by the physician
|
||||||
- Follow-up, referrals, next well visit
|
- Follow-up, referrals, next well visit
|
||||||
Rules:
|
Rules:
|
||||||
- Do NOT fabricate screening results — only include what was explicitly provided
|
- Do NOT fabricate screening results — only include what was explicitly provided
|
||||||
- Use physician templates when supplied
|
- Use physician templates when supplied
|
||||||
- Do not invent exam findings; state "deferred" if not provided
|
- Do not invent exam findings; state "deferred" if not provided
|
||||||
- Always include age-appropriate growth assessment and feeding/nutrition counseling in the plan
|
- 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"`,
|
- If growth/nutrition reference data is provided, incorporate relevant guidance into the plan as "anticipatory guidance provided"`,
|
||||||
|
|
||||||
wellVisitShort: `You are an expert pediatric physician.
|
wellVisitShort: `You are an expert pediatric physician.
|
||||||
|
|
@ -372,7 +375,7 @@ ${ROS_PE_RULES}
|
||||||
S: Well child visit, brief interval history, parent concerns
|
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.
|
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
|
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 ========================
|
// ======================== SICK VISIT NOTE ========================
|
||||||
sickVisitNote: `You are an expert pediatrician. Generate a concise, professional sick visit note from the provided data.
|
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)
|
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:
|
Assessment and Plan:
|
||||||
- List diagnoses with ICD-10 codes
|
- 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)
|
- Treatments and medications prescribed (if mentioned)
|
||||||
- Follow-up instructions
|
- Follow-up instructions
|
||||||
- Return precautions
|
- 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):
|
NOTE STRUCTURE (plain text, follow CORE RULES above — NO markdown):
|
||||||
Chief Complaint:
|
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)
|
Review of Systems: (per ROS/PE rules)
|
||||||
Physical Examination: (per ROS/PE rules)
|
Physical Examination: (per ROS/PE rules)
|
||||||
ED Course: (interventions, response, evolution — only when present in transcript)
|
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.
|
- 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.
|
- 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.
|
- 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+):
|
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.
|
||||||
|
|
@ -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):
|
NOTE STRUCTURE (plain text, follow CORE RULES — NO markdown):
|
||||||
Chief Complaint:
|
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)
|
Review of Systems: (per ROS/PE rules)
|
||||||
Physical Examination: (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)
|
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)
|
- 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
|
||||||
|
- 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`,
|
- levelRationale must reference specific elements actually documented in the encounter`,
|
||||||
|
|
||||||
// ======================== DON'T-MISS TOOLTIP (post-note) ========================
|
// ======================== 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.
|
HARD CAP: at most 5 points. Quality over quantity. If only 2 high-yield items exist, return 2.
|
||||||
|
|
||||||
WHAT TO SURFACE:
|
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)
|
- 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")
|
- 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
|
- 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);
|
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