From fd7f684fe9ade7bb786a0c1505fa60708a2d6fce Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 22 Apr 2026 16:52:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(pe-guide):=20Physical=20Exam=20Guide=20tab?= =?UTF-8?q?=20=E2=80=94=20OSCE=20reference=20+=20narrative=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New top-level tab (positioned after Catch-Up Schedule) combining two functions: 1. Study reference — for each (age group, system) shows OSCE-style components with technique, expected normal finding, and abnormal- feature watch-list. 2. Documentation generator — physician marks each component Normal / Abnormal (with free-text detail) / Skip; AI produces a two-section report (Technique + Findings), narrative or structured list format. Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool, school-age, adolescent). More systems can be added to the embedded PE_DATA in peGuide.js without route changes. Files: - src/routes/peGuide.js — POST /api/generate-pe-narrative (mirrors milestone-narrative pattern: AppRole-level injection guard, clinical audit category, PHI redaction upstream already in place) - src/utils/prompts.js — peGuideNarrative + peGuideList prompts, structured two-section output - public/components/pe-guide.html — demographics bar + sub-pills + cards - public/js/peGuide.js — embedded PE_DATA (all clinical content), render + state + AI call - public/index.html — tab button, section, script include - server.js — mount route at /api No schema change. No PHI stored — findings live in memory only, exported via existing copy/read-aloud/Nextcloud actions. --- public/components/pe-guide.html | 75 +++++ public/index.html | 6 + public/js/peGuide.js | 511 ++++++++++++++++++++++++++++++++ server.js | 1 + src/routes/peGuide.js | 86 ++++++ src/utils/prompts.js | 46 +++ 6 files changed, 725 insertions(+) create mode 100644 public/components/pe-guide.html create mode 100644 public/js/peGuide.js create mode 100644 src/routes/peGuide.js diff --git a/public/components/pe-guide.html b/public/components/pe-guide.html new file mode 100644 index 0000000..74eb39b --- /dev/null +++ b/public/components/pe-guide.html @@ -0,0 +1,75 @@ +
+

Physical Exam Guide

+

OSCE-style reference for age-appropriate MSK and Neuro exam + narrative report generator

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+

Select an age group above to see the exam guide.

+
+ + + + diff --git a/public/index.html b/public/index.html index 3eae536..e612e11 100644 --- a/public/index.html +++ b/public/index.html @@ -232,6 +232,10 @@ Catch-Up Schedule + '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += '
'; + html += '
How to perform: ' + esc(c.technique) + '
'; + html += '
Expected normal: ' + esc(c.normal) + '
'; + if (c.abnormalHints && c.abnormalHints.length) { + html += '
Watch for: ' + c.abnormalHints.map(esc).join(' · ') + '
'; + } + html += '
'; + html += ' '; + html += '
'; + html += '
'; + html += ''; + }); + + content.innerHTML = html; + + // Wire events + content.querySelectorAll('[data-pe-status]').forEach(function (btn) { + btn.addEventListener('click', function () { handleStatus(btn); }); + }); + content.querySelectorAll('.pe-note').forEach(function (inp) { + inp.addEventListener('input', function () { + var key = inp.dataset.peKey; + if (state[key]) state[key].note = inp.value; + }); + }); + } + + function handleStatus(btn) { + var key = btn.dataset.peKey; + var status = btn.dataset.peStatus; + if (!state[key]) return; + state[key].status = (status === 'skip') ? null : status; + // Redraw this card's buttons + abnormal input visibility + var card = btn.closest('[data-pe-key]'); + if (card) { + card.querySelectorAll('[data-pe-status]').forEach(function (b) { + b.classList.remove('done', 'refused', 'not-due'); + if (b.dataset.peStatus === 'normal' && state[key].status === 'normal') b.classList.add('done'); + if (b.dataset.peStatus === 'abnormal' && state[key].status === 'abnormal') b.classList.add('refused'); + if (b.dataset.peStatus === 'skip' && state[key].status === null) b.classList.add('not-due'); + }); + var detail = card.querySelector('.pe-abnormal-detail'); + if (detail) detail.style.display = (state[key].status === 'abnormal') ? '' : 'none'; + } + } + + function setAllNormal() { + Object.keys(state).forEach(function (k) { if (k.indexOf(currentSystem + '-') === 0) state[k].status = 'normal'; }); + renderSystem(); + } + function clearAll() { + Object.keys(state).forEach(function (k) { if (k.indexOf(currentSystem + '-') === 0) { state[k].status = null; state[k].note = ''; } }); + renderSystem(); + } + + function generate() { + if (!currentAgeGroup || !PE_DATA[currentAgeGroup]) return; + var components = Object.keys(state) + .filter(function (k) { return k.indexOf(currentSystem + '-') === 0; }) + .map(function (k) { return state[k]; }); + + var assessed = components.filter(function (c) { return c.status !== null; }); + if (assessed.length === 0) { showToast('Mark at least one component Normal or Abnormal', 'error'); return; } + + var btn = document.getElementById('pe-generate-btn'); + btn.disabled = true; btn.innerHTML = ' Generating...'; + + var modelSel = document.querySelector('#peguide-tab .tab-model-select'); + var model = modelSel ? modelSel.value : ''; + var format = document.getElementById('pe-format').value; + + fetch('/api/generate-pe-narrative', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + components: components, + ageGroup: PE_DATA[currentAgeGroup].label, + system: currentSystem, + patientAge: document.getElementById('pe-age').value, + patientGender: document.getElementById('pe-gender').value, + model: model, + format: format + }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; } + var out = document.getElementById('pe-output'); + out.classList.remove('hidden'); + document.getElementById('pe-narrative-text').textContent = data.narrative; + var tag = document.getElementById('pe-model-tag'); + if (tag) tag.textContent = data.model || ''; + var bar = document.getElementById('pe-summary-bar'); + if (bar && data.summary) { + bar.classList.remove('hidden'); + bar.innerHTML = '' + data.summary.normal + ' normal · ' + data.summary.abnormal + ' abnormal · ' + data.summary.notAssessed + ' skipped'; + } + }) + .catch(function (err) { console.error('[PEGuide] generate error', err); showToast('Request failed', 'error'); }) + .finally(function () { + btn.disabled = false; btn.innerHTML = ' Generate Exam Report'; + }); + } + + function esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + +})(); diff --git a/server.js b/server.js index 5ca72d4..6995d84 100644 --- a/server.js +++ b/server.js @@ -275,6 +275,7 @@ app.use('/api', require('./src/routes/hpi')); app.use('/api', require('./src/routes/hospitalCourse')); app.use('/api', require('./src/routes/chartReview')); app.use('/api', require('./src/routes/milestones')); +app.use('/api', require('./src/routes/peGuide')); app.use('/api', require('./src/routes/soap')); app.use('/api', require('./src/routes/tts')); app.use('/api', require('./src/routes/nextcloud')); diff --git a/src/routes/peGuide.js b/src/routes/peGuide.js new file mode 100644 index 0000000..cd5453e --- /dev/null +++ b/src/routes/peGuide.js @@ -0,0 +1,86 @@ +// ============================================================ +// PHYSICAL EXAM GUIDE — narrative + list generation +// ============================================================ +// Takes an age group, system (msk | neuro), and a set of assessed +// components (each flagged Normal or Abnormal, abnormal with optional +// note) and returns a professional narrative paragraph or structured +// list. Mirrors the milestone-narrative pattern exactly: same guardrails, +// same injection wrapping, same audit log category. + +var express = require('express'); +var router = express.Router(); +var { callAI } = require('../utils/ai'); +var { PROMPTS, INJECTION_GUARD, wrapUserText } = require('../utils/prompts'); +var { authMiddleware } = require('../middleware/auth'); +var logger = require('../utils/logger'); + +router.post('/generate-pe-narrative', authMiddleware, async function (req, res) { + try { + var { components, ageGroup, system, patientAge, patientGender, model, format } = req.body; + if (!Array.isArray(components) || components.length === 0) { + return res.status(400).json({ error: 'No components provided' }); + } + + // Partition by assessment status + var normal = components.filter(function (c) { return c.status === 'normal'; }); + var abnormal = components.filter(function (c) { return c.status === 'abnormal'; }); + + if (normal.length === 0 && abnormal.length === 0) { + return res.json({ + success: true, + narrative: 'No physical-exam components were assessed.', + summary: { normal: 0, abnormal: 0, notAssessed: components.length } + }); + } + + // Structured text for the model — includes both technique (how the exam + // component was performed) and the finding. The prompt instructs the AI + // to produce a "Technique" preamble + "Findings" narrative. + var text = ''; + if (normal.length > 0) { + text += 'NORMAL findings:\n'; + normal.forEach(function (c) { + text += ' - ' + c.name + '\n'; + text += ' technique: ' + (c.technique || 'standard method') + '\n'; + text += ' normal finding: ' + (c.normal_finding || 'unspecified') + '\n'; + }); + } + if (abnormal.length > 0) { + text += '\nABNORMAL findings:\n'; + abnormal.forEach(function (c) { + text += ' - ' + c.name + '\n'; + text += ' technique: ' + (c.technique || 'standard method') + '\n'; + text += ' physician note: ' + (c.note || 'abnormal, no detail provided') + '\n'; + }); + } + + var prompt = (format === 'list') ? PROMPTS.peGuideList : PROMPTS.peGuideNarrative; + var systemLabel = (system === 'neuro') ? 'Neurologic' : 'Musculoskeletal'; + + var result = await callAI([ + { role: 'system', content: prompt + INJECTION_GUARD }, + { role: 'user', content: + 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\n' + + 'Age group: ' + (ageGroup || 'Unknown') + '\n' + + 'Exam system: ' + systemLabel + '\n\n' + + wrapUserText('pe_components', text) + + '\n\nAssessed: ' + (normal.length + abnormal.length) + + ' | Normal: ' + normal.length + + ' | Abnormal: ' + abnormal.length + } + ], { model: model }); + + res.json({ + success: true, + narrative: result.content, + model: result.model, + summary: { normal: normal.length, abnormal: abnormal.length, notAssessed: components.length - normal.length - abnormal.length } + }); + logger.audit(req.user.id, 'generate_pe_narrative', 'Generated PE narrative (' + systemLabel + ')', req, { category: 'clinical' }); + } catch (err) { + logger.error('POST /generate-pe-narrative', err.message); + res.status(500).json({ error: 'Request failed' }); + } +}); + +module.exports = router; diff --git a/src/utils/prompts.js b/src/utils/prompts.js index a578555..ccddbc0 100644 --- a/src/utils/prompts.js +++ b/src/utils/prompts.js @@ -213,6 +213,52 @@ Sentence 2: Key strengths — domains on track Sentence 3: Any concerns or delays. If none, state no concerns identified. EXACTLY 3 sentences. Professional language. Only mention assessed milestones.`, + // ======================== PHYSICAL EXAM GUIDE ======================== + peGuideNarrative: `You are a pediatric documentation specialist. +Generate a professional OSCE-style physical-exam report with TWO sections. +${CORE_RULES} + +Structure the output as: + +Technique: +A short paragraph describing HOW the examination was performed. Mention the +maneuvers, tests, or methods used for each assessed component, using the +"technique" strings provided. Weave them into prose — do NOT list them +bullet-by-bullet. Omit techniques for components that were not assessed. + +Findings: +A narrative paragraph or two describing what was observed. +- ONLY components that were assessed (Normal or Abnormal) +- Normal findings: use concise language describing the specific normal result + (e.g. "Hips stable to Barlow and Ortolani" not "Hips normal") +- Abnormal findings: lead with the physician's note verbatim, then add a + brief clinical qualifier only if the note is clearly incomplete +- Group related findings by region when natural (upper extremities together, + spine separately, etc.) +- End with a single-phrase overall impression +- Do NOT fabricate measurements, grades, or adverbs not provided +- Do NOT mention components that were not assessed`, + + peGuideList: `You are a pediatric documentation specialist. +Generate an OSCE-style physical-exam report as a STRUCTURED numbered LIST. +${CORE_RULES} + +Produce TWO sections, each as a numbered list: + +Technique performed: +1. [method for component 1] +2. [method for component 2] +... using the "technique" strings provided — one per assessed component. + +Findings: +1. [finding for component 1] +2. [finding for component 2] +... one line per assessed component. +- Normal findings: describe the specific normal result, not just "normal" +- Abnormal findings: start with the physician's note, then qualifier if provided +- One-line overall impression at the end +- No markdown, no symbols, plain numbered list`, + // ======================== REFINE ======================== refine: `You are a medical documentation editor. The user wants to modify a previously generated medical document.