feat: add extension import preview

This commit is contained in:
Daniel 2026-05-08 21:22:22 +02:00
parent b7f9da6600
commit 1cbe248450
17 changed files with 557 additions and 179 deletions

34
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View file

@ -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/

View file

@ -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

View file

@ -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'`

View file

@ -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 |

View file

@ -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 |

View file

@ -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 918)
```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 5881):
1. `_blobToFloat32(blob)` decodes the WebM/Opus blob into a Float32Array
at 16 kHz mono using `AudioContext` (lines 142153). 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 114124):
- 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 659683) and the
`transcribeAudio` / `_serverTranscribe` chain (lines 739795).
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.

View file

@ -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,

View file

@ -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">

View file

@ -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;">

View file

@ -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
View 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.

View file

@ -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 };

View file

@ -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
};

View file

@ -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

View file

@ -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']);
});