diff --git a/README.md b/README.md
index f232149..0e4359c 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ The app runs as an authenticated Express/Postgres service with a browser fronten
- PE guide and clinical reference content.
- Vaccines, catch-up schedules, growth/vitals, bilirubin, BSA, GCS, equipment, and resuscitation helpers.
- Mobile-friendly PWA layout for bedside use.
+- Per-user phone extension and pager directory with soft-delete, search, ZIP export, and JSON/ZIP import for handoff between users.
### Learning Hub
@@ -70,6 +71,8 @@ Health check:
curl -fsS http://127.0.0.1:3552/api/health
```
+Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix.
+
The first registered user becomes an admin unless registration has already been configured differently.
## Core Environment
@@ -158,6 +161,7 @@ Primary references:
- `docs/learning-hub.md` for the CMS and education workflow.
- `docs/configuration.md` for environment variables.
- `docs/deployment.md` for production deployment.
+- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes.
- `docs/logic/README.md` for the deeper code walkthrough.
Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior.
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 3d37114..2ae436e 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -17,6 +17,7 @@ Complete endpoint reference for the PedAI application. All endpoints are prefixe
- [Audio Backups](#audio-backups)
- [Documents](#documents)
- [User Preferences](#user-preferences)
+- [Phone Extensions And Pagers](#phone-extensions-and-pagers)
- [Nextcloud Integration](#nextcloud-integration)
- [Learning Hub (Public)](#learning-hub-public)
- [Learning Hub CMS (Moderator+)](#learning-hub-cms-moderator)
@@ -25,6 +26,7 @@ Complete endpoint reference for the PedAI application. All endpoints are prefixe
- [Logs](#logs)
- [Milestones (Admin)](#milestones-admin)
- [Health](#health)
+- [Metrics](#metrics)
---
@@ -1072,6 +1074,190 @@ Save the user's preferred WebDAV learning content path.
---
+## Phone Extensions And Pagers
+
+Base path: `/api/extensions`. All endpoints require authentication and operate on the current user's personal directory.
+
+Portable import/export fields are `location`, `name`, `number`, `type`, and `notes`. Exports intentionally omit database IDs and user IDs. `type` is either `extension` or `pager`.
+
+### GET /api/extensions
+
+List active or trashed phone directory entries.
+
+- **Auth required:** Yes
+- **Query parameters:**
+ | Parameter | Type | Description |
+ |-----------|------|-------------|
+ | `trash` | string | `1` or `true` returns trashed entries instead of active entries. |
+ | `q` | string | Case-insensitive search over location, name, number, and notes. |
+- **Response:**
+ ```json
+ {
+ "success": true,
+ "items": [
+ {
+ "id": 1,
+ "location": "ED",
+ "name": "Charge Nurse",
+ "number": "1234",
+ "type": "extension",
+ "notes": "Nights",
+ "trashed_at": null,
+ "created_at": "2026-05-08T00:00:00.000Z",
+ "updated_at": "2026-05-08T00:00:00.000Z"
+ }
+ ]
+ }
+ ```
+
+---
+
+### POST /api/extensions
+
+Create a phone extension or pager entry.
+
+- **Auth required:** Yes
+- **Request body:**
+ ```json
+ {
+ "location": "ED",
+ "name": "Charge Nurse",
+ "number": "1234",
+ "type": "extension",
+ "notes": "Optional note"
+ }
+ ```
+- **Response:**
+ ```json
+ {
+ "success": true,
+ "id": 1
+ }
+ ```
+
+---
+
+### PUT /api/extensions/:id
+
+Update an existing entry owned by the current user.
+
+- **Auth required:** Yes
+- **Path parameters:**
+ | Parameter | Type | Description |
+ |-----------|------|-------------|
+ | `id` | number | Entry ID. |
+- **Request body:** Same fields as `POST /api/extensions`.
+- **Response:**
+ ```json
+ {
+ "success": true
+ }
+ ```
+
+---
+
+### DELETE /api/extensions/:id
+
+Move an active entry to trash. This is a soft delete.
+
+- **Auth required:** Yes
+- **Response:**
+ ```json
+ {
+ "success": true
+ }
+ ```
+
+---
+
+### POST /api/extensions/:id/restore
+
+Restore a trashed entry.
+
+- **Auth required:** Yes
+- **Response:**
+ ```json
+ {
+ "success": true
+ }
+ ```
+
+---
+
+### DELETE /api/extensions/:id/purge
+
+Permanently delete a trashed entry.
+
+- **Auth required:** Yes
+- **Response:**
+ ```json
+ {
+ "success": true
+ }
+ ```
+
+---
+
+### GET /api/extensions/export
+
+Export active entries as `pedscribe-extensions.zip`. The ZIP contains `pedscribe-extensions.json`.
+
+- **Auth required:** Yes
+- **Response headers:**
+ | Header | Description |
+ |--------|-------------|
+ | `Content-Type` | `application/zip` |
+ | `Content-Disposition` | Attachment filename. |
+ | `X-Export-Count` | Number of exported active entries. |
+- **JSON file shape inside ZIP:**
+ ```json
+ {
+ "format": "pedscribe-phone-directory",
+ "version": 1,
+ "exported_at": "2026-05-08T00:00:00.000Z",
+ "items": [
+ {
+ "location": "ED",
+ "name": "Charge Nurse",
+ "number": "1234",
+ "type": "extension",
+ "notes": "Optional note"
+ }
+ ]
+ }
+ ```
+
+---
+
+### POST /api/extensions/import
+
+Import entries from a JSON request body. Exact duplicates already owned by the user are skipped.
+
+- **Auth required:** Yes
+- **Request body:** Export payload object or an array of entry objects.
+- **Response:**
+ ```json
+ {
+ "success": true,
+ "imported": 2,
+ "skipped": 1,
+ "total": 3
+ }
+ ```
+
+---
+
+### POST /api/extensions/import-file
+
+Import entries from a multipart file upload. Accepts a PedScribe JSON export or a ZIP export containing JSON.
+
+- **Auth required:** Yes
+- **Request body:** `multipart/form-data` with field `file`.
+- **Limits:** Maximum upload size is 1 MB. At most 1000 valid entries are processed.
+- **Response:** Same as `POST /api/extensions/import`.
+
+---
+
## Nextcloud Integration
### POST /api/nextcloud/connect
@@ -2142,3 +2328,14 @@ Application health check endpoint.
"uptime": "number (seconds)"
}
```
+
+---
+
+## Metrics
+
+### GET /metrics
+
+Prometheus scrape endpoint for operational metrics. Metric names use the `ped_ai_` prefix and include HTTP request counters, durations, and in-flight request gauges. Unmatched 404 routes are collapsed under `route="unmatched"` to avoid unbounded path labels.
+
+- **Auth required:** No
+- **Response:** Prometheus text exposition format.
diff --git a/docs/architecture.md b/docs/architecture.md
index 3107ef2..a9ca6ea 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,7 +1,6 @@
# Architecture
-Self-hosted, single-tenant clinical documentation platform. Dockerized Node.js
-server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
+Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.
## Stack
@@ -9,9 +8,11 @@ server + PostgreSQL + vanilla-JS SPA. No build step on the frontend.
|---|---|
| Runtime | Node.js 20 (Alpine) + Express 4 |
| Database | PostgreSQL 16 with `pgvector` extension |
+| Cache / state | Redis for operational cache, prompt suggestions, and queue groundwork |
| Frontend | Vanilla JavaScript SPA, service-worker cache |
| Mobile | Capacitor 6 wrapper (Android + iOS) |
-| Container | Docker Compose (app + db) |
+| Container | Docker Compose (app + db + Redis) |
+| Observability | Prometheus metrics at `/metrics`; structured app logs in files, Postgres, and optional Loki |
| Reverse proxy | External (Caddy, Nginx, Traefik — any) |
## Repository layout
@@ -57,7 +58,6 @@ public/ # SPA
js/ # 24 vanilla JS modules
components/ # per-tab HTML fragments
css/styles.css
- models/ # bundled Whisper WASM + model files
mobile/ # Capacitor wrapper
capacitor.config.json # appId com.pedshub.scribe
@@ -132,8 +132,9 @@ tabs so logging out in one tab drops UI in every open tab.
|---|---|---|---|
| `pediatric-ai-scribe` | `ped-ai-local:latest` (built from repo) | 3000 | 127.0.0.1:3552 |
| `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed |
+| `ped-ai-redis` | Redis | 6379 | not exposed |
-Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs).
+Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs), and Redis data if persistence is enabled by compose.
Application health-check polls `GET /api/health`.
A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is
@@ -149,3 +150,11 @@ never bound to a public interface directly.
Precached on install: `index.html`, core JS, main stylesheet, login component.
Cleared on logout (`caches.keys() → caches.delete()`).
+
+## Clinical Assistant And MCP
+
+The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.
+
+## Speech
+
+Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through configured server-side providers. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.
diff --git a/docs/deployment.md b/docs/deployment.md
index 18dc809..ce4b097 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -10,8 +10,9 @@
| Image | Role |
|---|---|
-| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push (multi-arch: `linux/amd64` + `linux/arm64`). Pull directly or build from source. |
+| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push where configured. Pull directly or build from source. |
| `pgvector/pgvector:pg16` | Database. |
+| `redis:7-alpine` | Operational Redis cache/state. |
## Build from source
@@ -23,8 +24,7 @@ cp .env.example .env
docker compose up -d --build
```
-Two containers come up: `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db`
-internal only.
+The default compose starts `pediatric-ai-scribe` on `127.0.0.1:3552`, `pedscribe-db` internally, and `ped-ai-redis` internally.
## Minimum `.env`
@@ -80,7 +80,7 @@ App sets `trust proxy: 1` so rate limiting uses the original client IP.
| Volume | Contents | Backup priority |
|---|---|---|
| `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical |
-| `scribe-logs` | Filesystem audit log files (JSONL by day) | Low — Postgres also has these in `audit_log` table |
+| `scribe-logs` | Filesystem audit log files (JSONL by day) | High for compliance evidence; Postgres also has audit/API/access tables |
### Postgres backup / restore
@@ -120,6 +120,7 @@ REINDEXes if the ICU library version changed between image builds.
| `GET /api/health` | `{ok:true}` — public, used by Docker health check |
| `GET /api/health/detailed` | Provider status — admin-auth required |
| `GET /api/build` | Build ID (short git SHA) — useful for debugging cache invalidation |
+| `GET /metrics` | Prometheus metrics in text exposition format |
Docker health check in `Dockerfile`: every 30 s, wget-spiders `/api/health`.
Container marked unhealthy after 5 failures.
@@ -127,7 +128,7 @@ Container marked unhealthy after 5 failures.
## Resource footprint
- RAM: 256 MB minimum, 512 MB recommended for one instance with a handful of concurrent users.
-- Disk: ~220 MB image (self-hosted Whisper WASM included). Postgres size scales with audit log retention.
+- Disk: Postgres size scales with audit log retention, saved encounters, documents, and Learning Hub content.
- CPU: idle load negligible; AI calls are network-bound on the LLM provider side.
## Production checklist
@@ -141,6 +142,7 @@ Container marked unhealthy after 5 failures.
- Turnstile keys set for public-facing deployments
- Reverse proxy serves valid TLS certs
- Postgres dump scheduled off-host
+- Log retention and backup policy covers `audit_log`, `api_log`, `access_log`, and filesystem `scribe-logs`
## CI / CD
@@ -162,6 +164,7 @@ Triggered by `auto-version.yml` (reads commit messages, bumps + tags via
|---|---|---|
| App | 3000 | 127.0.0.1:3552 |
| Postgres | 5432 | not exposed |
+| Redis | 6379 | not exposed |
Change the app's external port by editing the `ports:` mapping in
`docker-compose.yml`.
@@ -174,6 +177,8 @@ Change the app's external port by editing the `ports:` mapping in
via `src/utils/auditQueue.js`, drained on SIGTERM.
4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event.
+A central Prometheus/Loki/Grafana stack can also scrape `GET /metrics` and collect Docker logs with Promtail. Keep direct Loki push enabled only for structured application events that are useful for compliance and operations.
+
## Auto-cleanup
| Target | Policy | Frequency |
diff --git a/docs/mobile-build.md b/docs/mobile-build.md
index 2281bb3..a263438 100644
--- a/docs/mobile-build.md
+++ b/docs/mobile-build.md
@@ -1,7 +1,8 @@
-# Mobile build & release
+# Mobile Build And Release
-Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS
-+ Xcode to produce an `.ipa`.
+Capacitor 6 wrapper around the hosted Ped-AI web app. The launcher defaults to `https://app.pedshub.com`, lets the user change the server URL, and stores that URL locally. Android is buildable on Linux. The iOS project exists but requires macOS and Xcode to produce an `.ipa`.
+
+This is not a separate native clinical app. The native shell provides WebView hosting, microphone permission plumbing, secure storage, and mobile packaging for the same authenticated web app.
## One-time setup
@@ -69,6 +70,8 @@ Output: `android/app/build/outputs/apk/release/app-release.apk`
For Play Store, swap `assembleRelease` → `bundleRelease`; output: `.aab` under
`bundle/release/`.
+If web assets or Capacitor config changed, run `npx cap sync android` from `mobile/` before building.
+
### Single-quote the password
Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must
@@ -109,7 +112,7 @@ user to uninstall + reinstall.
| Path | Purpose |
|---|---|
| `mobile/capacitor.config.json` | appId, name, WebView config, plugin opts |
-| `mobile/src/` | launcher HTML (server URL entry) |
+| `mobile/src/` | launcher HTML and server URL entry, defaulting to `https://app.pedshub.com` |
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
diff --git a/public/components/extensions.html b/public/components/extensions.html
index 6b7f8a6..fee540c 100644
--- a/public/components/extensions.html
+++ b/public/components/extensions.html
@@ -11,6 +11,9 @@
style="width:100%;padding:8px 10px 8px 32px;font-size:14px;border:1px solid var(--g300);border-radius:8px;">
+
+
+
diff --git a/public/js/extensions.js b/public/js/extensions.js
index ad0b9fc..4a9a669 100644
--- a/public/js/extensions.js
+++ b/public/js/extensions.js
@@ -17,6 +17,11 @@
document.getElementById('ext-add-btn').addEventListener('click', onAddClick);
document.getElementById('ext-cancel-btn').addEventListener('click', hideForm);
document.getElementById('ext-save-btn').addEventListener('click', saveItem);
+ document.getElementById('ext-export-btn').addEventListener('click', exportItems);
+ document.getElementById('ext-import-btn').addEventListener('click', function () {
+ document.getElementById('ext-import-file').click();
+ });
+ document.getElementById('ext-import-file').addEventListener('change', importItems);
document.getElementById('ext-trash-btn').addEventListener('click', toggleTrashMode);
document.getElementById('ext-back-active').addEventListener('click', function () { _mode = 'active'; updateModeBanner(); load(); });
@@ -327,6 +332,65 @@
);
}
+ function exportItems() {
+ fetch('/api/extensions/export', { headers: getAuthHeaders() })
+ .then(function (r) {
+ if (!r.ok) throw new Error('Export failed');
+ var count = parseInt(r.headers.get('X-Export-Count') || '', 10);
+ return r.blob().then(function (blob) { return { blob: blob, count: count }; });
+ })
+ .then(function (d) {
+ var a = document.createElement('a');
+ var stamp = new Date().toISOString().slice(0, 10);
+ a.href = URL.createObjectURL(d.blob);
+ a.download = 'pedscribe-extensions-' + stamp + '.zip';
+ document.body.appendChild(a);
+ a.click();
+ URL.revokeObjectURL(a.href);
+ document.body.removeChild(a);
+ showToast('Exported ' + (Number.isFinite(d.count) ? d.count : 'your') + ' entries', 'success');
+ })
+ .catch(function () { showToast('Export failed', 'error'); });
+ }
+
+ function importItems(e) {
+ 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' }
+ );
+ }
+
+ function submitImportFile(file, input) {
+ var form = new FormData();
+ form.append('file', file);
+ var headers = getAuthHeaders();
+ delete headers['Content-Type'];
+ fetch('/api/extensions/import-file', {
+ method: 'POST',
+ headers: headers,
+ body: form
+ })
+ .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');
+ load();
+ loadTrashCount();
+ })
+ .catch(function () { showToast('Import failed', 'error'); })
+ .finally(function () { input.value = ''; });
+ }
+
function toggleTrashMode() {
_mode = (_mode === 'trash') ? 'active' : 'trash';
updateModeBanner();
diff --git a/src/routes/extensions.js b/src/routes/extensions.js
index 02e7616..f5ad180 100644
--- a/src/routes/extensions.js
+++ b/src/routes/extensions.js
@@ -5,24 +5,27 @@
// or purge. List supports async search (q) and trash toggle (trash=1).
var express = require('express');
+var multer = require('multer');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
+var transfer = require('../utils/extensionTransfer');
+
+var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024 * 1024 } });
router.use(authMiddleware);
function sanitizeType(t) {
- return (t === 'pager') ? 'pager' : 'extension';
+ return transfer.sanitizeType(t);
}
function clip(s, n) {
- return String(s == null ? '' : s).trim().substring(0, n || 200);
+ return transfer.clip(s, n);
}
function parseId(raw) {
var n = parseInt(raw, 10);
return (Number.isFinite(n) && n > 0) ? n : null;
}
-
// ── GET /api/extensions ─────────────────────────────────────
// Query params:
// trash=1 → return trashed items only (default: active only)
@@ -52,6 +55,83 @@ router.get('/extensions', async function (req, res) {
}
});
+// ── GET /api/extensions/export — ZIP export for sharing/import ─────────
+router.get('/extensions/export', async function (req, res) {
+ try {
+ var rows = await db.all(
+ 'SELECT location, name, number, type, notes FROM user_phone_extensions WHERE user_id = $1 AND trashed_at IS NULL ORDER BY location ASC, type ASC, name ASC',
+ [req.user.id]
+ );
+ var payload = transfer.exportPayload(rows);
+ var zip = transfer.createJsonZip('pedscribe-extensions.json', payload);
+ res.setHeader('Content-Type', 'application/zip');
+ res.setHeader('Content-Disposition', 'attachment; filename="pedscribe-extensions.zip"');
+ res.setHeader('X-Export-Count', String(payload.items.length));
+ res.send(zip);
+ } catch (e) {
+ logger.error('GET /extensions/export', e.message);
+ res.status(500).json({ error: 'Export 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);
+ if (!result.success) return res.status(400).json(result);
+ res.json(result);
+ } catch (e) {
+ logger.error('POST /extensions/import', e.message);
+ res.status(500).json({ error: 'Import failed' });
+ }
+});
+
+// ── 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);
+ if (!result.success) return res.status(400).json(result);
+ res.json(result);
+ } catch (e) {
+ logger.error('POST /extensions/import-file', e.message);
+ res.status(400).json({ error: 'Import file must be a valid PedScribe JSON or ZIP export' });
+ }
+});
+
+async function importItemsForUser(userId, items) {
+ if (!items.length) return { success: false, error: 'No valid entries to import' };
+
+ var existingRows = await db.all(
+ 'SELECT location, name, number, type, notes 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; });
+
+ 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]) {
+ skipped++;
+ 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 };
+}
+
// ── POST /api/extensions ────────────────────────────────────
router.post('/extensions', async function (req, res) {
try {
@@ -151,3 +231,4 @@ router.delete('/extensions/:id/purge', async function (req, res) {
});
module.exports = router;
+module.exports._test = { importItemsForUser };
diff --git a/src/utils/extensionTransfer.js b/src/utils/extensionTransfer.js
new file mode 100644
index 0000000..0b85b8b
--- /dev/null
+++ b/src/utils/extensionTransfer.js
@@ -0,0 +1,156 @@
+var zlib = require('zlib');
+
+function sanitizeType(t) {
+ return t === 'pager' ? 'pager' : 'extension';
+}
+
+function clip(s, n) {
+ return String(s == null ? '' : s).trim().substring(0, n || 200);
+}
+
+function normalizeExportItem(raw) {
+ return {
+ location: clip(raw && raw.location, 120),
+ name: clip(raw && raw.name, 120),
+ number: clip(raw && raw.number, 40),
+ type: sanitizeType(raw && raw.type),
+ notes: clip(raw && raw.notes, 500)
+ };
+}
+
+function isValidImportItem(item) {
+ return !!(item && item.location && item.name && item.number);
+}
+
+function itemKey(item) {
+ return [item.location, item.name, item.number, item.type, item.notes].map(function(v) {
+ return String(v || '').trim().toLowerCase();
+ }).join('\u001f');
+}
+
+function exportPayload(rows) {
+ return {
+ format: 'pedscribe-phone-directory',
+ version: 1,
+ exported_at: new Date().toISOString(),
+ items: (rows || []).map(normalizeExportItem).filter(isValidImportItem)
+ };
+}
+
+function importItemsFromBody(body) {
+ var rawItems = Array.isArray(body) ? body : (body && Array.isArray(body.items) ? body.items : []);
+ var seen = Object.create(null);
+ return rawItems.slice(0, 1000).map(normalizeExportItem).filter(isValidImportItem).filter(function(item) {
+ var key = itemKey(item);
+ if (seen[key]) return false;
+ seen[key] = true;
+ return true;
+ });
+}
+
+function crc32(buf) {
+ var table = crc32.table;
+ if (!table) {
+ table = crc32.table = [];
+ for (var i = 0; i < 256; i++) {
+ var c = i;
+ for (var j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
+ table[i] = c >>> 0;
+ }
+ }
+ var crc = 0xffffffff;
+ for (var k = 0; k < buf.length; k++) crc = table[(crc ^ buf[k]) & 0xff] ^ (crc >>> 8);
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function dosDateTime(date) {
+ var d = date || new Date();
+ var time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2);
+ var day = Math.max(1, d.getDate());
+ var month = d.getMonth() + 1;
+ var year = Math.max(1980, d.getFullYear()) - 1980;
+ return { time: time, date: (year << 9) | (month << 5) | day };
+}
+
+function createJsonZip(filename, payload) {
+ var name = Buffer.from(filename || 'pedscribe-extensions.json');
+ var data = Buffer.from(JSON.stringify(payload, null, 2));
+ var stamp = dosDateTime(new Date());
+ var crc = crc32(data);
+ var local = Buffer.alloc(30);
+ local.writeUInt32LE(0x04034b50, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(0, 6);
+ local.writeUInt16LE(0, 8);
+ local.writeUInt16LE(stamp.time, 10);
+ local.writeUInt16LE(stamp.date, 12);
+ local.writeUInt32LE(crc, 14);
+ local.writeUInt32LE(data.length, 18);
+ local.writeUInt32LE(data.length, 22);
+ local.writeUInt16LE(name.length, 26);
+ local.writeUInt16LE(0, 28);
+
+ var central = Buffer.alloc(46);
+ central.writeUInt32LE(0x02014b50, 0);
+ central.writeUInt16LE(20, 4);
+ central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(0, 8);
+ central.writeUInt16LE(0, 10);
+ central.writeUInt16LE(stamp.time, 12);
+ central.writeUInt16LE(stamp.date, 14);
+ central.writeUInt32LE(crc, 16);
+ central.writeUInt32LE(data.length, 20);
+ central.writeUInt32LE(data.length, 24);
+ central.writeUInt16LE(name.length, 28);
+ central.writeUInt16LE(0, 30);
+ central.writeUInt16LE(0, 32);
+ central.writeUInt16LE(0, 34);
+ central.writeUInt16LE(0, 36);
+ central.writeUInt32LE(0, 38);
+ central.writeUInt32LE(0, 42);
+
+ var centralOffset = local.length + name.length + data.length;
+ var centralSize = central.length + name.length;
+ var end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(0, 4);
+ end.writeUInt16LE(0, 6);
+ end.writeUInt16LE(1, 8);
+ end.writeUInt16LE(1, 10);
+ end.writeUInt32LE(centralSize, 12);
+ end.writeUInt32LE(centralOffset, 16);
+ end.writeUInt16LE(0, 20);
+
+ return Buffer.concat([local, name, data, central, name, end]);
+}
+
+function parseImportBuffer(buffer) {
+ var buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || '');
+ if (buf.length >= 4 && buf.readUInt32LE(0) === 0x04034b50) {
+ if (buf.length < 30) throw new Error('Invalid zip file');
+ var method = buf.readUInt16LE(8);
+ var compressedSize = buf.readUInt32LE(18);
+ var nameLength = buf.readUInt16LE(26);
+ var extraLength = buf.readUInt16LE(28);
+ var dataStart = 30 + nameLength + extraLength;
+ var dataEnd = dataStart + compressedSize;
+ if (dataEnd > buf.length) throw new Error('Invalid zip file');
+ var data = buf.slice(dataStart, dataEnd);
+ if (method === 8) data = zlib.inflateRawSync(data);
+ if (method !== 0 && method !== 8) throw new Error('Unsupported zip compression');
+ return JSON.parse(data.toString('utf8'));
+ }
+ return JSON.parse(buf.toString('utf8'));
+}
+
+module.exports = {
+ sanitizeType,
+ clip,
+ normalizeExportItem,
+ isValidImportItem,
+ itemKey,
+ exportPayload,
+ importItemsFromBody,
+ createJsonZip,
+ parseImportBuffer
+};
diff --git a/src/utils/prompts.js b/src/utils/prompts.js
index dea8c31..68c961d 100644
--- a/src/utils/prompts.js
+++ b/src/utils/prompts.js
@@ -54,9 +54,11 @@ const PROMPTS = {
Generate a professional HPI from the doctor-patient encounter transcript.
${CORE_RULES}
- Write in third person
-- Use OLDCARTS framework (Onset, Location, Duration, Character, Aggravating/Alleviating, Radiation, Timing, Severity)
-- Include pertinent positives and negatives MENTIONED
-- Note the historian (parent, guardian)
+- Use OLDCARTS elements only when they are clinically relevant and explicitly provided; do not force every element into the HPI
+- Include only pertinent positives and negatives that were mentioned
+- Note the historian/source of history when stated, including parent, guardian, patient, EMS, or outside records
+- Preserve clinically important chronology, prior care, home treatments, ED/urgent-care course, and response to treatment when provided
+- Do not add severity, duration, associated symptoms, sick contacts, intake/output, fever height, or negatives unless stated
- Chronological flow`,
hpiDictation: `You are an expert medical scribe. Restructure physician dictation into a polished HPI.
@@ -75,6 +77,8 @@ ${CORE_RULES}
- Include ED course before admission if mentioned
- Include relevant labs/imaging from ED
- Include what was done in ED (IVF, meds, etc)
+- Keep the core reason for admission clear before listing ED data
+- Do not invent baseline status, family history, or social history; include them only if pertinent and provided
- More detailed than outpatient HPI
- Include relevant social history if pertinent`,
@@ -86,21 +90,23 @@ ${CORE_RULES}
- Start with arrival vital signs and initial assessment
- Chronological progression of care
- Include treatments, response to treatment, diet progression
-- End with discharge readiness statement
-- Include "medically and socially cleared for discharge" if appropriate
+- End with discharge readiness only if the provided documentation supports discharge
+- Include "medically and socially cleared for discharge" only if explicitly supported by the source material
- Mention key labs and their trends
+- Mention pending studies, unresolved problems, consultant follow-up, and discharge barriers if provided
- Do NOT organize by organ system for short stays unless ICU`,
hospitalCourseLong: `You are an expert pediatric inpatient medical documentation specialist.
Generate a hospital course organized BY DAY OF HOSPITALIZATION.
${CORE_RULES}
-- Format: Day 1 (Date), Day 2 (Date), etc.
+- Format by hospital day/date only when the source material supports those labels; otherwise use chronological paragraphs
- H&P is before Day 1. First progress note = Day 1
- For each day: key events, assessments, interventions, response
- Include relevant labs for each day
- Include changes in medications or treatment plan
- Include consultant recommendations if any
-- End with discharge day summary`,
+- Include pending studies, unresolved problems, consultant follow-up, and discharge barriers if provided
+- End with discharge day summary only if discharge-day information is provided`,
hospitalCourseICU: `You are an expert pediatric ICU documentation specialist.
Generate a hospital course organized BY ORGAN SYSTEM for ICU stay.
diff --git a/test/extension-transfer.test.js b/test/extension-transfer.test.js
new file mode 100644
index 0000000..3c30ba0
--- /dev/null
+++ b/test/extension-transfer.test.js
@@ -0,0 +1,43 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+
+const transfer = require('../src/utils/extensionTransfer');
+
+test('extension export payload removes ids and keeps portable fields', () => {
+ const payload = transfer.exportPayload([
+ { id: 10, location: ' ED ', name: ' Charge ', number: ' 1234 ', type: 'pager', notes: ' Nights ' },
+ { id: 11, location: '', name: 'Invalid', number: '5555', type: 'extension' }
+ ]);
+
+ assert.equal(payload.format, 'pedscribe-phone-directory');
+ assert.equal(payload.version, 1);
+ assert.deepEqual(payload.items, [
+ { location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' }
+ ]);
+});
+
+test('extension import normalizes, clips, and deduplicates entries', () => {
+ const items = transfer.importItemsFromBody({
+ items: [
+ { location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' },
+ { location: ' ed ', name: ' charge ', number: ' 1234 ', type: 'pager', notes: ' nights ' },
+ { location: 'Ward', name: 'Desk', number: '9999', type: 'unknown', notes: null },
+ { location: '', name: 'Bad', number: '1111' }
+ ]
+ });
+
+ assert.deepEqual(items, [
+ { location: 'ED', name: 'Charge', number: '1234', type: 'pager', notes: 'Nights' },
+ { location: 'Ward', name: 'Desk', number: '9999', type: 'extension', notes: '' }
+ ]);
+});
+
+test('extension transfer zip round-trips JSON payload', () => {
+ const payload = transfer.exportPayload([
+ { location: 'NICU', name: 'Fellow', number: '7777', type: 'pager', notes: '' }
+ ]);
+ const zip = transfer.createJsonZip('pedscribe-extensions.json', payload);
+ const parsed = transfer.parseImportBuffer(zip);
+
+ assert.deepEqual(parsed.items, payload.items);
+});