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:
commit
993e46f720
15 changed files with 2886 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.env.local
|
||||
8
.env.example
Normal file
8
.env.example
Normal 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
28
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
38
README.md
Normal 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
18
docker-compose.yml
Normal 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
18
package.json
Normal 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
593
public/css/styles.css
Normal 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
327
public/index.html
Normal 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
292
public/js/app.js
Normal 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
154
public/js/liveEncounter.js
Normal 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
301
public/js/milestones.js
Normal 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
445
public/js/milestonesData.js
Normal 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
146
public/js/voiceDictation.js
Normal 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
499
server.js
Normal 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('');
|
||||
});
|
||||
Loading…
Reference in a new issue