feat: add patient education handouts

This commit is contained in:
Daniel 2026-05-08 22:26:01 +02:00
parent 1cbe248450
commit 416fff624a
22 changed files with 307 additions and 39 deletions

View file

@ -11,6 +11,7 @@ The app runs as an authenticated Express/Postgres service with a browser fronten
- Live encounter capture with structured pediatric HPI generation.
- Dictation cleanup for narrative notes.
- SOAP, sick visit, well visit, hospital course, chart review, precharting, and ED encounter workflows.
- Parent-facing education handouts generated from clinician notes, with diagnosis, medication, emergency-care guidance, and preferred-language support.
- Pediatric developmental milestone tooling.
- Templates, physician memory, and per-tab model overrides.
- Server-side speech-to-text routing through configured providers.

View file

@ -436,19 +436,23 @@ Generate a SHADESS (Strengths, Home, Activities, Drugs, Emotions, Sexuality, Saf
---
### POST /api/generate-sick-visit
### POST /api/sick-visit/note
Generate a sick visit note from a transcript, incorporating diagnosis information.
Generate a sick visit note from chief complaint, transcript/dictation, ROS/PE, and diagnosis context.
- **Auth required:** Yes
- **Request body:**
```json
{
"transcript": "string",
"chiefComplaint": "string",
"transcript": "string (optional)",
"dictation": "string (optional)",
"patientAge": "string",
"patientGender": "string",
"model": "string",
"diagnoses": "string | array",
"ros": "string (optional)",
"physicalExam": "string (optional)",
"diagnoses": "string (optional)",
"physicianMemories": "string (optional)"
}
```
@ -456,6 +460,34 @@ Generate a sick visit note from a transcript, incorporating diagnosis informatio
---
### POST /api/patient-education
Generate a parent-facing education handout from an existing clinician note. The frontend exposes this as the Handout action beside generated notes.
- **Auth required:** Yes
- **Request body:**
```json
{
"noteText": "string",
"diagnosis": "string (optional)",
"medications": "string (optional)",
"patientAge": "string (optional)",
"language": "string (optional, defaults to English)",
"readingLevel": "string (optional)",
"model": "string (optional)"
}
```
- **Response:**
```json
{
"success": true,
"handout": "plain-text parent handout",
"model": "string"
}
```
---
### POST /api/generate-milestone-narrative
Generate a developmental milestone narrative from milestone data.
@ -498,8 +530,9 @@ Refine existing clinical text according to provided instructions.
- **Request body:**
```json
{
"text": "string",
"currentDocument": "string",
"instructions": "string",
"sourceContext": "string (optional)",
"model": "string"
}
```
@ -515,7 +548,7 @@ Shorten a block of clinical text while preserving key information.
- **Request body:**
```json
{
"text": "string",
"document": "string",
"model": "string"
}
```
@ -531,7 +564,8 @@ Improve clarity and readability of clinical text.
- **Request body:**
```json
{
"text": "string",
"document": "string",
"context": "string (optional)",
"model": "string"
}
```
@ -550,7 +584,7 @@ Check whether speech-to-text transcription is available and which provider is co
```json
{
"available": true,
"provider": "whisper | deepgram | browser"
"provider": "google | aws | litellm | openai | local | none"
}
```

View file

@ -50,7 +50,7 @@ src/
models.js, prompts.js, ai.js # AI provider + model + prompt management
embeddings.js # Vertex / LiteLLM / OpenAI embeddings
transcribe*.js, tts*.js # STT / TTS provider clients
routes/ # 27 Express routers (auth, hpi, soap, …)
routes/ # Express routers (auth, hpi, soap, patient education, …)
public/ # SPA
index.html # shell, loads components on demand
@ -82,7 +82,7 @@ request
→ express.json (10 MB cap)
→ rate limiters (general 200 req/min, per-endpoint tighter on auth)
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
→ route (27 routers under /api/*)
→ route (feature routers under /api/*)
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
→ handler
→ response
@ -121,6 +121,12 @@ per-feature HTML fragments under `public/components/` fetched on demand. JS
modules talk via `window` globals and `CustomEvent` on `document` — no
bundler, no framework. Loader order is fixed in `index.html`.
Post-note helpers such as billing suggestions, don't-miss review, and patient
education handouts are reusable browser-side actions backed by authenticated
JSON APIs. The patient education helper generates a parent-facing plain-text
draft from the edited note and keeps the clinician in the review loop before
copying or sharing.
`authFetch.js` installs a global `fetch` interceptor that treats any 401 on an
authenticated request as a signal to clear local session state and redirect to
login. A `BroadcastChannel('pedscribe-auth')` pushes that signal to sibling

View file

@ -134,7 +134,7 @@ The `database.js` file exports a helper object (`db`) with convenience methods:
│ │ ├── wellVisit.js # Well-visit note generation (ROS/PE/ICD-10)
│ │ ├── sickVisit.js # Sick visit note generation
│ │ ├── refine.js # Refine/shorten any generated document
│ │ ├── transcribe.js # Whisper audio transcription
│ │ ├── transcribe.js # Server-side audio transcription provider router
│ │ ├── tts.js # Text-to-speech (if configured)
│ │ ├── nextcloud.js # Nextcloud WebDAV connect/export/disconnect
│ │ ├── learningHub.js # User-facing: feed, content, quiz submission
@ -170,9 +170,9 @@ The `database.js` file exports a helper object (`db`) with convenience methods:
│ │ │ # resend verification link handler, 2FA code input
│ │ ├── admin.js # Admin panel: user management, site settings, SMTP config,
│ │ │ # model enable/disable, prompt editor, announcement banner
│ │ ├── liveEncounter.js # MediaRecorder → Whisper transcription → AI HPI generation.
│ │ ├── liveEncounter.js # MediaRecorder → server STT → AI HPI generation.
│ │ │ # Handles start/stop recording, timer, save/load encounters
│ │ ├── voiceDictation.js # Web Speech API (real-time) or Whisper (recorded) dictation
│ │ ├── voiceDictation.js # Web Speech preview plus server-STT-backed dictation
│ │ ├── hospitalCourse.js # Paste/dictate hospital course → AI summary
│ │ ├── chartReview.js # Paste/dictate chart data → AI outpatient review
│ │ ├── soap.js # Paste/dictate → AI SOAP note

View file

@ -40,7 +40,7 @@ src/
embeddings.js Vertex / LiteLLM / OpenAI embeddings
notify.js ntfy push
transcribe*.js, tts*.js STT / TTS provider clients
routes/ 27 routers
routes/ Express routers for auth, AI workflows, education, logs, and user data
public/
index.html SPA shell, version-stamped asset refs
@ -49,7 +49,7 @@ public/
js/ 24 vanilla JS modules (no bundler)
components/ per-tab HTML fragments loaded on demand
css/styles.css
models/ bundled Whisper WASM
template-guide.md downloadable user template guide
mobile/ Capacitor 6 wrapper (Android + iOS)
.github/workflows/ CI (auto-version, APK, docker)

View file

@ -8,6 +8,12 @@ Ped-AI generates pediatric clinical notes from typed input, dictation, or record
Model selection is available per task where the UI exposes a tab-level selector. Admin defaults provide the baseline model and user/task choices can override that baseline.
Generated notes can expose post-note helper panels. Billing suggestions and don't-miss review are clinician-facing. Patient education handouts are parent-facing drafts generated from the edited note, with optional diagnosis, medication, and preferred-language context. The clinician must verify the handout before sharing it.
## Phone Extensions And Pagers
The bedside tools include a per-user phone extension and pager directory. Entries support active/trash views, search, soft delete/restore, permanent purge, ZIP export, and JSON/ZIP import. Import preview flags exact active duplicates, exact trashed matches that can be restored, and possible duplicates before committing changes.
## Speech
Final transcription is server-side. Configure Google/Gemini, AWS Transcribe, LiteLLM, or OpenAI Whisper according to your deployment requirements.
@ -61,6 +67,8 @@ Admins can manage users, roles, registration, security settings, model defaults,
| Browser Whisper | Removed | No public worker or model download path. |
| Learning Hub CMS | Active | Articles, pearls, quizzes, presentations. |
| Nextcloud WebDAV | Active | Used for file browsing/content import. |
| Patient handouts | Active | Parent-facing, note-derived, preferred-language draft. |
| Extension transfer | Active | ZIP export plus JSON/ZIP import preview. |
| Audio backups | Active | Failure recovery only. |
| TTS preview | Active | Depends on configured provider. |

View file

@ -93,9 +93,9 @@ This would help administrators optimize model selection and identify training op
### 9. Patient Education Materials
**Current state:** The Learning Hub serves educational content to physicians.
**Current state:** Patient education handouts are active as post-note helpers. Generated notes can open a Handout panel that creates a parent-facing plain-text draft from the clinician note, with optional diagnosis, medication, patient age, and preferred language context. The Learning Hub remains the physician-facing education/CMS area.
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language.
**Further improvement:** Add handout templates, saved handout history, institution-approved language libraries, and printable/PDF export.
### 10. Multi-Language Support

View file

@ -560,17 +560,16 @@ it top-to-bottom and you have the whole boot sequence.
`req.ip` is the real client IP for rate-limit + audit log.
5. **`server.js:2154`** — Helmet with a hand-crafted CSP. CSP allows:
- `'self'` everywhere by default.
- `'wasm-unsafe-eval'` and `'unsafe-eval'` because in-browser
Whisper (`@xenova/transformers`) needs both.
- `cdn.jsdelivr.net` for the transformers worker.
- `huggingface.co` + the cdn-lfs subdomains for Whisper model
downloads.
- CDN origins used by the current frontend vendor assets.
- `https://challenges.cloudflare.com` for Turnstile.
- `connectSrc` extended to `clinicaltables.nlm.nih.gov` for ICD
autocomplete.
- `upgradeInsecureRequests: null` so the e2e container can be
hit over plain HTTP. Production has Caddy in front, mixed
content is a non-issue.
Browser Whisper, Transformers.js browser STT, public Whisper workers, and
browser-local model downloads are intentionally not part of the runtime or CSP.
6. **`server.js:5980`** — CORS, scoped to `/api/*` only. Static
assets must not be CORS-checked because ES-module scripts always
send an Origin header. In production, fail-closed: if neither
@ -738,7 +737,7 @@ From `server.js:229303`:
| `routes/chartReview.js` | `/api` | Chart review summarization (multi-note input) |
| `routes/milestones.js` | `/api` | Read milestones, run developmental check |
| `routes/peGuide.js` | `/api` | PE guide AI prompts (audio findings → narrative) |
| `routes/extensions.js` | `/api` | CRUD on `user_phone_extensions` |
| `routes/extensions.js` | `/api` | CRUD, ZIP export, JSON/ZIP import, duplicate preview for `user_phone_extensions` |
| `routes/soap.js` | `/api` | SOAP note generation |
| `routes/tts.js` | `/api` | Text-to-speech proxy (Google / ElevenLabs / LiteLLM) |
| `routes/nextcloud.js` | `/api` | WebDAV upload of finished notes |
@ -755,6 +754,7 @@ From `server.js:229303`:
| `routes/sickVisit.js` | `/api` | Sick-visit AI note pipeline |
| `routes/edEncounters.js` | `/api` | ED encounter pipeline |
| `routes/dontMiss.js` | `/api` | "Don't miss" differential / red-flag prompt |
| `routes/patientEducation.js` | `/api` | Parent-facing education handouts generated from clinician notes |
| `routes/userPreferences.js` | `/api/user` | User-level preferences (STT model, TTS voice, etc.) |
| `routes/learningAI.js` | `/api/admin/learning` | AI-assisted Learning Hub authoring (generate quiz from content, embed) |
@ -1519,12 +1519,6 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ \
COPY . .
RUN chmod +x /app/docker-entrypoint.sh
RUN mkdir -p /app/data/logs
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
cd /app/public/models && \
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
cd Xenova/whisper-tiny.en && \
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
...
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
@ -1543,6 +1537,8 @@ Notes:
Final image stays slim (~220 MB).
- **`npm install --omit=dev`** — devDependencies (jsdom, dompurify
for tests) are not in the runtime image.
- **No browser Whisper download step.** Browser-local Whisper assets and
Transformers.js browser STT are intentionally absent from the runtime image.
- **Healthcheck.** Polls `/api/health` every 30 s. Container marked
unhealthy after 5 failures.

View file

@ -42,8 +42,9 @@ But every tab sits on top of the same scaffolding:
`src/routes/`.
5. An **output card** that hides until generation succeeds, then
reveals the AI's note plus a `Refine` and `Shorten` bar.
6. After generation, a few of the tabs auto-call **billing-codes** and
**don't-miss** helpers that mount additional cards beneath the note.
6. After generation, shared helpers can mount additional cards beneath
the note, including billing suggestions, don't-miss review, and
parent-facing patient education handouts.
Why a tab per note type?
Each note type has different inputs (e.g. Sick Visit needs CC + ROS +
@ -133,16 +134,18 @@ react.
`transcribeAudio(blob)` in `public/js/app.js:739795` is the single
entry point for STT:
- If browser-Whisper is enabled in user settings (and supported), it
runs locally — no network call, no PHI leaves the device.
- Otherwise it POSTs the Blob to `/api/transcribe` (handled by
- It POSTs the Blob to `/api/transcribe` (handled by
`src/routes/transcribe.js` — out of scope here). The server picks
whichever provider the admin configured: Gemini, AWS Transcribe
Medical, OpenAI Whisper, Local Whisper, or LiteLLM.
Medical, OpenAI Whisper, local server Whisper, or LiteLLM.
- On any failure with a non-empty Blob, the audio is auto-saved via
`saveAudioBackup(blob, 'failed-transcription')` (`audioBackup.js`)
so the user can retry from the Settings page.
Browser Whisper and browser-local model downloads are removed from the
runtime. Browser-native Web Speech remains an explicit opt-in live preview
when supported by the browser, not the final transcription path.
A startup probe (`checkTranscribeStatus`, `app.js:722`) queries
`/api/transcribe/status` and stores the result in
`window._transcribeAvailable`. If the server reports `false`, every tab
@ -188,6 +191,10 @@ After the note is rendered, several tabs trigger optional follow-ups:
- `suggestDontMiss(outputId, noteText, noteType, age, cc)`
(`app.js:894`) — POSTs to `/api/dont-miss` and mounts an
orange-bordered card with up to 5 high-yield "don't miss" items.
- `attachPatientEducation(outputId, { patientAge })` — adds a Handout
button that opens a parent-facing education panel. Generate POSTs the
edited note to `/api/patient-education` with optional diagnosis,
medication, and parent-language fields.
Refine and Shorten use the same pattern: `refineDocument(outputId,
inputId)` (`app.js:946`) and `shortenDocument(outputId)` (`app.js:971`)

View file

@ -1,10 +1,10 @@
# PedScribe Mobile App
Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides background audio recording, push notifications, haptic feedback, deep linking, and share intent support on both iOS and Android.
Capacitor mobile wrapper for the hosted Ped-AI web app. The app defaults to `https://app.pedshub.com`, lets users choose a self-hosted server URL, and keeps clinical workflows API-backed through the same Express service as the browser app.
## Features
- Background recording that survives screen lock (foreground service on Android, background audio on iOS)
- Hosted web workflow inside a native WebView; server updates reach mobile clients without app-store releases
- Configurable server URL (supports self-hosted instances)
- Haptic feedback on recording start/stop
- Keep screen awake during recording
@ -15,7 +15,7 @@ Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides backgrou
stored in iOS Keychain / Android Keystore, gated by OS biometric.
Enrolled on first password sign-in (opt-in prompt). 2FA still applies
on top — biometric replaces the password step only.
- App Store and Play Store ready
- Android and iOS project scaffolds for store builds
## Prerequisites

View file

@ -817,6 +817,112 @@ function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefC
});
}
// ── Patient education handout helper ────────────────────────
// Adds a reusable "Handout" action beside generated clinical notes. The actual
// handout is generated only when the physician clicks Generate in the panel.
function attachPatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var actions = card.querySelector('.output-actions');
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
if (actions && !document.getElementById(prefix + '-patient-ed-btn')) {
var btn = document.createElement('button');
btn.id = prefix + '-patient-ed-btn';
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.innerHTML = '<i class="fas fa-person-breastfeeding"></i> Handout';
btn.addEventListener('click', function() {
var panel = ensurePatientEducationPanel(outputElementId, opts);
panel.classList.remove('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
actions.appendChild(btn);
}
}
function ensurePatientEducationPanel(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var panelId = prefix + '-patient-ed';
var panel = document.getElementById(panelId);
if (panel) return panel;
panel = document.createElement('div');
panel.id = panelId;
panel.className = 'card patient-ed-card hidden';
panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;';
panel.innerHTML =
'<div class="card-header output-header">' +
'<h3><i class="fas fa-person-breastfeeding" style="color:#0ea5e9;"></i> Patient Education Handout</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" id="' + prefix + '-patient-ed-generate" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' +
'<div class="demo-field"><label>Parent language</label><select id="' + prefix + '-patient-ed-language">' +
'<option>English</option><option>Spanish</option><option>French</option><option>Arabic</option><option>Haitian Creole</option><option>Chinese</option><option>Russian</option><option>Portuguese</option>' +
'</select></div>' +
'<div class="demo-field"><label>Diagnosis/context</label><input type="text" id="' + prefix + '-patient-ed-diagnosis" placeholder="Optional: diagnosis to emphasize"></div>' +
'<div class="demo-field"><label>Medications</label><input type="text" id="' + prefix + '-patient-ed-meds" placeholder="Optional: meds/doses from plan"></div>' +
'</div>' +
'<div id="' + prefix + '-patient-ed-text" class="output-text" contenteditable="true" style="margin:0 16px 12px;min-height:120px;" data-placeholder="Generated parent handout appears here..."></div>' +
'<div style="padding:0 16px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + prefix + '-patient-ed-text"><i class="fas fa-copy"></i> Copy</button>' +
'<span style="font-size:11px;color:var(--g500);">Parent-facing draft. Verify before sharing.</span>' +
'</div>';
outputEl.parentNode.insertBefore(panel, outputEl.nextSibling);
var gen = panel.querySelector('#' + prefix + '-patient-ed-generate');
if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); });
return panel;
}
function generatePatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var handoutEl = document.getElementById(prefix + '-patient-ed-text');
var langEl = document.getElementById(prefix + '-patient-ed-language');
var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis');
var medsEl = document.getElementById(prefix + '-patient-ed-meds');
var noteText = (outputEl.innerText || outputEl.textContent || '').trim();
if (!noteText) { showToast('No note for handout', 'error'); return; }
if (handoutEl) handoutEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating parent handout...';
fetch('/api/patient-education', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
diagnosis: dxEl ? dxEl.value : '',
medications: medsEl ? medsEl.value : '',
patientAge: opts.patientAge || '',
language: langEl ? langEl.value : 'English',
readingLevel: '6th grade plain language',
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
if (handoutEl) handoutEl.textContent = '';
showToast(data.error || 'Handout generation failed', 'error');
return;
}
setOutputText(handoutEl, data.handout || '');
showToast('Patient handout generated', 'success');
})
.catch(function(err) {
if (handoutEl) handoutEl.textContent = '';
showToast(err.message || 'Handout generation failed', 'error');
});
}
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId);
var input = document.getElementById(inputElementId);

View file

@ -174,6 +174,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Chart review generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('cr-review-text', { patientAge: document.getElementById('cr-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('cr-review-text', data.review, 'chart', document.getElementById('cr-age').value);
} else showToast(data.error || 'Failed', 'error');
})
@ -190,6 +191,7 @@
var outputCard = document.getElementById('cr-output');
if (reviewText) reviewText.textContent = enc.generated_note;
if (outputCard) outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('cr-review-text', { patientAge: document.getElementById('cr-age').value });
}
});

View file

@ -205,6 +205,7 @@
'<div id="ed-final-note-text" class="output-text"></div>';
var t = container.querySelector('#ed-final-note-text');
if (t) t.textContent = note;
if (typeof attachPatientEducation === 'function') attachPatientEducation('ed-final-note-text', { patientAge: getVal('ed-age') });
container.classList.remove('hidden');
container.scrollIntoView({ behavior: 'smooth' });
}

View file

@ -175,6 +175,7 @@
clarifyOutput.classList.add('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Hospital course generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('hc-course-text', { patientAge: document.getElementById('hc-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('hc-course-text', data.hospitalCourse, 'hospital', document.getElementById('hc-age').value, document.getElementById('hc-setting').value);
} else showToast(data.error || 'Failed', 'error');
})

View file

@ -213,6 +213,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('HPI generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('enc-hpi-text', { patientAge: document.getElementById('enc-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value, document.getElementById('enc-setting').value);
if (typeof suggestDontMiss === 'function') suggestDontMiss('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value);
} else showToast(data.error || 'Failed', 'error');

View file

@ -921,6 +921,7 @@
if (tag) tag.textContent = (data.model || '').split('/').pop();
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
showToast('Well visit note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('wv-note-text', { patientAge: document.getElementById('wv-note-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('wv-note-text', data.note, 'wellvisit', document.getElementById('wv-note-age').value);
} else {
showToast(data.error || 'Generation failed', 'error');

View file

@ -335,6 +335,7 @@
if (tag) tag.textContent = (data.model || '').split('/').pop();
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
showToast('Sick visit note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('sick-note-text', { patientAge: document.getElementById('sick-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value);
if (typeof suggestDontMiss === 'function') suggestDontMiss('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value, document.getElementById('sick-cc').value);
} else {

View file

@ -193,6 +193,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('SOAP note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('soap-text', { patientAge: document.getElementById('soap-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('soap-text', data.soap, 'soap', document.getElementById('soap-age').value);
} else showToast(data.error || 'Failed', 'error');
})
@ -209,6 +210,7 @@
if (enc.generated_note) {
setOutputText(soapText, enc.generated_note);
outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('soap-text', { patientAge: document.getElementById('soap-age').value });
}
try {
var pd = JSON.parse(enc.partial_data || '{}');

View file

@ -187,6 +187,7 @@
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('dict-hpi-text', { patientAge: document.getElementById('dict-age').value });
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
@ -202,6 +203,7 @@
if (enc.generated_note) {
hpiText.textContent = enc.generated_note;
outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('dict-hpi-text', { patientAge: document.getElementById('dict-age').value });
}
});
}

View file

@ -320,6 +320,7 @@ app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.use('/api', require('./src/routes/edEncounters'));
app.use('/api', require('./src/routes/dontMiss'));
app.use('/api', require('./src/routes/patientEducation'));
app.use('/api/user', require('./src/routes/userPreferences'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));

View file

@ -0,0 +1,66 @@
// ============================================================
// PATIENT EDUCATION HANDOUTS — parent-facing education generated
// from an existing clinician note.
// ============================================================
var express = require('express');
var router = express.Router();
var { callAI } = require('../utils/ai');
var PROMPTS = require('../utils/prompts');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
router.use(authMiddleware);
// ── POST /patient-education ─────────────────────────────────
// Body:
// noteText — generated clinician note (required)
// diagnosis — optional explicit diagnosis override/context
// medications — optional medication list or free text
// patientAge — optional, used for age-appropriate language
// language — parent preferred language; defaults to English
// readingLevel — optional plain-language target
// model — optional override
// Returns: { success, handout, model }
router.post('/patient-education', async function(req, res) {
var start = Date.now();
try {
var body = req.body || {};
var noteText = String(body.noteText || '').trim();
if (!noteText) return res.status(400).json({ error: 'noteText is required' });
var language = String(body.language || 'English').trim() || 'English';
var context = 'PARENT PREFERRED LANGUAGE: ' + language + '\n';
context += 'PATIENT AGE: ' + (body.patientAge || 'unknown') + '\n';
context += 'READING LEVEL: ' + (body.readingLevel || 'plain language') + '\n';
if (body.diagnosis && String(body.diagnosis).trim()) {
context += 'DIAGNOSIS CONTEXT: ' + wrapUserText('diagnosis', body.diagnosis) + '\n';
}
if (body.medications && String(body.medications).trim()) {
context += 'MEDICATION CONTEXT: ' + wrapUserText('medications', body.medications) + '\n';
}
context += '\nCLINICIAN NOTE:\n' + wrapUserText('note', noteText);
var result = await callAI([
{ role: 'system', content: PROMPTS.patientEducation + INJECTION_GUARD },
{ role: 'user', content: context }
], { model: body.model, maxTokens: 1800 });
var duration = Date.now() - start;
logger.apiCall(req.user.id, '/api/patient-education', {
model: result.model,
tokensInput: result.usage && result.usage.input_tokens,
tokensOutput: result.usage && result.usage.output_tokens,
duration: duration,
statusCode: 200
});
res.json({ success: true, handout: String(result.content || '').trim(), model: result.model });
} catch (e) {
logger.error('[patientEducation] failed', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;

View file

@ -547,7 +547,39 @@ WHAT TO AVOID:
- Items already clearly documented in the note
- Generic boilerplate ("consider differential diagnosis")
- Treatment recommendations (this is a documentation/clinical-reasoning prompt, not a treatment plan)
- Padding to reach 5 fewer is fine`
- Padding to reach 5 fewer is fine`,
// ======================== PATIENT EDUCATION HANDOUT ========================
patientEducation: `You are a pediatric clinician creating a parent-facing discharge/education handout from a clinician's note.
CRITICAL SAFETY RULES:
- Use only diagnoses, medications, instructions, and clinical facts explicitly provided in the input
- Do not invent test results, medication doses, follow-up intervals, restrictions, or return precautions not supported by the input
- If a requested detail is missing, write a safe general sentence such as "Your care team did not specify this in the note" rather than guessing
- This is patient education, not a new medical plan. Do not add new treatment recommendations
- Use plain, reassuring, parent-friendly language at about a 6th grade reading level unless asked otherwise
- Translate the entire handout into the requested parent language. Keep medication names and emergency symptoms clinically clear
- PLAIN TEXT ONLY: do NOT use markdown formatting no asterisks (*), no pound signs (#), no underscores (_), no backticks, no bold/italic markers
Output exactly these sections, with section labels on their own lines:
Diagnosis:
Explain the diagnosis or likely diagnosis in simple language. If no diagnosis is documented, say that the note does not list a final diagnosis.
What this means:
Give a brief age-appropriate explanation for parents/caregivers.
Medicines and home care:
List only medicines and care instructions documented in the input. Include doses only if provided. If none are documented, say none were specified in the note.
When to seek emergency care:
List urgent warning signs that are either documented in the note or are broadly appropriate for the documented condition and pediatric age. Keep this section safety-focused and concise.
Follow-up:
Include follow-up timing or clinician instructions only if documented. If not documented, say to follow the care team's instructions.
Questions for your care team:
List 2-4 practical questions parents can ask if anything is unclear.`
};
// ── DB override support ────────────────────────────────────────────────────