Initial commit: Pediatric AI Scribe v1.0.0

- Live encounter recording → HPI generation
- Physician voice dictation → polished HPI
- Developmental milestones checklist (AAP/Nelson)
- 3-sentence summary generator
- OpenRouter integration with model selector
- Docker support
- Mobile responsive UI
This commit is contained in:
ifedan-ed 2026-03-17 21:46:05 -04:00
commit 993e46f720
15 changed files with 2886 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
npm-debug.log
.git
.env.local

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Copy this file to .env and fill in your API keys
# cp .env.example .env
OPENROUTER_API_KEY=sk-or-v1-paste-your-key-here
OPENAI_API_KEY=sk-paste-your-openai-key-here
ELEVENLABS_API_KEY=
PORT=3000
APP_URL=http://localhost:3000

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Environment variables (NEVER push API keys)
.env
.env.local
.env.production
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Docker
docker-compose.override.yml
# IDE
.vscode/
.idea/
*.swp
*.swo
# Build
dist/
build/

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# 🏥 Pediatric AI Scribe
AI-powered clinical documentation tool for pediatric practices.
## Features
| Module | Description |
|--------|-------------|
| **Live Encounter → HPI** | Records doctor-patient conversation, generates structured HPI |
| **Voice Dictation → HPI** | Physician dictates narrative, AI restructures into polished HPI |
| **Developmental Milestones** | Clickable AAP/Nelson checklist → AI narrative + 3-sentence summary |
## Tech Stack
- **Backend:** Node.js + Express
- **AI:** OpenRouter (Gemini Flash, DeepSeek, GPT-4o, Claude)
- **Transcription:** OpenAI Whisper
- **Frontend:** Vanilla HTML/CSS/JS
## Quick Start with Docker
```bash
# 1. Create .env file
cat > .env << 'EOF'
OPENROUTER_API_KEY=sk-or-v1-your-key
OPENAI_API_KEY=sk-your-key
ELEVENLABS_API_KEY=
PORT=3000
APP_URL=http://localhost:3000
EOF
# 2. Run
docker run -d \
--name pediatric-scribe \
--env-file .env \
-p 3000:3000 \
danielonyejesi/pediatric-ai-scribe:latest
# 3. Open http://localhost:3000

18
docker-compose.yml Normal file
View file

@ -0,0 +1,18 @@
version: '3.8'
services:
pediatric-scribe:
build: .
ports:
- "3000:3000"
env_file:
- .env
restart: unless-stopped
container_name: pediatric-ai-scribe
volumes:
- ./public:/app/public
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "pediatric-ai-scribe",
"version": "1.0.0",
"description": "AI-powered pediatric clinical documentation",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"openai": "^4.73.0",
"dotenv": "^16.4.5",
"cors": "^2.8.5",
"multer": "^1.4.5-lts.1",
"axios": "^1.7.7"
}
}

593
public/css/styles.css Normal file
View file

@ -0,0 +1,593 @@
/* ============================================================
PEDIATRIC AI SCRIBE - STYLES
============================================================ */
:root {
--blue: #2563eb;
--blue-dark: #1d4ed8;
--blue-light: #dbeafe;
--purple: #7c3aed;
--purple-light: #ede9fe;
--teal: #0891b2;
--teal-light: #cffafe;
--green: #10b981;
--green-light: #d1fae5;
--red: #ef4444;
--red-light: #fee2e2;
--amber: #f59e0b;
--amber-light: #fef3c7;
--g50: #f9fafb; --g100: #f3f4f6; --g200: #e5e7eb;
--g300: #d1d5db; --g400: #9ca3af; --g500: #6b7280;
--g600: #4b5563; --g700: #374151; --g800: #1f2937; --g900: #111827;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--g50);
color: var(--g800);
line-height: 1.6;
}
/* HEADER */
.app-header {
background: linear-gradient(135deg, var(--blue) 0%, var(--purple) 100%);
color: white;
padding: 16px 24px;
}
.header-content { max-width: 1200px; margin: 0 auto; }
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.logo { display: flex; align-items: center; gap: 12px; }
.logo i { font-size: 28px; }
.logo h1 { font-size: 22px; font-weight: 700; }
.tagline { font-size: 13px; opacity: 0.8; }
.model-selector {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.15);
padding: 8px 14px;
border-radius: 8px;
}
.model-selector label {
font-size: 13px;
white-space: nowrap;
}
.model-selector select {
padding: 5px 10px;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 6px;
background: rgba(255,255,255,0.2);
color: white;
font-size: 13px;
font-family: inherit;
}
.model-selector select option {
background: var(--g800);
color: white;
}
.cost-badge {
font-size: 11px;
padding: 2px 8px;
background: rgba(16,185,129,0.3);
border: 1px solid rgba(16,185,129,0.5);
border-radius: 10px;
color: #6ee7b7;
font-weight: 600;
white-space: nowrap;
}
/* TABS */
.tab-nav {
display: flex;
background: white;
border-bottom: 2px solid var(--g200);
max-width: 1200px;
margin: 0 auto;
overflow-x: auto;
}
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 24px;
border: none;
border-bottom: 3px solid transparent;
background: none;
color: var(--g500);
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
transition: all 0.2s;
}
.tab-btn:hover { color: var(--g700); background: var(--g50); }
.tab-btn.active { color: var(--blue); border-bottom-color: var(--blue); }
.tab-btn i { font-size: 16px; }
/* MAIN */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px;
}
.tab-content { display: none; }
.tab-content.active { display: block; animation: fadeIn 0.25s ease; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* MODULE HEADER */
.module-header { margin-bottom: 20px; }
.module-header h2 {
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.module-header p { color: var(--g500); font-size: 14px; margin-top: 4px; }
/* DEMOGRAPHICS */
.demographics-bar {
display: flex;
gap: 16px;
flex-wrap: wrap;
background: white;
padding: 14px 18px;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.demo-field { display: flex; flex-direction: column; gap: 4px; }
.demo-field label {
font-size: 11px;
font-weight: 600;
color: var(--g500);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.demo-field input, .demo-field select {
padding: 8px 12px;
border: 1.5px solid var(--g300);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
min-width: 150px;
}
.demo-field input:focus, .demo-field select:focus {
outline: none;
border-color: var(--blue);
box-shadow: 0 0 0 3px var(--blue-light);
}
/* CARDS */
.card {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 16px;
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
background: var(--g50);
border-bottom: 1px solid var(--g200);
}
.card-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--g700);
display: flex;
align-items: center;
gap: 8px;
}
/* EDITABLE BOX */
.editable-box {
min-height: 120px;
max-height: 300px;
overflow-y: auto;
padding: 14px 18px;
font-size: 14px;
line-height: 1.8;
outline: none;
}
.editable-box:empty::before {
content: attr(data-placeholder);
color: var(--g400);
}
/* RECORDING */
.record-controls {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 18px;
}
.record-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: var(--red);
color: white;
border: none;
border-radius: 50px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(239,68,68,0.3);
}
.record-btn:hover { transform: translateY(-1px); }
.record-btn.recording {
background: var(--g700);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.btn-purple { background: var(--purple); box-shadow: 0 4px 12px rgba(124,58,237,0.3); }
.recording-indicator { display: flex; align-items: center; gap: 8px; color: var(--red); font-weight: 500; }
.recording-indicator.hidden { display: none; }
.pulse-dot {
width: 12px; height: 12px; background: var(--red);
border-radius: 50%; animation: pulse 1.5s infinite;
}
.pulse-purple { background: var(--purple); }
@keyframes pulse {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(1.3); }
}
/* BUTTONS */
.btn-generate {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px;
background: var(--blue);
color: white;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
margin-bottom: 16px;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(37,99,235,0.25);
}
.btn-generate:hover { transform: translateY(-1px); }
.btn-generate:disabled { background: var(--g300); cursor: not-allowed; transform: none; box-shadow: none; }
.btn-generate-purple { background: var(--purple); box-shadow: 0 4px 12px rgba(124,58,237,0.25); }
.btn-generate-teal { background: var(--teal); box-shadow: 0 4px 12px rgba(8,145,178,0.25); }
.btn-sm {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.btn-primary { background: var(--blue); color: white; }
.btn-primary:hover { background: var(--blue-dark); }
.btn-ghost { background: var(--g200); color: var(--g700); }
.btn-ghost:hover { background: var(--g300); }
.btn-success { background: var(--green); color: white; }
.btn-success:hover { background: #059669; }
/* OUTPUT */
.output-card { border: 2px solid var(--green); }
.output-card.hidden { display: none; }
.output-header { background: var(--green-light); border-bottom-color: var(--green); }
.output-actions { display: flex; align-items: center; gap: 8px; }
.output-text {
padding: 18px;
font-size: 14px;
line-height: 1.8;
outline: none;
min-height: 80px;
white-space: pre-wrap;
}
.model-tag {
font-size: 10px;
padding: 2px 8px;
background: var(--g200);
border-radius: 4px;
color: var(--g600);
}
/* ============================================================
MILESTONES
============================================================ */
.legend {
display: flex;
gap: 20px;
flex-wrap: wrap;
padding: 10px 16px;
background: var(--teal-light);
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-icon {
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 6px;
font-size: 13px; font-weight: 700;
}
.legend-yes { background: var(--green-light); color: var(--green); border: 2px solid var(--green); }
.legend-no { background: var(--red-light); color: var(--red); border: 2px solid var(--red); }
.legend-blank { background: var(--g100); color: var(--g400); border: 2px solid var(--g300); }
.domain-section {
background: white;
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 12px;
overflow: hidden;
}
.domain-header {
padding: 12px 18px;
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.domain-header .badge {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 2px 10px;
border-radius: 10px;
background: rgba(255,255,255,0.6);
}
.domain-gross-motor .domain-header { background: var(--amber-light); color: #92400e; }
.domain-fine-motor .domain-header { background: var(--blue-light); color: #1e40af; }
.domain-language .domain-header { background: var(--purple-light); color: #5b21b6; }
.domain-social .domain-header { background: #fce7f3; color: #9d174d; }
.domain-cognitive .domain-header { background: var(--green-light); color: #065f46; }
.ms-items { padding: 6px 8px; }
.ms-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.15s;
}
.ms-item:hover { background: var(--g50); }
.ms-toggle { display: flex; gap: 4px; flex-shrink: 0; }
.toggle-btn {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
border: 2px solid var(--g300);
border-radius: 8px;
background: white;
cursor: pointer;
font-size: 15px; font-weight: 700;
color: var(--g400);
transition: all 0.15s;
}
.toggle-btn:hover { border-color: var(--g400); }
.toggle-btn.yes-on {
background: var(--green-light); border-color: var(--green); color: var(--green);
}
.toggle-btn.no-on {
background: var(--red-light); border-color: var(--red); color: var(--red);
}
.ms-text { font-size: 14px; color: var(--g700); }
.ms-item.is-yes .ms-text { color: var(--g900); font-weight: 500; }
.ms-item.is-no .ms-text { color: var(--red); }
.milestone-actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin: 16px 0;
}
.summary-bar {
display: flex;
gap: 16px;
padding: 10px 18px;
background: var(--g50);
border-bottom: 1px solid var(--g200);
font-size: 13px;
}
.summary-bar.hidden { display: none; }
.stat { display: flex; align-items: center; gap: 6px; font-weight: 500; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-green { background: var(--green); }
.dot-red { background: var(--red); }
.dot-gray { background: var(--g400); }
/* LOADING */
.loading-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
z-index: 9999;
backdrop-filter: blur(3px);
}
.loading-overlay.hidden { display: none; }
.loading-box {
background: white;
padding: 32px 48px;
border-radius: var(--radius);
text-align: center;
box-shadow: var(--shadow-md);
}
.spinner {
width: 36px; height: 36px;
border: 4px solid var(--g200);
border-top-color: var(--blue);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin: 0 auto 14px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-box p { color: var(--g600); font-weight: 500; font-size: 14px; }
/* TOAST */
.toast-container {
position: fixed; bottom: 20px; right: 20px;
display: flex; flex-direction: column; gap: 8px; z-index: 10000;
}
.toast {
padding: 10px 18px;
border-radius: 8px;
color: white;
font-size: 13px; font-weight: 500;
box-shadow: var(--shadow-md);
animation: slideIn 0.3s ease;
display: flex; align-items: center; gap: 8px;
}
.toast-success { background: var(--green); }
.toast-error { background: var(--red); }
.toast-info { background: var(--blue); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.hidden { display: none !important; }
/* RESPONSIVE */
@media (max-width: 768px) {
.header-top { flex-direction: column; align-items: flex-start; }
.model-selector { width: 100%; }
.model-selector select { flex: 1; }
.tab-btn span { display: none; }
.tab-btn { flex: 1; justify-content: center; padding: 12px; }
.tab-btn i { font-size: 18px; }
.demographics-bar { flex-direction: column; }
.demo-field input, .demo-field select { min-width: 100%; }
.milestone-actions { flex-direction: column; }
.btn-generate-teal { width: 100%; }
.legend { flex-direction: column; gap: 8px; }
}
/* Scrollbar */
.editable-box::-webkit-scrollbar, .output-text::-webkit-scrollbar { width: 5px; }
.editable-box::-webkit-scrollbar-thumb, .output-text::-webkit-scrollbar-thumb {
background: var(--g300); border-radius: 3px;
}
/* Reading Active State */
.btn-reading {
background: var(--red) !important;
color: white !important;
animation: btn-pulse 1.5s infinite;
}
.btn-reading:hover {
background: #dc2626 !important;
}
@keyframes btn-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ============================================================
3-SENTENCE SUMMARY
============================================================ */
.summary-section {
border-top: 2px dashed var(--g200);
}
.summary-section-header {
padding: 14px 18px;
display: flex;
align-items: center;
}
.btn-generate-summary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
box-shadow: 0 3px 10px rgba(245, 158, 11, 0.25);
}
.btn-generate-summary:hover {
transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(245, 158, 11, 0.35);
}
.btn-generate-summary:disabled {
background: var(--g300);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-generate-summary.loading {
pointer-events: none;
opacity: 0.7;
}
.summary-output-header {
background: var(--amber-light);
border-bottom: 1px solid #fbbf24;
border-top: 1px solid var(--g200);
}
.summary-output-text {
padding: 16px 18px;
font-size: 14px;
line-height: 1.9;
outline: none;
min-height: 60px;
white-space: pre-wrap;
color: var(--g800);
background: #fffbeb;
font-weight: 500;
}
.summary-output-text::-webkit-scrollbar { width: 5px; }
.summary-output-text::-webkit-scrollbar-thumb {
background: var(--g300);
border-radius: 3px;
}

327
public/index.html Normal file
View file

@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pediatric AI Scribe</title>
<link rel="stylesheet" href="/css/styles.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>
<!-- HEADER -->
<header class="app-header">
<div class="header-content">
<div class="header-top">
<div class="logo">
<i class="fas fa-stethoscope"></i>
<div>
<h1>Pediatric AI Scribe</h1>
<p class="tagline">AI-Powered Clinical Documentation</p>
</div>
</div>
<div class="model-selector">
<label><i class="fas fa-robot"></i> Model:</label>
<select id="global-model-select">
<option value="google/gemini-2.5-flash">⚡ Gemini Flash 2.5 (Best Value)</option>
<option value="google/gemini-2.5-flash:thinking">🧠 Gemini Flash Thinking</option>
<option value="deepseek/deepseek-chat-v3-0324">💰 DeepSeek V3</option>
<option value="deepseek/deepseek-r1:free">🆓 DeepSeek R1 (FREE)</option>
<option value="qwen/qwen3-30b-a3b:free">🆓 Qwen3 30B (FREE)</option>
<option value="meta-llama/llama-3.1-70b-instruct">🦙 Llama 3.1 70B</option>
<option value="openai/gpt-4o">👑 GPT-4o (Premium)</option>
<option value="anthropic/claude-sonnet-4">👑 Claude Sonnet 4</option>
</select>
<span id="model-cost-badge" class="cost-badge">~$0.001/call</span>
</div>
</div>
</div>
</header>
<!-- TAB NAVIGATION -->
<nav class="tab-nav">
<button class="tab-btn active" data-tab="encounter">
<i class="fas fa-comments"></i>
<span>Live Encounter → HPI</span>
</button>
<button class="tab-btn" data-tab="dictation">
<i class="fas fa-microphone"></i>
<span>Voice Dictation → HPI</span>
</button>
<button class="tab-btn" data-tab="milestones">
<i class="fas fa-baby"></i>
<span>Dev Milestones</span>
</button>
</nav>
<main class="main-content">
<!-- ===== TAB 1: LIVE ENCOUNTER ===== -->
<section id="encounter-tab" class="tab-content active">
<div class="module-header">
<h2><i class="fas fa-comments"></i> Live Patient Encounter → HPI</h2>
<p>Record the doctor-patient conversation. AI generates a structured HPI.</p>
</div>
<div class="demographics-bar">
<div class="demo-field">
<label>Patient Age</label>
<input type="text" id="enc-patient-age" placeholder="e.g., 4 years old">
</div>
<div class="demo-field">
<label>Gender</label>
<select id="enc-patient-gender">
<option value="">Select</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
</div>
<div class="card">
<div class="record-controls">
<button id="enc-record-btn" class="record-btn">
<i class="fas fa-microphone"></i>
<span>Start Recording</span>
</button>
<div id="enc-recording-indicator" class="recording-indicator hidden">
<div class="pulse-dot"></div>
<span>Recording... <span id="enc-timer">00:00</span></span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-alt"></i> Transcript</h3>
<button id="enc-clear-transcript" class="btn-sm btn-ghost">
<i class="fas fa-eraser"></i> Clear
</button>
</div>
<div id="enc-transcript" class="editable-box" contenteditable="true"
data-placeholder="Transcript appears here. You can also type or paste directly."></div>
</div>
<button id="enc-generate-btn" class="btn-generate">
<i class="fas fa-magic"></i> Generate HPI from Encounter
</button>
<div id="enc-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> Generated HPI</h3>
<div class="output-actions">
<span id="enc-model-used" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('enc-hpi-text')">
<i class="fas fa-copy"></i> Copy
</button>
<button class="btn-sm btn-ghost" onclick="speakText('enc-hpi-text')">
<i class="fas fa-volume-up"></i> Read
</button>
</div>
</div>
<div id="enc-hpi-text" class="output-text" contenteditable="true"></div>
</div>
</section>
<!-- ===== TAB 2: VOICE DICTATION ===== -->
<section id="dictation-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-microphone"></i> Physician Voice Dictation → HPI</h2>
<p>Dictate your clinical narrative. AI restructures it into a polished HPI.</p>
</div>
<div class="demographics-bar">
<div class="demo-field">
<label>Patient Age</label>
<input type="text" id="dict-patient-age" placeholder="e.g., 8 months">
</div>
<div class="demo-field">
<label>Gender</label>
<select id="dict-patient-gender">
<option value="">Select</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
</div>
<div class="card">
<div class="record-controls">
<button id="dict-record-btn" class="record-btn btn-purple">
<i class="fas fa-microphone"></i>
<span>Start Dictation</span>
</button>
<div id="dict-recording-indicator" class="recording-indicator hidden">
<div class="pulse-dot pulse-purple"></div>
<span>Dictating... <span id="dict-timer">00:00</span></span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-file-alt"></i> Dictation Text</h3>
<button id="dict-clear-transcript" class="btn-sm btn-ghost">
<i class="fas fa-eraser"></i> Clear
</button>
</div>
<div id="dict-transcript" class="editable-box" contenteditable="true"
data-placeholder="Your dictation appears here. You can also type directly."></div>
</div>
<button id="dict-generate-btn" class="btn-generate btn-generate-purple">
<i class="fas fa-magic"></i> Generate Polished HPI
</button>
<div id="dict-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> Polished HPI</h3>
<div class="output-actions">
<span id="dict-model-used" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('dict-hpi-text')">
<i class="fas fa-copy"></i> Copy
</button>
<button class="btn-sm btn-ghost" onclick="speakText('dict-hpi-text')">
<i class="fas fa-volume-up"></i> Read
</button>
</div>
</div>
<div id="dict-hpi-text" class="output-text" contenteditable="true"></div>
</div>
</section>
<!-- ===== TAB 3: DEVELOPMENTAL MILESTONES (COMPLETELY SEPARATE) ===== -->
<section id="milestones-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-baby"></i> Developmental Milestones Assessment</h2>
<p>Based on AAP & Nelson Textbook of Pediatrics. Click to assess. Leave blank = not assessed (omitted from narrative).</p>
</div>
<div class="demographics-bar">
<div class="demo-field">
<label>Patient Age</label>
<input type="text" id="ms-patient-age" placeholder="e.g., 9 months">
</div>
<div class="demo-field">
<label>Gender</label>
<select id="ms-patient-gender">
<option value="">Select</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
</select>
</div>
<div class="demo-field">
<label>Milestone Age Group</label>
<select id="ms-age-group">
<option value="">-- Select Age Group --</option>
<option value="2 months">2 Months</option>
<option value="4 months">4 Months</option>
<option value="6 months">6 Months</option>
<option value="9 months">9 Months</option>
<option value="12 months">12 Months</option>
<option value="15 months">15 Months</option>
<option value="18 months">18 Months</option>
<option value="24 months">2 Years</option>
<option value="30 months">2.5 Years</option>
<option value="36 months">3 Years</option>
<option value="48 months">4 Years</option>
<option value="60 months">5 Years</option>
</select>
</div>
</div>
<!-- LEGEND -->
<div class="legend">
<div class="legend-item">
<span class="legend-icon legend-yes"></span> Achieved
</div>
<div class="legend-item">
<span class="legend-icon legend-no"></span> Not Yet Achieved
</div>
<div class="legend-item">
<span class="legend-icon legend-blank"></span> Not Assessed (omitted)
</div>
</div>
<!-- CHECKLIST CONTAINER -->
<div id="milestone-checklist"></div>
<!-- ACTIONS -->
<div class="milestone-actions" id="milestone-actions" style="display:none;">
<button id="ms-all-yes" class="btn-sm btn-success">
<i class="fas fa-check-double"></i> All Yes
</button>
<button id="ms-all-clear" class="btn-sm btn-ghost">
<i class="fas fa-eraser"></i> Clear All
</button>
<button id="ms-generate-btn" class="btn-generate btn-generate-teal">
<i class="fas fa-magic"></i> Generate Narrative
</button>
</div>
<!-- OUTPUT -->
<!-- OUTPUT -->
<div id="ms-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> Developmental Assessment Narrative</h3>
<div class="output-actions">
<span id="ms-model-used" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('ms-narrative-text')">
<i class="fas fa-copy"></i> Copy
</button>
<button class="btn-sm btn-ghost" onclick="speakText('ms-narrative-text')">
<i class="fas fa-volume-up"></i> Read
</button>
</div>
</div>
<div id="ms-summary-bar" class="summary-bar hidden"></div>
<div id="ms-narrative-text" class="output-text" contenteditable="true"></div>
<!-- 3-SENTENCE SUMMARY SECTION -->
<div class="summary-section">
<div class="summary-section-header">
<button id="ms-quick-summary-btn" class="btn-generate-summary">
<i class="fas fa-compress-alt"></i> Generate 3-Sentence Summary
</button>
</div>
<div id="ms-quick-summary" class="hidden">
<div class="card-header summary-output-header">
<h3><i class="fas fa-clipboard-list"></i> Quick Summary (3 Sentences)</h3>
<div class="output-actions">
<span id="ms-summary-model-used" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('ms-summary-text')">
<i class="fas fa-copy"></i> Copy
</button>
<button class="btn-sm btn-ghost" onclick="speakText('ms-summary-text')">
<i class="fas fa-volume-up"></i> Read
</button>
</div>
</div>
<div id="ms-summary-text" class="summary-output-text" contenteditable="true"></div>
</div>
</div>
</div>
</section>
</main>
<!-- LOADING OVERLAY -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loading-box">
<div class="spinner"></div>
<p id="loading-text">Processing...</p>
</div>
</div>
<!-- TOAST -->
<div id="toast-container" class="toast-container"></div>
<!-- SCRIPTS (order matters) -->
<script src="/js/milestonesData.js"></script>
<script src="/js/app.js"></script>
<script src="/js/liveEncounter.js"></script>
<script src="/js/voiceDictation.js"></script>
<script src="/js/milestones.js"></script>
</body>
</html>

292
public/js/app.js Normal file
View file

@ -0,0 +1,292 @@
// ============================================================
// APP.JS - Core utilities and tab navigation
// ============================================================
// --- TAB NAVIGATION ---
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
btn.classList.add('active');
var tabId = btn.getAttribute('data-tab') + '-tab';
document.getElementById(tabId).classList.add('active');
});
});
// --- MODEL SELECTOR ---
var modelSelect = document.getElementById('global-model-select');
var costBadge = document.getElementById('model-cost-badge');
var MODEL_COSTS = {
'google/gemini-2.5-flash': '~$0.001/call',
'google/gemini-2.5-flash:thinking': '~$0.002/call',
'deepseek/deepseek-chat-v3-0324': '~$0.001/call',
'deepseek/deepseek-r1:free': 'FREE ✨',
'qwen/qwen3-30b-a3b:free': 'FREE ✨',
'meta-llama/llama-3.1-70b-instruct': '~$0.001/call',
'openai/gpt-4o': '~$0.01/call',
'anthropic/claude-sonnet-4': '~$0.015/call'
};
function getSelectedModel() {
return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash';
}
if (modelSelect) {
modelSelect.addEventListener('change', function() {
var m = modelSelect.value;
if (costBadge) {
costBadge.textContent = MODEL_COSTS[m] || '~$0.001/call';
}
showToast('Model: ' + m.split('/').pop(), 'info');
});
}
// --- LOADING ---
function showLoading(text) {
document.getElementById('loading-text').textContent = text || 'Processing...';
document.getElementById('loading-overlay').classList.remove('hidden');
}
function hideLoading() {
document.getElementById('loading-overlay').classList.add('hidden');
}
// --- TOAST ---
function showToast(message, type) {
var container = document.getElementById('toast-container');
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
toast.innerHTML = '<i class="fas fa-' + icon + '"></i> ' + message;
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3000);
}
// --- COPY ---
function copyText(elementId) {
var el = document.getElementById(elementId);
var text = el.innerText || el.textContent;
navigator.clipboard.writeText(text).then(function() {
showToast('Copied to clipboard!', 'success');
}).catch(function() {
// Fallback
var range = document.createRange();
range.selectNode(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast('Copied!', 'success');
});
}
// ============================================================
// SPEAK / STOP READING
// ============================================================
var currentlyReadingId = null;
var currentAudio = null;
function speakText(elementId) {
// If already reading THIS element, stop it
if (currentlyReadingId === elementId) {
stopReading();
return;
}
// Stop any previous reading first
stopReading();
var el = document.getElementById(elementId);
var text = (el.innerText || el.textContent).trim();
if (!text) {
showToast('Nothing to read', 'error');
return;
}
// Find the read button that triggered this
var readBtn = findReadButton(elementId);
// Try ElevenLabs first
fetch('/api/text-to-speech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.substring(0, 5000) })
})
.then(function(response) {
if (!response.ok) throw new Error('ElevenLabs not available');
return response.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
currentAudio = new Audio(url);
currentlyReadingId = elementId;
setReadButtonActive(readBtn, true);
currentAudio.onended = function() {
stopReading();
};
currentAudio.onerror = function() {
stopReading();
// Fall back to browser TTS
useBrowserTTS(text, elementId, readBtn);
};
currentAudio.play();
showToast('Playing audio — click Stop to end', 'info');
})
.catch(function() {
// Fall back to browser speech
useBrowserTTS(text, elementId, readBtn);
});
}
function useBrowserTTS(text, elementId, readBtn) {
if (!('speechSynthesis' in window)) {
showToast('Text-to-speech not supported', 'error');
return;
}
window.speechSynthesis.cancel();
var utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 0.9;
utterance.pitch = 1.0;
utterance.onend = function() {
stopReading();
};
utterance.onerror = function() {
stopReading();
};
currentlyReadingId = elementId;
setReadButtonActive(readBtn, true);
window.speechSynthesis.speak(utterance);
showToast('Reading aloud — click Stop to end', 'info');
}
function stopReading() {
// Stop browser TTS
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel();
}
// Stop ElevenLabs audio
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
if (currentAudio.src && currentAudio.src.startsWith('blob:')) {
URL.revokeObjectURL(currentAudio.src);
}
currentAudio = null;
}
// Reset button state
if (currentlyReadingId) {
var readBtn = findReadButton(currentlyReadingId);
setReadButtonActive(readBtn, false);
currentlyReadingId = null;
}
}
function findReadButton(elementId) {
// Walk up from the output text to find the read button in the same card
var el = document.getElementById(elementId);
if (!el) return null;
var card = el.closest('.output-card') || el.closest('.card');
if (!card) return null;
// Find the button that calls speakText with this elementId
var buttons = card.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var onclick = buttons[i].getAttribute('onclick') || '';
if (onclick.indexOf('speakText') !== -1 && onclick.indexOf(elementId) !== -1) {
return buttons[i];
}
}
return null;
}
function setReadButtonActive(btn, isActive) {
if (!btn) return;
if (isActive) {
btn.classList.add('btn-reading');
btn.innerHTML = '<i class="fas fa-stop"></i> Stop';
} else {
btn.classList.remove('btn-reading');
btn.innerHTML = '<i class="fas fa-volume-up"></i> Read';
}
}
// --- TIMER ---
function createTimer(displayEl) {
var secs = 0;
var interval = null;
return {
start: function() {
secs = 0;
this.update();
var self = this;
interval = setInterval(function() { secs++; self.update(); }, 1000);
},
stop: function() {
if (interval) { clearInterval(interval); interval = null; }
return secs;
},
update: function() {
var m = String(Math.floor(secs / 60)).padStart(2, '0');
var s = String(secs % 60).padStart(2, '0');
displayEl.textContent = m + ':' + s;
}
};
}
// --- AUDIO RECORDER ---
function AudioRecorder() {
this.mediaRecorder = null;
this.chunks = [];
this.stream = null;
}
AudioRecorder.prototype.start = function() {
var self = this;
self.chunks = [];
return navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }
}).then(function(stream) {
self.stream = stream;
var mimeType = 'audio/webm';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
mimeType = 'audio/webm;codecs=opus';
}
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mimeType });
self.mediaRecorder.ondataavailable = function(e) {
if (e.data.size > 0) self.chunks.push(e.data);
};
self.mediaRecorder.start(1000);
});
};
AudioRecorder.prototype.stop = function() {
var self = this;
return new Promise(function(resolve) {
if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') {
resolve(null);
return;
}
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) {
self.stream.getTracks().forEach(function(t) { t.stop(); });
}
resolve(blob);
};
self.mediaRecorder.stop();
});
};
console.log('✅ App.js loaded');

154
public/js/liveEncounter.js Normal file
View file

@ -0,0 +1,154 @@
// ============================================================
// MODULE 1: LIVE ENCOUNTER → HPI (separate from milestones)
// ============================================================
(function() {
var recordBtn = document.getElementById('enc-record-btn');
var indicator = document.getElementById('enc-recording-indicator');
var timerEl = document.getElementById('enc-timer');
var transcript = document.getElementById('enc-transcript');
var clearBtn = document.getElementById('enc-clear-transcript');
var generateBtn = document.getElementById('enc-generate-btn');
var outputCard = document.getElementById('enc-output');
var hpiText = document.getElementById('enc-hpi-text');
var modelUsed = document.getElementById('enc-model-used');
var recorder = new AudioRecorder();
var timer = createTimer(timerEl);
var isRecording = false;
// Web Speech for live preview
var recognition = null;
var finalText = '';
if (window.SpeechRecognition || window.webkitSpeechRecognition) {
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalText += e.results[i][0].transcript + ' ';
} else {
interim = e.results[i][0].transcript;
}
}
transcript.innerHTML = finalText +
(interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
};
recognition.onend = function() {
if (isRecording) { try { recognition.start(); } catch(e) {} }
};
recognition.onerror = function() {};
}
recordBtn.addEventListener('click', function() {
if (!isRecording) {
finalText = '';
recorder.start().then(function() {
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.querySelector('span').textContent = 'Stop Recording';
indicator.classList.remove('hidden');
timer.start();
if (recognition) { try { recognition.start(); } catch(e) {} }
showToast('Recording started', 'info');
}).catch(function() {
showToast('Microphone access denied', 'error');
});
} else {
isRecording = false;
var duration = timer.stop();
recordBtn.classList.remove('recording');
recordBtn.querySelector('span').textContent = 'Start Recording';
indicator.classList.add('hidden');
if (recognition) { try { recognition.stop(); } catch(e) {} }
showLoading('Transcribing audio with Whisper...');
recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) {
hideLoading();
showToast('No audio captured', 'error');
return;
}
var formData = new FormData();
formData.append('audio', blob, 'encounter.webm');
return fetch('/api/transcribe', { method: 'POST', body: formData })
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
transcript.textContent = data.text;
showToast('Transcribed ' + duration + 's of audio', 'success');
} else {
showToast(data.error || 'Transcription failed', 'error');
// Keep the live transcript if whisper failed
if (finalText.trim()) {
transcript.textContent = finalText.trim();
}
}
});
}).catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
if (finalText.trim()) {
transcript.textContent = finalText.trim();
}
});
}
});
clearBtn.addEventListener('click', function() {
transcript.textContent = '';
finalText = '';
outputCard.classList.add('hidden');
});
generateBtn.addEventListener('click', function() {
var text = transcript.innerText.trim();
if (!text) {
showToast('No transcript to process', 'error');
return;
}
showLoading('Generating HPI from encounter...');
fetch('/api/generate-hpi-encounter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript: text,
patientAge: document.getElementById('enc-patient-age').value,
patientGender: document.getElementById('enc-patient-gender').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
hpiText.textContent = data.hpi;
modelUsed.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('HPI generated!', 'success');
} else {
showToast(data.error || 'Generation failed', 'error');
}
})
.catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
});
});
console.log('✅ Live Encounter module loaded');
})();

301
public/js/milestones.js Normal file
View file

@ -0,0 +1,301 @@
// ============================================================
// MODULE 3: DEVELOPMENTAL MILESTONES → NARRATIVE
// COMPLETELY SEPARATE FROM HPI MODULES 1 & 2
// Uses AI to convert checked milestones into sentences
// ============================================================
(function() {
var ageSelect = document.getElementById('ms-age-group');
var checklist = document.getElementById('milestone-checklist');
var actionsBar = document.getElementById('milestone-actions');
var allYesBtn = document.getElementById('ms-all-yes');
var allClearBtn = document.getElementById('ms-all-clear');
var generateBtn = document.getElementById('ms-generate-btn');
var outputCard = document.getElementById('ms-output');
var summaryBar = document.getElementById('ms-summary-bar');
var narrativeText = document.getElementById('ms-narrative-text');
var modelUsed = document.getElementById('ms-model-used');
// State: { "domain-0": { domain, milestone, status: null|'yes'|'no' } }
var state = {};
// When age group changes, rebuild checklist
ageSelect.addEventListener('change', function() {
var age = ageSelect.value;
state = {};
outputCard.classList.add('hidden');
if (!age || !MILESTONES_DATA[age]) {
checklist.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:40px;">Select an age group to load milestones.</p>';
actionsBar.style.display = 'none';
return;
}
renderChecklist(MILESTONES_DATA[age]);
actionsBar.style.display = 'flex';
});
function renderChecklist(data) {
checklist.innerHTML = '';
Object.keys(data).forEach(function(domain) {
var items = data[domain];
var config = DOMAIN_CONFIG[domain] || { icon: '📋', css: '' };
var section = document.createElement('div');
section.className = 'domain-section ' + config.css;
var itemsHtml = '';
items.forEach(function(milestone, idx) {
var id = domain + '-' + idx;
state[id] = { domain: domain, milestone: milestone, status: null };
itemsHtml += '<div class="ms-item" id="item-' + safeId(id) + '">' +
'<div class="ms-toggle">' +
'<button class="toggle-btn" data-id="' + id + '" data-action="yes" title="Yes">✓</button>' +
'<button class="toggle-btn" data-id="' + id + '" data-action="no" title="No">✗</button>' +
'</div>' +
'<span class="ms-text">' + milestone + '</span>' +
'</div>';
});
section.innerHTML =
'<div class="domain-header">' +
'<span>' + config.icon + '</span>' +
'<span>' + domain + '</span>' +
'<span class="badge" id="badge-' + safeId(domain) + '">' + items.length + ' items</span>' +
'</div>' +
'<div class="ms-items">' + itemsHtml + '</div>';
checklist.appendChild(section);
});
// Attach click handlers
checklist.querySelectorAll('.toggle-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
handleToggle(btn.getAttribute('data-id'), btn.getAttribute('data-action'));
});
});
}
function safeId(str) {
return str.replace(/[^a-zA-Z0-9]/g, '_');
}
function handleToggle(id, action) {
var sid = safeId(id);
var item = document.getElementById('item-' + sid);
var yesBtn = item.querySelector('[data-action="yes"]');
var noBtn = item.querySelector('[data-action="no"]');
var current = state[id].status;
if (action === 'yes') {
if (current === 'yes') {
// Deselect
state[id].status = null;
yesBtn.classList.remove('yes-on');
item.classList.remove('is-yes', 'is-no');
} else {
state[id].status = 'yes';
yesBtn.classList.add('yes-on');
noBtn.classList.remove('no-on');
item.classList.add('is-yes');
item.classList.remove('is-no');
}
} else {
if (current === 'no') {
state[id].status = null;
noBtn.classList.remove('no-on');
item.classList.remove('is-yes', 'is-no');
} else {
state[id].status = 'no';
noBtn.classList.add('no-on');
yesBtn.classList.remove('yes-on');
item.classList.remove('is-yes');
item.classList.add('is-no');
}
}
updateBadges();
}
function updateBadges() {
var counts = {};
Object.keys(state).forEach(function(id) {
var d = state[id].domain;
if (!counts[d]) counts[d] = { total: 0, yes: 0, no: 0 };
counts[d].total++;
if (state[id].status === 'yes') counts[d].yes++;
if (state[id].status === 'no') counts[d].no++;
});
Object.keys(counts).forEach(function(domain) {
var badge = document.getElementById('badge-' + safeId(domain));
if (badge) {
var c = counts[domain];
var assessed = c.yes + c.no;
badge.textContent = assessed > 0
? c.yes + '✓ ' + c.no + '✗ / ' + c.total
: c.total + ' items';
}
});
}
// All Yes
allYesBtn.addEventListener('click', function() {
Object.keys(state).forEach(function(id) {
state[id].status = 'yes';
var sid = safeId(id);
var item = document.getElementById('item-' + sid);
if (item) {
item.querySelector('[data-action="yes"]').classList.add('yes-on');
item.querySelector('[data-action="no"]').classList.remove('no-on');
item.classList.add('is-yes');
item.classList.remove('is-no');
}
});
updateBadges();
showToast('All marked as achieved', 'success');
});
// Clear All
allClearBtn.addEventListener('click', function() {
Object.keys(state).forEach(function(id) {
state[id].status = null;
var sid = safeId(id);
var item = document.getElementById('item-' + sid);
if (item) {
item.querySelector('[data-action="yes"]').classList.remove('yes-on');
item.querySelector('[data-action="no"]').classList.remove('no-on');
item.classList.remove('is-yes', 'is-no');
}
});
updateBadges();
outputCard.classList.add('hidden');
showToast('All cleared', 'info');
});
// GENERATE NARRATIVE
// Sends ONLY assessed milestones to AI
// AI converts them into professional narrative sentences
generateBtn.addEventListener('click', function() {
var ageGroup = ageSelect.value;
if (!ageGroup) {
showToast('Select an age group first', 'error');
return;
}
// Collect all milestone data (AI will only use yes/no, ignore null)
var allMilestones = Object.keys(state).map(function(id) {
return state[id];
});
var assessed = allMilestones.filter(function(m) { return m.status !== null; });
if (assessed.length === 0) {
showToast('Please assess at least one milestone', 'error');
return;
}
showLoading('AI is generating developmental narrative...');
fetch('/api/generate-milestone-narrative', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
milestones: allMilestones,
ageGroup: ageGroup,
patientAge: document.getElementById('ms-patient-age').value,
patientGender: document.getElementById('ms-patient-gender').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
narrativeText.textContent = data.narrative;
modelUsed.textContent = (data.model || '').split('/').pop();
// Summary bar
if (data.summary) {
summaryBar.innerHTML =
'<div class="stat"><span class="dot dot-green"></span> Achieved: ' + data.summary.achieved + '</div>' +
'<div class="stat"><span class="dot dot-red"></span> Not Yet: ' + data.summary.notAchieved + '</div>' +
'<div class="stat"><span class="dot dot-gray"></span> Not Assessed: ' + data.summary.notAssessed + ' (omitted)</div>';
summaryBar.classList.remove('hidden');
}
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Developmental narrative generated!', 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
});
});
console.log('✅ Milestones module loaded (separate from HPI)');
// ============================================================
// 3-SENTENCE SUMMARY GENERATOR
// Takes the full narrative and condenses it
// ============================================================
var quickSummaryBtn = document.getElementById('ms-quick-summary-btn');
var quickSummaryDiv = document.getElementById('ms-quick-summary');
var summaryText = document.getElementById('ms-summary-text');
var summaryModelUsed = document.getElementById('ms-summary-model-used');
quickSummaryBtn.addEventListener('click', function() {
var fullNarrative = narrativeText.innerText.trim();
if (!fullNarrative) {
showToast('Generate the full narrative first', 'error');
return;
}
// Disable button and show loading state
quickSummaryBtn.disabled = true;
quickSummaryBtn.classList.add('loading');
var originalHTML = quickSummaryBtn.innerHTML;
quickSummaryBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Summarizing...';
fetch('/api/generate-milestone-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
narrative: fullNarrative,
ageGroup: ageSelect.value,
patientAge: document.getElementById('ms-patient-age').value,
patientGender: document.getElementById('ms-patient-gender').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
quickSummaryBtn.disabled = false;
quickSummaryBtn.classList.remove('loading');
quickSummaryBtn.innerHTML = '<i class="fas fa-compress-alt"></i> Regenerate Summary';
if (data.success) {
summaryText.textContent = data.summary;
summaryModelUsed.textContent = (data.model || '').split('/').pop();
quickSummaryDiv.classList.remove('hidden');
quickSummaryDiv.scrollIntoView({ behavior: 'smooth' });
showToast('3-sentence summary generated!', 'success');
} else {
quickSummaryBtn.innerHTML = originalHTML;
showToast(data.error || 'Summary failed', 'error');
}
})
.catch(function(err) {
quickSummaryBtn.disabled = false;
quickSummaryBtn.classList.remove('loading');
quickSummaryBtn.innerHTML = originalHTML;
showToast('Error: ' + err.message, 'error');
});
});
console.log('✅ 3-sentence summary feature loaded');
})();

445
public/js/milestonesData.js Normal file
View file

@ -0,0 +1,445 @@
// ============================================================
// DEVELOPMENTAL MILESTONES DATA
// Sources: AAP Bright Futures, Nelson Textbook of Pediatrics
// THIS MODULE IS COMPLETELY SEPARATE FROM HPI
// ============================================================
var MILESTONES_DATA = {
"2 months": {
"Gross Motor": [
"Lifts head when prone (45 degrees)",
"Holds head steady when held upright briefly",
"Moves both arms and both legs",
"Pushes up on tummy"
],
"Fine Motor": [
"Opens hands briefly",
"Holds rattle if placed in hand briefly",
"Brings hands to midline"
],
"Language": [
"Coos and makes gurgling sounds",
"Makes sounds other than crying",
"Turns head toward sounds"
],
"Social/Emotional": [
"Social smile (responsive to faces)",
"Begins to self-soothe (hands to mouth)",
"Tries to look at parent",
"Calms when spoken to or picked up"
],
"Cognitive": [
"Pays attention to faces",
"Begins to follow things with eyes",
"Recognizes people at a distance"
]
},
"4 months": {
"Gross Motor": [
"Holds head steady unsupported",
"Pushes up to elbows when on tummy",
"May roll over tummy to back",
"Pushes down on legs when feet on hard surface"
],
"Fine Motor": [
"Reaches for toys with one hand",
"Uses hands and eyes together",
"Grasps and shakes hand toys"
],
"Language": [
"Babbles with expression and copies sounds",
"Cries differently for hunger pain and tiredness",
"Makes vowel sounds ah eh oh"
],
"Social/Emotional": [
"Smiles spontaneously especially at people",
"Likes to play and may cry when playing stops",
"Copies movements and facial expressions"
],
"Cognitive": [
"Lets you know if happy or sad",
"Follows moving things with eyes side to side",
"Watches faces closely",
"Recognizes familiar people at a distance"
]
},
"6 months": {
"Gross Motor": [
"Rolls over in both directions",
"Begins to sit without support",
"Supports weight on legs and might bounce",
"Rocks back and forth on hands and knees"
],
"Fine Motor": [
"Reaches for and grasps objects",
"Transfers objects hand to hand",
"Raking grasp to pick up objects",
"Brings things to mouth"
],
"Language": [
"Responds to own name",
"Responds to sounds by making sounds",
"Strings vowels together when babbling",
"Begins consonant sounds m and b",
"Makes sounds to show joy and displeasure"
],
"Social/Emotional": [
"Knows familiar faces and recognizes strangers",
"Likes to play with others especially parents",
"Responds to other peoples emotions",
"Likes to look at self in mirror"
],
"Cognitive": [
"Looks around at things nearby",
"Shows curiosity and tries to get things out of reach",
"Begins to pass things from one hand to the other"
]
},
"9 months": {
"Gross Motor": [
"Stands holding on to support",
"Can get into sitting position",
"Sits without support",
"Pulls to stand",
"Crawls"
],
"Fine Motor": [
"Pincer grasp developing (thumb and index finger)",
"Transfers objects smoothly between hands",
"Picks up small objects like cereal pieces",
"Bangs two objects together"
],
"Language": [
"Understands no",
"Makes many different sounds like mamamama bababababa",
"Copies sounds and gestures of others",
"Uses fingers to point at things"
],
"Social/Emotional": [
"May be afraid of strangers (stranger anxiety)",
"May be clingy with familiar adults (separation anxiety)",
"Has favorite toys",
"Plays peek-a-boo"
],
"Cognitive": [
"Watches the path of something as it falls",
"Looks for hidden things (object permanence developing)",
"Puts things in mouth to explore",
"Moves things smoothly between hands"
]
},
"12 months": {
"Gross Motor": [
"Pulls up to stand",
"Walks holding on to furniture (cruising)",
"May take a few steps without holding on",
"May stand alone"
],
"Fine Motor": [
"Neat pincer grasp (thumb and forefinger)",
"Puts things in a container",
"Takes things out of a container",
"Lets go of things without help",
"Pokes with index finger"
],
"Language": [
"Says mama and dada with meaning",
"Responds to simple spoken requests",
"Uses simple gestures like waving bye-bye",
"Says one to two other words",
"Tries to say words you say"
],
"Social/Emotional": [
"Is shy or nervous with strangers",
"Cries when mom or dad leaves",
"Has favorite things and people",
"Shows fear in some situations",
"Hands you a book to hear a story"
],
"Cognitive": [
"Explores things by shaking banging throwing",
"Finds hidden things easily",
"Looks at right picture when it is named",
"Copies gestures",
"Starts to use things correctly like drinking from cup"
]
},
"15 months": {
"Gross Motor": [
"Walks independently",
"May walk up steps with help",
"Stoops to pick up toy and stands back up"
],
"Fine Motor": [
"Stacks two blocks",
"Scribbles with crayon",
"Drinks from cup with some spilling",
"Uses fingers to eat"
],
"Language": [
"Says three to five words",
"Follows one-step commands without gestures",
"Points to show something interesting",
"Points to ask for something"
],
"Social/Emotional": [
"Copies other children while playing",
"Shows affection to familiar people",
"Claps when excited",
"Shows you objects they like"
],
"Cognitive": [
"Tries to use things the right way (phone cup book)",
"Stacks at least two small objects",
"Follows one-step directions without gestures"
]
},
"18 months": {
"Gross Motor": [
"Walks well independently",
"Runs stiffly",
"Walks up steps with hand held",
"Pulls toys while walking",
"Climbs on and off furniture without help"
],
"Fine Motor": [
"Scribbles spontaneously",
"Stacks three to four blocks",
"Drinks from cup",
"Eats with spoon (messy)",
"Turns book pages two to three at a time"
],
"Language": [
"Says ten to twenty or more single words",
"Says and shakes head no",
"Points to show someone what they want",
"Points to one body part when asked",
"Follows one-step verbal commands without gestures"
],
"Social/Emotional": [
"Likes to hand things to others as play",
"May have temper tantrums",
"Shows affection to familiar people",
"Plays simple pretend like feeding a doll",
"May cling to caregivers in new situations"
],
"Cognitive": [
"Knows what ordinary things are for (phone brush spoon)",
"Points to get attention of others",
"Shows interest in doll by pretending to feed",
"Points to one body part when asked",
"Scribbles on own"
]
},
"24 months": {
"Gross Motor": [
"Kicks a ball",
"Runs",
"Walks up and down stairs holding on",
"Stands on tiptoe",
"Throws ball overhand",
"Begins to jump with both feet"
],
"Fine Motor": [
"Stacks six or more blocks",
"Turns book pages one at a time",
"Turns door handles",
"Copies a straight line",
"Eats with spoon well"
],
"Language": [
"Points to things in book when asked",
"Says sentences with two to four words",
"Follows simple two-step instructions",
"Knows names of familiar people and body parts",
"Uses more than fifty words",
"Strangers understand about half of speech"
],
"Social/Emotional": [
"Copies others especially adults and older children",
"Gets excited with other children",
"Shows increasing independence",
"Plays mainly beside other children (parallel play)",
"Shows defiant behavior"
],
"Cognitive": [
"Finds things hidden under two to three covers",
"Begins to sort shapes and colors",
"Plays simple make-believe",
"Builds tower of four or more blocks",
"Follows two-step instructions"
]
},
"30 months": {
"Gross Motor": [
"Jumps with both feet off the ground",
"Walks up stairs alternating feet with rail",
"Runs well without falling",
"Kicks ball forward"
],
"Fine Motor": [
"Stacks eight or more blocks",
"Copies a vertical line",
"Turns pages one at a time",
"Strings large beads"
],
"Language": [
"Uses two to three word phrases routinely",
"Uses plurals (dogs cats)",
"Knows at least one color",
"Uses I me you",
"Vocabulary over 200 words"
],
"Social/Emotional": [
"Plays alongside progressing to interactive play",
"Shows concern for crying friend",
"Shows wide range of emotions",
"Takes turns in games with help"
],
"Cognitive": [
"Plays make-believe with dolls animals people",
"Does puzzles with three to four pieces",
"Understands concept of two",
"Copies a circle by age 3"
]
},
"36 months": {
"Gross Motor": [
"Climbs well",
"Runs easily",
"Pedals a tricycle",
"Walks up and down stairs one foot per step",
"Jumps forward"
],
"Fine Motor": [
"Copies a circle",
"Builds towers of more than nine blocks",
"Turns rotating handles like doorknobs",
"Uses scissors to snip"
],
"Language": [
"Carries on conversation using two to three sentences",
"Names most familiar things",
"Understands in on under",
"Says first name age and sex",
"Talks well enough for strangers to understand most of the time",
"Follows two to three step instructions"
],
"Social/Emotional": [
"Copies adults and friends",
"Takes turns in games",
"Shows affection for friends without prompting",
"Understands mine and his or hers",
"Separates easily from parents"
],
"Cognitive": [
"Works toys with buttons levers and moving parts",
"Plays make-believe with dolls animals people",
"Does puzzles with three to four pieces",
"Understands what two means",
"Copies a circle with pencil or crayon"
]
},
"48 months": {
"Gross Motor": [
"Hops on one foot",
"Catches a bounced ball most of the time",
"Walks up and down stairs without support",
"Kicks ball forward with force",
"Stands on one foot for two or more seconds"
],
"Fine Motor": [
"Draws a person with two to four body parts",
"Copies a cross shape",
"Uses scissors to cut along a line",
"Prints some capital letters",
"Stacks ten or more blocks"
],
"Language": [
"Knows some basic rules of grammar like he and she",
"Sings a song or says a poem from memory",
"Tells stories",
"Says first and last name",
"Understands same and different",
"Uses sentences with four or more words",
"Speech is fully intelligible to strangers"
],
"Social/Emotional": [
"Enjoys doing new things",
"Plays mom and dad (role playing)",
"Is creative with make-believe play",
"Would rather play with others than alone",
"Cooperates with other children"
],
"Cognitive": [
"Names some colors and numbers",
"Understands the idea of counting",
"Starts to understand time concepts",
"Remembers parts of a story",
"Understands same and different",
"Draws a person with two to four body parts"
]
},
"60 months": {
"Gross Motor": [
"Stands on one foot for ten or more seconds",
"Hops and may skip",
"Can do a somersault",
"Swings and climbs",
"Catches a small ball using hands only"
],
"Fine Motor": [
"Copies a triangle and other shapes",
"Draws a person with at least six body parts",
"Prints some letters and numbers",
"Uses fork and spoon well and may use knife",
"Can dress and undress independently"
],
"Language": [
"Speaks very clearly",
"Tells a simple story using full sentences",
"Uses future tense",
"Says name and address",
"Counts ten or more objects",
"Uses five to eight word sentences"
],
"Social/Emotional": [
"Wants to please friends",
"Wants to be like friends",
"More likely to agree with rules",
"Likes to sing dance and act",
"Aware of gender",
"Can tell real from make-believe"
],
"Cognitive": [
"Counts ten or more objects",
"Names at least four colors correctly",
"Better understands time concepts",
"Knows about everyday things (money food)",
"Draws a person with at least six body parts",
"Prints some letters or numbers"
]
}
};
var DOMAIN_CONFIG = {
"Gross Motor": { icon: "🏃", css: "domain-gross-motor" },
"Fine Motor": { icon: "✋", css: "domain-fine-motor" },
"Language": { icon: "🗣️", css: "domain-language" },
"Social/Emotional": { icon: "🤝", css: "domain-social" },
"Cognitive": { icon: "🧠", css: "domain-cognitive" }
};
console.log('✅ Milestones data loaded:', Object.keys(MILESTONES_DATA).length, 'age groups');

146
public/js/voiceDictation.js Normal file
View file

@ -0,0 +1,146 @@
// ============================================================
// MODULE 2: VOICE DICTATION → HPI (separate from milestones)
// ============================================================
(function() {
var recordBtn = document.getElementById('dict-record-btn');
var indicator = document.getElementById('dict-recording-indicator');
var timerEl = document.getElementById('dict-timer');
var transcript = document.getElementById('dict-transcript');
var clearBtn = document.getElementById('dict-clear-transcript');
var generateBtn = document.getElementById('dict-generate-btn');
var outputCard = document.getElementById('dict-output');
var hpiText = document.getElementById('dict-hpi-text');
var modelUsed = document.getElementById('dict-model-used');
var recorder = new AudioRecorder();
var timer = createTimer(timerEl);
var isRecording = false;
var recognition = null;
var finalText = '';
if (window.SpeechRecognition || window.webkitSpeechRecognition) {
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SR();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'en-US';
recognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalText += e.results[i][0].transcript + ' ';
} else {
interim = e.results[i][0].transcript;
}
}
transcript.innerHTML = finalText +
(interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
};
recognition.onend = function() {
if (isRecording) { try { recognition.start(); } catch(e) {} }
};
recognition.onerror = function() {};
}
recordBtn.addEventListener('click', function() {
if (!isRecording) {
finalText = '';
recorder.start().then(function() {
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.querySelector('span').textContent = 'Stop Dictation';
indicator.classList.remove('hidden');
timer.start();
if (recognition) { try { recognition.start(); } catch(e) {} }
showToast('Dictation started', 'info');
}).catch(function() {
showToast('Microphone access denied', 'error');
});
} else {
isRecording = false;
var duration = timer.stop();
recordBtn.classList.remove('recording');
recordBtn.querySelector('span').textContent = 'Start Dictation';
indicator.classList.add('hidden');
if (recognition) { try { recognition.stop(); } catch(e) {} }
showLoading('Transcribing dictation...');
recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) {
hideLoading();
if (finalText.trim()) transcript.textContent = finalText.trim();
return;
}
var formData = new FormData();
formData.append('audio', blob, 'dictation.webm');
return fetch('/api/transcribe', { method: 'POST', body: formData })
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
transcript.textContent = data.text;
showToast('Dictation transcribed (' + duration + 's)', 'success');
} else {
if (finalText.trim()) transcript.textContent = finalText.trim();
showToast(data.error || 'Transcription failed', 'error');
}
});
}).catch(function(err) {
hideLoading();
if (finalText.trim()) transcript.textContent = finalText.trim();
showToast('Error: ' + err.message, 'error');
});
}
});
clearBtn.addEventListener('click', function() {
transcript.textContent = '';
finalText = '';
outputCard.classList.add('hidden');
});
generateBtn.addEventListener('click', function() {
var text = transcript.innerText.trim();
if (!text) {
showToast('No dictation to process', 'error');
return;
}
showLoading('Generating polished HPI...');
fetch('/api/generate-hpi-dictation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript: text,
patientAge: document.getElementById('dict-patient-age').value,
patientGender: document.getElementById('dict-patient-gender').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
hpiText.textContent = data.hpi;
modelUsed.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Polished HPI generated!', 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
});
});
console.log('✅ Voice Dictation module loaded');
})();

499
server.js Normal file
View file

@ -0,0 +1,499 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const { OpenAI } = require('openai');
const http = require('http');
const path = require('path');
const axios = require('axios');
const app = express();
const server = http.createServer(app);
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024 }
});
// ============================================================
// API CLIENTS
// ============================================================
// OpenRouter for text generation
const openrouter = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY,
defaultHeaders: {
'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000',
'X-Title': 'Pediatric AI Scribe'
}
});
// OpenAI for Whisper transcription only
let whisperClient = null;
if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-')) {
whisperClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
console.log('✅ Whisper transcription: configured');
} else {
console.log('⚠️ Whisper transcription: not configured (set OPENAI_API_KEY)');
}
// ============================================================
// MODEL CONFIG
// ============================================================
const DEFAULT_MODEL = 'google/gemini-2.5-flash';
const FALLBACK_MODEL = 'deepseek/deepseek-chat-v3-0324';
// ============================================================
// HELPER: Call AI with retry and fallback
// ============================================================
async function callAI(messages, options = {}) {
const model = options.model || DEFAULT_MODEL;
const temperature = options.temperature || 0.3;
const maxTokens = options.maxTokens || 2000;
// Attempt primary model
try {
console.log(`[AI] Calling ${model}...`);
const completion = await openrouter.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
const content = completion.choices[0].message.content;
console.log(`[AI] Success. Output: ${content.length} chars`);
return {
success: true,
content: content,
model: model,
usage: completion.usage || null
};
} catch (err) {
console.error(`[AI] Primary model failed: ${err.message}`);
}
// Attempt fallback
if (model !== FALLBACK_MODEL) {
try {
console.log(`[AI] Trying fallback: ${FALLBACK_MODEL}...`);
const completion = await openrouter.chat.completions.create({
model: FALLBACK_MODEL,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
const content = completion.choices[0].message.content;
console.log(`[AI] Fallback success. Output: ${content.length} chars`);
return {
success: true,
content: content,
model: FALLBACK_MODEL,
usedFallback: true,
usage: completion.usage || null
};
} catch (err2) {
console.error(`[AI] Fallback also failed: ${err2.message}`);
throw new Error(`AI generation failed: ${err2.message}`);
}
}
throw new Error('AI generation failed on all models');
}
// ============================================================
// ROUTE: Health check
// ============================================================
app.get('/api/health', (req, res) => {
res.json({
status: 'running',
timestamp: new Date().toISOString(),
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
whisper: whisperClient ? 'configured' : 'not configured',
elevenlabs: process.env.ELEVENLABS_API_KEY ? 'configured' : 'not configured',
defaultModel: DEFAULT_MODEL
});
});
// ============================================================
// ROUTE: Get available models
// ============================================================
app.get('/api/models', (req, res) => {
res.json({
current: DEFAULT_MODEL,
models: [
{ id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001/call', tag: 'BEST VALUE' },
{ id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking', cost: '~$0.002/call', tag: 'SMART' },
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', cost: '~$0.001/call', tag: 'CHEAP' },
{ id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1', cost: 'FREE', tag: 'FREE' },
{ id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B', cost: 'FREE', tag: 'FREE' },
{ id: 'meta-llama/llama-3.1-70b-instruct', name: 'Llama 3.1 70B', cost: '~$0.001/call', tag: 'GOOD' },
{ id: 'openai/gpt-4o', name: 'GPT-4o', cost: '~$0.01/call', tag: 'PREMIUM' },
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', cost: '~$0.015/call', tag: 'PREMIUM' }
]
});
});
// ============================================================
// ROUTE: Transcribe audio with Whisper
// ============================================================
app.post('/api/transcribe', upload.single('audio'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file uploaded' });
}
if (!whisperClient) {
return res.status(400).json({
error: 'Whisper not configured. Add OPENAI_API_KEY to .env file.'
});
}
console.log(`[Whisper] Transcribing ${req.file.size} bytes...`);
const file = new File([req.file.buffer], 'recording.webm', {
type: req.file.mimetype || 'audio/webm'
});
const result = await whisperClient.audio.transcriptions.create({
file: file,
model: 'whisper-1',
language: 'en',
prompt: 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications, symptoms.'
});
console.log(`[Whisper] Done: "${result.text.substring(0, 80)}..."`);
res.json({ success: true, text: result.text });
} catch (err) {
console.error('[Whisper] Error:', err.message);
res.status(500).json({ error: 'Transcription failed', details: err.message });
}
});
// ============================================================
// ROUTE: Generate HPI from encounter transcript
// ============================================================
app.post('/api/generate-hpi-encounter', async (req, res) => {
try {
const { transcript, patientAge, patientGender, model } = req.body;
if (!transcript || transcript.trim().length === 0) {
return res.status(400).json({ error: 'Transcript is empty' });
}
const messages = [
{
role: 'system',
content: `You are an expert medical scribe specializing in pediatrics.
Generate a professional History of Present Illness (HPI) from the doctor-patient encounter transcript below.
FORMAT RULES:
- Write in third person, past tense
- Use OLDCARTS: Onset, Location, Duration, Character, Aggravating/Alleviating, Radiation, Timing, Severity
- Include pertinent positives and negatives MENTIONED in the conversation
- Note the historian (parent, guardian) for pediatric patients
- Professional medical language
- Chronological flow
- Do NOT fabricate any information not present in the transcript
- Do NOT add a separate Review of Systems
- Be concise but thorough
- Output ONLY the HPI text, no headers or labels`
},
{
role: 'user',
content: `Patient: ${patientAge || 'Unknown age'}, ${patientGender || 'Unknown gender'}
TRANSCRIPT:
${transcript}`
}
];
const result = await callAI(messages, { model: model || DEFAULT_MODEL });
res.json({
success: true,
hpi: result.content,
model: result.model,
usage: result.usage
});
} catch (err) {
console.error('[HPI-Encounter] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// ROUTE: Generate HPI from physician dictation
// ============================================================
app.post('/api/generate-hpi-dictation', async (req, res) => {
try {
const { transcript, patientAge, patientGender, model } = req.body;
if (!transcript || transcript.trim().length === 0) {
return res.status(400).json({ error: 'Dictation is empty' });
}
const messages = [
{
role: 'system',
content: `You are an expert medical scribe. A physician has dictated a verbal summary of a patient encounter.
YOUR TASK: Restructure the dictation into a polished, professional HPI.
RULES:
- Reorganize logically and chronologically
- Convert casual speech into professional medical language
- Keep ALL clinical details the physician mentioned
- Remove filler words, repetitions, self-corrections
- Use OLDCARTS structure where data is available
- Write in third person
- Fix obvious speech-to-text errors in medical terminology
- Include pertinent positives and negatives as stated
- Do NOT add any information not in the dictation
- Standard medical abbreviations where appropriate
- Output ONLY the polished HPI text, no headers or labels`
},
{
role: 'user',
content: `Patient: ${patientAge || 'Unknown age'}, ${patientGender || 'Unknown gender'}
PHYSICIAN DICTATION:
${transcript}`
}
];
const result = await callAI(messages, { model: model || DEFAULT_MODEL });
res.json({
success: true,
hpi: result.content,
model: result.model,
usage: result.usage
});
} catch (err) {
console.error('[HPI-Dictation] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// ROUTE: Generate developmental milestone narrative
// THIS IS COMPLETELY SEPARATE FROM HPI
// ============================================================
app.post('/api/generate-milestone-narrative', async (req, res) => {
try {
const { milestones, ageGroup, patientAge, patientGender, model } = req.body;
if (!milestones) {
return res.status(400).json({ error: 'No milestones data' });
}
// CRITICAL: Only send assessed milestones to AI
const achieved = milestones.filter(m => m.status === 'yes');
const notAchieved = milestones.filter(m => m.status === 'no');
const notAssessed = milestones.filter(m => m.status === null);
// If nothing was assessed at all
if (achieved.length === 0 && notAchieved.length === 0) {
return res.json({
success: true,
narrative: 'No developmental milestones were assessed during this visit.',
summary: { achieved: 0, notAchieved: 0, notAssessed: notAssessed.length }
});
}
// Build the milestone list for AI - ONLY assessed items
let milestoneText = '';
if (achieved.length > 0) {
milestoneText += 'ACHIEVED (Yes):\n';
achieved.forEach(m => {
milestoneText += ` - [${m.domain}] ${m.milestone}\n`;
});
}
if (notAchieved.length > 0) {
milestoneText += '\nNOT YET ACHIEVED (No):\n';
notAchieved.forEach(m => {
milestoneText += ` - [${m.domain}] ${m.milestone}\n`;
});
}
const messages = [
{
role: 'system',
content: `You are a pediatric documentation specialist. Generate a professional developmental assessment narrative.
ABSOLUTE RULES - FOLLOW STRICTLY:
1. ONLY write about milestones listed below (marked Yes or No)
2. Milestones NOT listed were NOT ASSESSED - do NOT mention them AT ALL
3. Do NOT say "patient cannot do X" for any milestone not listed
4. Do NOT speculate about any abilities not explicitly assessed
5. Group findings by domain: Gross Motor, Fine Motor, Language, Social/Emotional, Cognitive
6. Write as flowing medical narrative paragraphs, NOT bullet points
7. Start with a one-sentence summary of overall developmental status
8. For achieved milestones: state the child "is able to" or "demonstrates"
9. For not-achieved milestones: state the child "has not yet achieved" or "is not yet demonstrating"
10. End with a brief overall impression
11. Use professional medical language
12. Output ONLY the narrative, no headers like "Developmental Assessment:"`
},
{
role: 'user',
content: `Patient: ${patientAge || 'Unknown age'}, ${patientGender || 'Unknown gender'}
Milestone Age Group: ${ageGroup}
${milestoneText}
Total assessed: ${achieved.length + notAchieved.length}
Achieved: ${achieved.length}
Not yet achieved: ${notAchieved.length}
Not assessed (DO NOT MENTION THESE): ${notAssessed.length}
Generate the developmental narrative now.`
}
];
const result = await callAI(messages, { model: model || DEFAULT_MODEL });
res.json({
success: true,
narrative: result.content,
model: result.model,
summary: {
achieved: achieved.length,
notAchieved: notAchieved.length,
notAssessed: notAssessed.length,
totalAssessed: achieved.length + notAchieved.length
},
usage: result.usage
});
} catch (err) {
console.error('[Milestones] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// ROUTE: Generate 3-sentence summary from milestone narrative
// ============================================================
app.post('/api/generate-milestone-summary', async (req, res) => {
try {
const { narrative, ageGroup, patientAge, patientGender, model } = req.body;
if (!narrative || narrative.trim().length === 0) {
return res.status(400).json({ error: 'No narrative provided' });
}
const messages = [
{
role: 'system',
content: `You are a pediatric documentation specialist.
You will receive a detailed developmental milestone narrative.
Condense it into EXACTLY 3 sentences:
Sentence 1: Overall developmental status statement (e.g., "This X-month-old demonstrates age-appropriate development in most domains." or "This child shows mixed developmental progress with some delays noted.")
Sentence 2: Key strengths which domains are on track and notable achievements.
Sentence 3: Any concerns which milestones are delayed or not yet achieved. If everything is on track, state that no developmental concerns were identified.
RULES:
- EXACTLY 3 sentences, no more, no less
- Professional medical language
- Do NOT mention milestones that were not assessed
- Be specific but concise
- Output ONLY the 3 sentences, nothing else`
},
{
role: 'user',
content: `Patient: ${patientAge || 'Unknown age'}, ${patientGender || 'Unknown gender'}
Age Group: ${ageGroup || 'Not specified'}
FULL DEVELOPMENTAL NARRATIVE:
${narrative}
Generate the 3-sentence summary now.`
}
];
const result = await callAI(messages, {
model: model || DEFAULT_MODEL,
temperature: 0.2,
maxTokens: 500
});
res.json({
success: true,
summary: result.content,
model: result.model,
usage: result.usage
});
} catch (err) {
console.error('[Milestone-Summary] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// ROUTE: Text-to-Speech (optional ElevenLabs)
// ============================================================
app.post('/api/text-to-speech', async (req, res) => {
try {
if (!process.env.ELEVENLABS_API_KEY) {
return res.status(400).json({ error: 'ElevenLabs not configured' });
}
const { text } = req.body;
const response = await axios({
method: 'POST',
url: 'https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM',
headers: {
'xi-api-key': process.env.ELEVENLABS_API_KEY,
'Content-Type': 'application/json'
},
data: {
text: text.substring(0, 5000),
model_id: 'eleven_monolingual_v1',
voice_settings: { stability: 0.5, similarity_boost: 0.75 }
},
responseType: 'arraybuffer'
});
res.set('Content-Type', 'audio/mpeg');
res.send(Buffer.from(response.data));
} catch (err) {
res.status(500).json({ error: 'TTS failed' });
}
});
// Serve frontend
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Start
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log('');
console.log('========================================');
console.log('🏥 PEDIATRIC AI SCRIBE');
console.log('========================================');
console.log(`🌐 URL: http://localhost:${PORT}`);
console.log(`🤖 Model: ${DEFAULT_MODEL}`);
console.log(`🎙️ Whisper: ${whisperClient ? 'Ready' : 'Not configured'}`);
console.log('========================================');
console.log('');
});