v2.0.0: Pediatric AI Scribe

Features:
- Live encounter recording → HPI (outpatient/inpatient)
- Voice dictation → HPI / SOAP note
- Hospital course generator (prose/day-by-day/organ system/psych)
- Chart review / precharting (outpatient/subspecialty/ED)
- SOAP note generator (full/subjective only)
- Developmental milestones (AAP/Nelson) with narrative + 3-sentence summary
- AI refine & shorten for all outputs
- Ask AI what's missing (clarification)
- Authentication (email/password, email verification, 2FA)
- Nextcloud integration with auto date folders
- PWA support (installable on phone)
- 18+ AI models via OpenRouter
- HIPAA compliance guidance
- Docker support
This commit is contained in:
ifedan-ed 2026-03-21 16:55:50 -04:00
commit 1c2098d1dd
35 changed files with 4323 additions and 0 deletions

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# AI APIs (required)
OPENROUTER_API_KEY=sk-or-v1-your-key
OPENAI_API_KEY=sk-your-openai-key
# Optional
ELEVENLABS_API_KEY=
# App
PORT=3000
APP_URL=https://your-domain.com
JWT_SECRET=generate-a-random-64-char-string-here
SESSION_SECRET=generate-another-random-string-here
# Email (for verification & password reset)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
# Nextcloud (optional)
NEXTCLOUD_URL=https://cloud.yourdomain.com

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
node_modules/
.env
.env.local
.env.production
data/
*.db
*.db-journal
*.db-wal
.DS_Store
Thumbs.db
*.log
npm-debug.log*
.vscode/
.idea/
*.swp
dist/
build/

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:20-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
RUN mkdir -p /app/data
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
services:
pediatric-scribe:
image: danielonyejesi/pediatric-ai-scribe:latest
build: .
ports:
- "3552:3000"
env_file:
- .env
volumes:
- scribe-data:/app/data
restart: unless-stopped
container_name: pediatric-ai-scribe
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 15s
volumes:
scribe-data:

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "pediatric-ai-scribe",
"version": "2.0.0",
"description": "AI-powered pediatric clinical documentation platform",
"main": "server.js",
"scripts": {
"start": "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",
"better-sqlite3": "^11.6.0",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"speakeasy": "^2.0.0",
"qrcode": "^1.5.4",
"nodemailer": "^6.9.16",
"crypto": "^1.0.1",
"@simplewebauthn/server": "^10.0.0",
"cookie-parser": "^1.4.7",
"helmet": "^8.0.0",
"express-rate-limit": "^7.4.0"
}
}

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

@ -0,0 +1,244 @@
: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;}
/* AUTH */
.auth-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%);padding:20px;}
.auth-box{background:white;border-radius:16px;padding:40px;max-width:440px;width:100%;box-shadow:0 20px 40px rgba(0,0,0,0.2);}
.auth-logo{text-align:center;margin-bottom:30px;}
.auth-logo i{font-size:40px;color:var(--blue);margin-bottom:10px;}
.auth-logo h1{font-size:24px;color:var(--g900);}
.auth-logo p{color:var(--g500);font-size:14px;}
.auth-form h2{font-size:20px;margin-bottom:20px;color:var(--g800);}
.form-group{margin-bottom:16px;}
.form-group label{display:block;font-size:13px;font-weight:600;color:var(--g600);margin-bottom:4px;}
.form-group input{width:100%;padding:10px 14px;border:1.5px solid var(--g300);border-radius:8px;font-size:14px;font-family:inherit;}
.form-group input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
.form-group small{color:var(--g500);font-size:12px;}
.btn-auth{width:100%;padding:12px;background:var(--blue);color:white;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:background 0.2s;}
.btn-auth:hover{background:var(--blue-dark);}
.auth-links{text-align:center;margin-top:16px;font-size:13px;}
.auth-links a{color:var(--blue);text-decoration:none;margin:0 8px;}
.hipaa-notice{margin-top:24px;padding:12px;background:var(--amber-light);border-radius:8px;font-size:12px;color:#92400e;display:flex;align-items:flex-start;gap:8px;}
.hipaa-notice i{margin-top:2px;flex-shrink:0;}
/* HEADER */
.app-header{background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%);color:white;padding:14px 24px;}
.header-content{max-width:1200px;margin:0 auto;}
.header-top{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;}
.logo{display:flex;align-items:center;gap:12px;}
.logo i{font-size:26px;}
.logo h1{font-size:20px;font-weight:700;}
.tagline{font-size:12px;opacity:0.8;}
.header-right{display:flex;align-items:center;gap:12px;}
.model-selector{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,0.15);padding:6px 12px;border-radius:8px;}
.model-selector label{font-size:12px;}
.model-selector select{padding:4px 8px;border:1px solid rgba(255,255,255,0.3);border-radius:6px;background:rgba(255,255,255,0.2);color:white;font-size:12px;font-family:inherit;max-width:220px;}
.model-selector select option{background:var(--g800);color:white;}
.cost-badge{font-size:10px;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;}
.header-buttons{display:flex;gap:6px;}
.btn-header{background:rgba(255,255,255,0.15);border:none;color:white;width:36px;height:36px;border-radius:8px;cursor:pointer;font-size:14px;transition:background 0.2s;}
.btn-header:hover{background:rgba(255,255,255,0.3);}
/* 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:6px;padding:12px 18px;border:none;border-bottom:3px solid transparent;background:none;color:var(--g500);font-size:13px;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:15px;}
/* MAIN */
.main-content{max-width:1200px;margin:0 auto;padding:20px 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{margin-bottom:16px;}
.module-header h2{font-size:18px;font-weight:700;display:flex;align-items:center;gap:8px;}
.module-header p{color:var(--g500);font-size:13px;margin-top:2px;}
/* DEMOGRAPHICS */
.demographics-bar{display:flex;gap:12px;flex-wrap:wrap;background:white;padding:12px 16px;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:12px;}
.demo-field{display:flex;flex-direction:column;gap:3px;}
.demo-field label{font-size:10px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.5px;}
.demo-field input,.demo-field select{padding:7px 10px;border:1.5px solid var(--g300);border-radius:8px;font-size:13px;font-family:inherit;min-width:130px;}
.demo-field input:focus,.demo-field select:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
.soap-options{display:flex;align-items:center;gap:10px;margin-bottom:12px;font-size:13px;font-weight:500;color:var(--g600);}
.soap-options select{padding:6px 10px;border:1.5px solid var(--g300);border-radius:8px;font-size:13px;font-family:inherit;}
/* CARDS */
.card{background:white;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:12px;overflow:hidden;}
.card-header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:var(--g50);border-bottom:1px solid var(--g200);}
.card-header h3{font-size:13px;font-weight:600;color:var(--g700);display:flex;align-items:center;gap:6px;}
/* EDITABLE BOX */
.editable-box{min-height:100px;max-height:280px;overflow-y:auto;padding:12px 16px;font-size:13px;line-height:1.8;outline:none;}
.editable-box:empty::before{content:attr(data-placeholder);color:var(--g400);}
/* NOTE ENTRY (hospital course, chart review) */
.note-entry{padding:10px 16px;}
.note-meta{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;}
.note-meta input,.note-meta select{padding:6px 10px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
.note-dictate{padding-top:6px;}
.lab-entry{display:flex;gap:8px;padding:6px 16px;align-items:center;flex-wrap:wrap;}
.lab-entry input{padding:6px 10px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;flex:1;min-width:120px;}
.full-input{width:100%;padding:10px 16px;border:none;font-size:13px;font-family:inherit;outline:none;border-top:1px solid var(--g200);}
.action-row{margin-bottom:12px;}
/* RECORDING */
.record-controls{display:flex;align-items:center;gap:14px;padding:14px 16px;}
.record-btn{display:flex;align-items:center;gap:8px;padding:10px 22px;background:var(--red);color:white;border:none;border-radius:50px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.2s;box-shadow:0 3px 10px rgba(239,68,68,0.3);}
.record-btn:hover{transform:translateY(-1px);}
.record-btn.recording{background:var(--g700);box-shadow:0 3px 10px rgba(0,0,0,0.2);}
.btn-purple{background:var(--purple);box-shadow:0 3px 10px rgba(124,58,237,0.3);}
.btn-teal{background:var(--teal);box-shadow:0 3px 10px rgba(8,145,178,0.3);}
.btn-recording{background:var(--red) !important;color:white !important;animation:btnpulse 1.5s infinite;}
.recording-indicator{display:flex;align-items:center;gap:6px;color:var(--red);font-weight:500;font-size:13px;}
.recording-indicator.hidden{display:none;}
.pulse-dot{width:10px;height:10px;background:var(--red);border-radius:50%;animation:pulse 1.5s infinite;}
.pulse-purple{background:var(--purple);}
.pulse-teal{background:var(--teal);}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(1.3);}}
@keyframes btnpulse{0%,100%{opacity:1;}50%{opacity:0.7;}}
/* BUTTONS */
.btn-generate{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:12px;background:var(--blue);color:white;border:none;border-radius:var(--radius);font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;margin-bottom:12px;transition:all 0.2s;box-shadow:0 3px 10px 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 3px 10px rgba(124,58,237,0.25);}
.btn-generate-teal{background:var(--teal);box-shadow:0 3px 10px rgba(8,145,178,0.25);}
.btn-generate-blue{background:var(--blue);box-shadow:0 3px 10px rgba(37,99,235,0.25);}
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;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;}
.btn-warning{background:var(--amber);color:white;}.btn-warning:hover{background:#d97706;}
.btn-reading{background:var(--red)!important;color:white!important;animation:btnpulse 1.5s infinite;}
/* 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:6px;flex-wrap:wrap;}
.output-text{padding:16px;font-size:13px;line-height:1.8;outline:none;min-height:80px;white-space:pre-wrap;}
.model-tag{font-size:10px;padding:2px 7px;background:var(--g200);border-radius:4px;color:var(--g600);}
/* REFINE BAR */
.refine-bar{display:flex;gap:6px;padding:10px 16px;border-top:1px solid var(--g200);background:var(--g50);align-items:center;flex-wrap:wrap;}
.refine-input{flex:1;min-width:200px;padding:7px 12px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
.refine-input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
/* CLARIFY BOX */
.clarify-box{padding:14px 16px;background:var(--amber-light);border-top:1px solid #fbbf24;font-size:13px;line-height:1.8;white-space:pre-wrap;color:#92400e;}
.clarify-box.hidden{display:none;}
/* MILESTONES */
.legend{display:flex;gap:16px;flex-wrap:wrap;padding:8px 14px;background:var(--teal-light);border-radius:var(--radius);margin-bottom:12px;font-size:12px;}
.legend-item{display:flex;align-items:center;gap:5px;}
.legend-icon{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border-radius:6px;font-size:12px;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:10px;overflow:hidden;}
.domain-header{padding:10px 16px;font-weight:700;font-size:13px;display:flex;align-items:center;gap:6px;}
.domain-header .badge{margin-left:auto;font-size:10px;font-weight:500;padding:2px 8px;border-radius:8px;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:4px 6px;}
.ms-item{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:6px;transition:background 0.15s;}
.ms-item:hover{background:var(--g50);}
.ms-toggle{display:flex;gap:3px;flex-shrink:0;}
.toggle-btn{width:30px;height:30px;display:flex;align-items:center;justify-content:center;border:2px solid var(--g300);border-radius:6px;background:white;cursor:pointer;font-size:14px;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:13px;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:10px;align-items:center;flex-wrap:wrap;margin:12px 0;}
.summary-bar{display:flex;gap:14px;padding:8px 16px;background:var(--g50);border-bottom:1px solid var(--g200);font-size:12px;}
.summary-bar.hidden{display:none;}
.stat{display:flex;align-items:center;gap:5px;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);}
.summary-section{border-top:2px dashed var(--g200);}
.summary-section-header{padding:12px 16px;}
.btn-generate-summary{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;background:linear-gradient(135deg,#f59e0b,#d97706);color:white;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.2s;}
.btn-generate-summary:hover{transform:translateY(-1px);}
.btn-generate-summary:disabled{background:var(--g300);cursor:not-allowed;transform:none;}
.summary-output-header{background:var(--amber-light);border-bottom:1px solid #fbbf24;border-top:1px solid var(--g200);}
.summary-output-text{padding:14px 16px;font-size:13px;line-height:1.8;outline:none;background:#fffbeb;font-weight:500;white-space:pre-wrap;}
/* MODAL */
.modal{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:5000;padding:20px;backdrop-filter:blur(3px);}
.modal.hidden{display:none;}
.modal-content{background:white;border-radius:16px;max-width:600px;width:100%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 40px rgba(0,0,0,0.2);}
.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;border-bottom:1px solid var(--g200);}
.modal-header h2{font-size:18px;display:flex;align-items:center;gap:8px;}
.modal-close{background:none;border:none;font-size:18px;color:var(--g500);cursor:pointer;padding:4px;}
.modal-body{padding:24px;}
.settings-section{margin-bottom:28px;}
.settings-section h3{font-size:15px;font-weight:600;margin-bottom:10px;display:flex;align-items:center;gap:8px;color:var(--g800);}
.settings-section p{font-size:13px;color:var(--g600);margin-bottom:8px;}
.settings-section .form-group{margin-bottom:12px;}
.settings-section img{max-width:200px;margin:12px 0;border:1px solid var(--g200);border-radius:8px;}
.settings-section code{background:var(--g100);padding:2px 8px;border-radius:4px;font-size:12px;}
.hipaa-info{font-size:13px;color:var(--g700);}
.hipaa-info ul{margin:8px 0 8px 20px;}
.hipaa-info li{margin-bottom:4px;}
/* 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:28px 44px;border-radius:var(--radius);text-align:center;box-shadow:var(--shadow-md);}
.spinner{width:32px;height:32px;border:4px solid var(--g200);border-top-color:var(--blue);border-radius:50%;animation:spin 0.7s linear infinite;margin:0 auto 12px;}
@keyframes spin{to{transform:rotate(360deg);}}
.loading-box p{color:var(--g600);font-weight:500;font-size:13px;}
/* TOAST */
.toast-container{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:6px;z-index:10000;}
.toast{padding:8px 16px;border-radius:8px;color:white;font-size:12px;font-weight:500;box-shadow:var(--shadow-md);animation:slideIn 0.3s ease;display:flex;align-items:center;gap:6px;}
.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;}
.header-right{width:100%;flex-wrap:wrap;}
.model-selector{flex:1;}
.model-selector select{max-width:100%;flex:1;}
.tab-btn span{display:none;}
.tab-btn{flex:1;justify-content:center;padding:10px;}
.tab-btn i{font-size:16px;}
.demographics-bar{flex-direction:column;}
.demo-field input,.demo-field select{min-width:100%;}
.note-meta{flex-direction:column;}
.note-meta input,.note-meta select{min-width:100%;}
.refine-bar{flex-direction:column;}
.refine-input{min-width:100%;}
.milestone-actions{flex-direction:column;}
.auth-box{padding:24px;}
}
.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;}

722
public/index.html Normal file
View file

@ -0,0 +1,722 @@
<!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">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="PedScribe">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
</head>
<body>
<!-- AUTH SCREEN -->
<div id="auth-screen" class="auth-screen">
<div class="auth-box">
<div class="auth-logo">
<i class="fas fa-stethoscope"></i>
<h1>Pediatric AI Scribe</h1>
<p>AI-Powered Clinical Documentation</p>
</div>
<!-- Login Form -->
<form id="login-form" class="auth-form">
<h2>Sign In</h2>
<div class="form-group">
<label>Email</label>
<input type="email" id="login-email" required placeholder="your@email.com">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="login-password" required placeholder="••••••••">
</div>
<div id="totp-group" class="form-group hidden">
<label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div>
<button type="submit" class="btn-auth">Sign In</button>
<div class="auth-links">
<a href="#" id="show-register">Create account</a>
<a href="#" id="show-forgot">Forgot password?</a>
</div>
</form>
<!-- Register Form -->
<form id="register-form" class="auth-form hidden">
<h2>Create Account</h2>
<div class="form-group">
<label>Full Name</label>
<input type="text" id="reg-name" required placeholder="Dr. Jane Smith">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="reg-email" required placeholder="your@email.com">
</div>
<div class="form-group">
<label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</div>
<button type="submit" class="btn-auth">Create Account</button>
<div class="auth-links">
<a href="#" id="show-login">Back to sign in</a>
</div>
</form>
<!-- Forgot Password Form -->
<form id="forgot-form" class="auth-form hidden">
<h2>Reset Password</h2>
<div class="form-group">
<label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com">
</div>
<button type="submit" class="btn-auth">Send Reset Link</button>
<div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a>
</div>
</form>
<div class="hipaa-notice">
<i class="fas fa-shield-alt"></i>
<span>HIPAA Notice: Do not enter real PHI unless your organization has a BAA with all AI providers used.</span>
</div>
</div>
</div>
<!-- MAIN APP (hidden until authenticated) -->
<div id="main-app" class="hidden">
<!-- 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">Welcome, <span id="user-name">Doctor</span></p>
</div>
</div>
<div class="header-right">
<div class="model-selector">
<label><i class="fas fa-robot"></i></label>
<select id="global-model-select"></select>
<span id="model-cost-badge" class="cost-badge"></span>
</div>
<div class="header-buttons">
<button id="btn-settings" class="btn-header" title="Settings">
<i class="fas fa-cog"></i>
</button>
<button id="btn-logout" class="btn-header" title="Logout">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</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>Encounter HPI</span>
</button>
<button class="tab-btn" data-tab="dictation">
<i class="fas fa-microphone"></i>
<span>Dictation HPI</span>
</button>
<button class="tab-btn" data-tab="hospital">
<i class="fas fa-hospital"></i>
<span>Hospital Course</span>
</button>
<button class="tab-btn" data-tab="chart">
<i class="fas fa-clipboard-list"></i>
<span>Chart Review</span>
</button>
<button class="tab-btn" data-tab="soap">
<i class="fas fa-file-medical-alt"></i>
<span>SOAP Note</span>
</button>
<button class="tab-btn" data-tab="milestones">
<i class="fas fa-baby"></i>
<span>Milestones</span>
</button>
</nav>
<main class="main-content">
<!-- ===== TAB: ENCOUNTER HPI ===== -->
<section id="encounter-tab" class="tab-content active">
<div class="module-header">
<h2><i class="fas fa-comments"></i> Live Encounter → HPI</h2>
<p>Record doctor-patient conversation → AI generates structured HPI</p>
</div>
<div class="demographics-bar">
<div class="demo-field">
<label>Patient Age</label>
<input type="text" id="enc-age" placeholder="e.g., 4 years">
</div>
<div class="demo-field">
<label>Gender</label>
<select id="enc-gender"><option value="">Select</option><option>Male</option><option>Female</option></select>
</div>
<div class="demo-field">
<label>Setting</label>
<select id="enc-setting">
<option value="outpatient">Outpatient</option>
<option value="inpatient">Inpatient/Floors</option>
<option value="ed">Emergency Dept</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" 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 or type/paste directly..."></div>
</div>
<button id="enc-generate-btn" class="btn-generate"><i class="fas fa-magic"></i> Generate HPI</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-tag" 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>
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('enc-hpi-text','hpi-encounter')"><i class="fas fa-cloud-upload-alt"></i></button>
</div>
</div>
<div id="enc-hpi-text" class="output-text" contenteditable="true"></div>
<div class="refine-bar">
<input type="text" id="enc-refine-input" class="refine-input" placeholder="Tell AI to modify (e.g., 'make it shorter', 'add that patient has asthma history')">
<button class="btn-sm btn-primary" id="enc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<button class="btn-sm btn-ghost" id="enc-shorten-btn"><i class="fas fa-compress-alt"></i> Shorter</button>
</div>
</div>
</section>
<!-- ===== TAB: DICTATION HPI ===== -->
<section id="dictation-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-microphone"></i> Voice Dictation → HPI</h2>
<p>Dictate your narrative → AI restructures into polished HPI</p>
</div>
<div class="demographics-bar">
<div class="demo-field"><label>Patient Age</label><input type="text" id="dict-age" placeholder="e.g., 8 months"></div>
<div class="demo-field"><label>Gender</label><select id="dict-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
<div class="demo-field">
<label>Setting</label>
<select id="dict-setting">
<option value="outpatient">Outpatient</option>
<option value="inpatient">Inpatient/Floors</option>
<option value="ed">Emergency Dept</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</h3><button id="dict-clear" 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="Dictation appears here or type directly..."></div>
</div>
<div class="soap-options">
<label>Generate as:</label>
<select id="dict-output-type">
<option value="hpi">HPI Only</option>
<option value="soap-full">Full SOAP Note</option>
<option value="soap-subjective">SOAP Subjective Only</option>
</select>
</div>
<button id="dict-generate-btn" class="btn-generate btn-generate-purple"><i class="fas fa-magic"></i> Generate</button>
<div id="dict-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> Output</h3>
<div class="output-actions">
<span id="dict-model-tag" 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>
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('dict-hpi-text','hpi-dictation')"><i class="fas fa-cloud-upload-alt"></i></button>
</div>
</div>
<div id="dict-hpi-text" class="output-text" contenteditable="true"></div>
<div class="refine-bar">
<input type="text" id="dict-refine-input" class="refine-input" placeholder="Tell AI to modify...">
<button class="btn-sm btn-primary" id="dict-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<button class="btn-sm btn-ghost" id="dict-shorten-btn"><i class="fas fa-compress-alt"></i> Shorter</button>
</div>
</div>
</section>
<!-- ===== TAB: HOSPITAL COURSE ===== -->
<section id="hospital-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-hospital"></i> Hospital Course Generator</h2>
<p>Upload notes by date → AI generates organized hospital course</p>
</div>
<div class="demographics-bar">
<div class="demo-field"><label>Patient Age</label><input type="text" id="hc-age" placeholder="e.g., 3 years"></div>
<div class="demo-field"><label>Gender</label><select id="hc-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
<div class="demo-field"><label>PMH</label><input type="text" id="hc-pmh" placeholder="e.g., asthma, obesity"></div>
<div class="demo-field">
<label>Unit</label>
<select id="hc-setting">
<option value="floor">General Floor</option>
<option value="picu">PICU</option>
<option value="nicu">NICU</option>
<option value="psych">Psych/Behavioral</option>
</select>
</div>
<div class="demo-field"><label>LOS (days)</label><input type="number" id="hc-los" placeholder="e.g., 3" min="1"></div>
<div class="demo-field">
<label>Format</label>
<select id="hc-format">
<option value="auto">Auto (AI decides)</option>
<option value="prose">Prose Summary</option>
<option value="dayByDay">Day by Day</option>
<option value="organSystem">Organ System (ICU)</option>
</select>
</div>
</div>
<!-- ED Note -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-ambulance"></i> ED Note (before admission)</h3></div>
<div class="note-entry">
<div class="note-meta">
<input type="date" id="hc-ed-date">
<input type="text" id="hc-ed-labs" placeholder="ED Labs (e.g., WBC 15, BMP normal...)">
</div>
<div id="hc-ed-content" class="editable-box" contenteditable="true" data-placeholder="Paste or type ED note here..."></div>
<div class="note-dictate">
<button class="btn-sm btn-ghost hc-dictate-btn" data-target="hc-ed-content"><i class="fas fa-microphone"></i> Dictate</button>
</div>
</div>
</div>
<!-- H&P -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-file-medical"></i> H&P (Admission Note)</h3></div>
<div class="note-entry">
<div class="note-meta">
<input type="date" id="hc-hp-date">
</div>
<div id="hc-hp-content" class="editable-box" contenteditable="true" data-placeholder="Paste or type H&P here..."></div>
<div class="note-dictate">
<button class="btn-sm btn-ghost hc-dictate-btn" data-target="hc-hp-content"><i class="fas fa-microphone"></i> Dictate</button>
</div>
</div>
</div>
<!-- Progress Notes (dynamic) -->
<div id="hc-notes-container">
<div class="card note-card" data-note-index="0">
<div class="card-header">
<h3><i class="fas fa-notes-medical"></i> Progress Note #1</h3>
<button class="btn-sm btn-ghost remove-note-btn"><i class="fas fa-trash"></i></button>
</div>
<div class="note-entry">
<div class="note-meta">
<input type="date" class="note-date">
<select class="note-type">
<option value="progress-attending">Progress - Attending</option>
<option value="progress-resident">Progress - Resident</option>
<option value="progress-np">Progress - NP/PA</option>
<option value="consult">Consult Note</option>
<option value="procedure">Procedure Note</option>
<option value="discharge-summary">Discharge Summary</option>
</select>
</div>
<div class="editable-box note-content" contenteditable="true" data-placeholder="Paste or type note..."></div>
<div class="note-dictate">
<button class="btn-sm btn-ghost hc-dictate-btn" data-target-class="note-content"><i class="fas fa-microphone"></i> Dictate</button>
</div>
</div>
</div>
</div>
<button id="hc-add-note" class="btn-sm btn-ghost" style="margin:8px 0"><i class="fas fa-plus"></i> Add Progress Note</button>
<!-- Labs -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-flask"></i> Labs</h3><button id="hc-add-lab" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add</button></div>
<div id="hc-labs-container">
<div class="lab-entry">
<input type="date" class="lab-date">
<input type="text" class="lab-values" placeholder="e.g., WBC 12.5, H/H 10.2/31, BMP: Na 138, K 3.5, BUN 11, Cr 0.5">
</div>
</div>
</div>
<!-- AI Instructions -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Additional Instructions (optional)</h3></div>
<input type="text" id="hc-instructions" class="full-input" placeholder="e.g., 'Focus on respiratory course', 'Patient was transferred from outside hospital'">
</div>
<div class="action-row">
<button id="hc-generate-btn" class="btn-generate btn-generate-blue"><i class="fas fa-magic"></i> Generate Hospital Course</button>
</div>
<!-- Output -->
<div id="hc-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> Hospital Course</h3>
<div class="output-actions">
<span id="hc-format-tag" class="model-tag"></span>
<span id="hc-model-tag" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('hc-course-text')"><i class="fas fa-copy"></i> Copy</button>
<button class="btn-sm btn-ghost" onclick="speakText('hc-course-text')"><i class="fas fa-volume-up"></i> Read</button>
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('hc-course-text','hospital-course')"><i class="fas fa-cloud-upload-alt"></i></button>
</div>
</div>
<div id="hc-course-text" class="output-text" contenteditable="true"></div>
<div class="refine-bar">
<input type="text" id="hc-refine-input" class="refine-input" placeholder="Tell AI to modify or add discharge day info...">
<button class="btn-sm btn-primary" id="hc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<button class="btn-sm btn-ghost" id="hc-shorten-btn"><i class="fas fa-compress-alt"></i> Shorter</button>
<button class="btn-sm btn-warning" id="hc-clarify-btn"><i class="fas fa-question-circle"></i> What's Missing?</button>
</div>
<div id="hc-clarify-output" class="clarify-box hidden"></div>
</div>
</section>
<!-- ===== TAB: CHART REVIEW ===== -->
<section id="chart-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-clipboard-list"></i> Chart Review / Precharting</h2>
<p>Paste clinic notes, subspecialty notes, ED notes, labs → AI summarizes</p>
</div>
<div class="demographics-bar">
<div class="demo-field"><label>Patient Age</label><input type="text" id="cr-age" placeholder="e.g., 8 years"></div>
<div class="demo-field"><label>Gender</label><select id="cr-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
<div class="demo-field"><label>PMH</label><input type="text" id="cr-pmh" placeholder="e.g., hypothyroidism, vitiligo"></div>
<div class="demo-field">
<label>Review Type</label>
<select id="cr-type">
<option value="outpatient">Outpatient Clinic</option>
<option value="subspecialty">Subspecialty</option>
<option value="ed">ED Visit</option>
</select>
</div>
</div>
<!-- Visits -->
<div id="cr-visits-container">
<div class="card visit-card">
<div class="card-header">
<h3><i class="fas fa-calendar"></i> Visit / Note #1</h3>
<button class="btn-sm btn-ghost remove-visit-btn"><i class="fas fa-trash"></i></button>
</div>
<div class="note-entry">
<div class="note-meta">
<input type="date" class="visit-date">
<select class="visit-type">
<option value="outpatient">Outpatient Visit</option>
<option value="subspecialty">Subspecialty Note</option>
<option value="ed">ED Visit</option>
</select>
<input type="text" class="visit-specialist" placeholder="Specialist name (if subspecialty)">
<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)">
</div>
<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>
<input type="text" class="visit-labs" placeholder="Labs from this visit (optional)">
</div>
</div>
</div>
<button id="cr-add-visit" class="btn-sm btn-ghost" style="margin:8px 0"><i class="fas fa-plus"></i> Add Visit/Note</button>
<!-- Labs -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-flask"></i> Additional Labs</h3><button id="cr-add-lab" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add</button></div>
<div id="cr-labs-container">
<div class="lab-entry">
<input type="date" class="lab-date">
<input type="text" class="lab-values" placeholder="Lab results...">
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
<input type="text" id="cr-instructions" class="full-input" placeholder="e.g., 'Focus on thyroid management', 'Include medication changes'">
</div>
<button id="cr-generate-btn" class="btn-generate btn-generate-green"><i class="fas fa-magic"></i> Generate Chart Review</button>
<div id="cr-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> Chart Review</h3>
<div class="output-actions">
<span id="cr-model-tag" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('cr-review-text')"><i class="fas fa-copy"></i> Copy</button>
<button class="btn-sm btn-ghost" onclick="speakText('cr-review-text')"><i class="fas fa-volume-up"></i> Read</button>
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('cr-review-text','chart-review')"><i class="fas fa-cloud-upload-alt"></i></button>
</div>
</div>
<div id="cr-review-text" class="output-text" contenteditable="true"></div>
<div class="refine-bar">
<input type="text" id="cr-refine-input" class="refine-input" placeholder="Tell AI to modify...">
<button class="btn-sm btn-primary" id="cr-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<button class="btn-sm btn-ghost" id="cr-shorten-btn"><i class="fas fa-compress-alt"></i> Shorter</button>
</div>
</div>
</section>
<!-- ===== TAB: SOAP ===== -->
<section id="soap-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-file-medical-alt"></i> SOAP Note Generator</h2>
<p>Generate full SOAP note or just Subjective from dictation/text</p>
</div>
<div class="demographics-bar">
<div class="demo-field"><label>Patient Age</label><input type="text" id="soap-age" placeholder="e.g., 5 years"></div>
<div class="demo-field"><label>Gender</label><select id="soap-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
<div class="demo-field">
<label>Generate</label>
<select id="soap-type">
<option value="full">Full SOAP Note</option>
<option value="subjective">Subjective Only</option>
</select>
</div>
</div>
<div class="card">
<div class="record-controls">
<button id="soap-record-btn" class="record-btn btn-teal"><i class="fas fa-microphone"></i><span>Dictate</span></button>
<div id="soap-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot pulse-teal"></div><span>Dictating... <span id="soap-timer">00:00</span></span></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-file-alt"></i> Input</h3><button id="soap-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear</button></div>
<div id="soap-transcript" class="editable-box" contenteditable="true" data-placeholder="Dictate or type encounter details..."></div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
<input type="text" id="soap-instructions" class="full-input" placeholder="e.g., 'Include assessment for otitis media', 'Add anticipatory guidance'">
</div>
<button id="soap-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-magic"></i> Generate SOAP Note</button>
<div id="soap-output" class="card output-card hidden">
<div class="card-header output-header">
<h3><i class="fas fa-file-medical"></i> SOAP Note</h3>
<div class="output-actions">
<span id="soap-model-tag" class="model-tag"></span>
<button class="btn-sm btn-primary" onclick="copyText('soap-text')"><i class="fas fa-copy"></i> Copy</button>
<button class="btn-sm btn-ghost" onclick="speakText('soap-text')"><i class="fas fa-volume-up"></i> Read</button>
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('soap-text','soap-note')"><i class="fas fa-cloud-upload-alt"></i></button>
</div>
</div>
<div id="soap-text" class="output-text" contenteditable="true"></div>
<div class="refine-bar">
<input type="text" id="soap-refine-input" class="refine-input" placeholder="Tell AI to modify...">
<button class="btn-sm btn-primary" id="soap-refine-btn"><i class="fas fa-edit"></i> Refine</button>
<button class="btn-sm btn-ghost" id="soap-shorten-btn"><i class="fas fa-compress-alt"></i> Shorter</button>
</div>
</div>
</section>
<!-- ===== TAB: MILESTONES ===== -->
<section id="milestones-tab" class="tab-content">
<div class="module-header">
<h2><i class="fas fa-baby"></i> Developmental Milestones</h2>
<p>AAP & Nelson — Click to assess. Blank = not assessed (omitted).</p>
</div>
<div class="demographics-bar">
<div class="demo-field"><label>Patient Age</label><input type="text" id="ms-age" placeholder="e.g., 9 months"></div>
<div class="demo-field"><label>Gender</label><select id="ms-gender"><option value="">Select</option><option>Male</option><option>Female</option></select></div>
<div class="demo-field">
<label>Age Group</label>
<select id="ms-age-group">
<option value="">-- Select --</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 class="demo-field">
<label>Output Format</label>
<select id="ms-format">
<option value="narrative">Narrative (paragraphs)</option>
<option value="list">Structured List</option>
</select>
</div>
</div>
<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 Achieved</div>
<div class="legend-item"><span class="legend-icon legend-blank"></span> Not Assessed (omitted)</div>
</div>
<div id="milestone-checklist"></div>
<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>
<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</h3>
<div class="output-actions">
<span id="ms-model-tag" 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>
<button class="btn-sm btn-ghost" onclick="exportToNextcloud('ms-narrative-text','milestones')"><i class="fas fa-cloud-upload-alt"></i></button>
</div>
</div>
<div id="ms-summary-bar" class="summary-bar hidden"></div>
<div id="ms-narrative-text" class="output-text" contenteditable="true"></div>
<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> 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</h3>
<div class="output-actions">
<button class="btn-sm btn-primary" onclick="copyText('ms-summary-text')"><i class="fas fa-copy"></i> Copy</button>
</div>
</div>
<div id="ms-summary-text" class="summary-output-text" contenteditable="true"></div>
</div>
</div>
</div>
</section>
</main>
<!-- SETTINGS MODAL -->
<div id="settings-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2><i class="fas fa-cog"></i> Settings</h2>
<button class="modal-close" id="close-settings"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<!-- 2FA -->
<div class="settings-section">
<h3><i class="fas fa-shield-alt"></i> Two-Factor Authentication</h3>
<p id="2fa-status">Status: Loading...</p>
<button id="btn-setup-2fa" class="btn-sm btn-primary">Enable 2FA</button>
<button id="btn-disable-2fa" class="btn-sm btn-ghost hidden">Disable 2FA</button>
<div id="2fa-setup" class="hidden">
<p>Scan this QR code with your authenticator app:</p>
<img id="2fa-qr" src="" alt="QR Code">
<p>Or enter manually: <code id="2fa-secret"></code></p>
<div class="form-group">
<label>Enter the 6-digit code to verify:</label>
<input type="text" id="2fa-verify-code" maxlength="6" placeholder="123456">
<button id="btn-verify-2fa" class="btn-sm btn-success">Verify & Enable</button>
</div>
</div>
</div>
<!-- Nextcloud -->
<div class="settings-section">
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>
<p>Export generated documents to your Nextcloud.</p>
<div id="nc-status">Not connected</div>
<div class="form-group">
<label>Nextcloud URL</label>
<input type="text" id="nc-url" placeholder="https://cloud.example.com">
</div>
<div class="form-group">
<label>Username</label>
<input type="text" id="nc-user" placeholder="your-username">
</div>
<div class="form-group">
<label>App Password</label>
<input type="password" id="nc-pass" placeholder="Generate in Nextcloud → Settings → Security">
<small>Go to Nextcloud → Settings → Security → Create new app password</small>
</div>
<button id="btn-nc-connect" class="btn-sm btn-primary">Connect</button>
<button id="btn-nc-disconnect" class="btn-sm btn-ghost hidden">Disconnect</button>
</div>
<!-- HIPAA -->
<div class="settings-section">
<h3><i class="fas fa-lock"></i> HIPAA & Privacy</h3>
<div class="hipaa-info">
<p><strong>Current Status:</strong> This tool processes data through third-party AI APIs.</p>
<ul>
<li>✅ All connections use HTTPS/TLS encryption</li>
<li>✅ Authentication required</li>
<li>✅ No patient data stored on server</li>
<li>✅ 2FA available</li>
<li>⚠️ OpenRouter does not currently offer BAA</li>
<li>⚠️ For full HIPAA: use Azure OpenAI or AWS Bedrock with BAA</li>
</ul>
<p><strong>Recommendation:</strong> Do not enter real PHI until your organization has executed BAAs with all AI providers.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- LOADING -->
<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 -->
<script src="/js/milestonesData.js"></script>
<script src="/js/app.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/liveEncounter.js"></script>
<script src="/js/voiceDictation.js"></script>
<script src="/js/hospitalCourse.js"></script>
<script src="/js/chartReview.js"></script>
<script src="/js/soap.js"></script>
<script src="/js/milestones.js"></script>
<script src="/js/nextcloud.js"></script>
</body>
</html>

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

@ -0,0 +1,277 @@
// ============================================================
// APP.JS — Core utilities, tabs, model selector, refine, speak
// ============================================================
// --- 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');
document.getElementById(btn.getAttribute('data-tab') + '-tab').classList.add('active');
});
});
// --- MODEL SELECTOR ---
var modelSelect = document.getElementById('global-model-select');
var costBadge = document.getElementById('model-cost-badge');
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) {
if (data.models && modelSelect) {
modelSelect.innerHTML = '';
data.models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
var prefix = m.tag === 'FREE' ? '🆓' : m.tag === 'PREMIUM' ? '👑' : m.tag === 'REASONING' ? '🧠' : '⚡';
opt.textContent = prefix + ' ' + m.name + ' (' + m.cost + ')';
modelSelect.appendChild(opt);
});
if (costBadge) costBadge.textContent = data.models[0].cost;
}
}).catch(function() {});
function getSelectedModel() {
return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash';
}
if (modelSelect) {
modelSelect.addEventListener('change', function() {
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) {
var m = data.models.find(function(x) { return x.id === modelSelect.value; });
if (m && costBadge) costBadge.textContent = m.cost;
});
showToast('Model: ' + modelSelect.value.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(); }, 3500);
}
// --- COPY ---
function copyText(elementId) {
var el = document.getElementById(elementId);
var text = el.innerText || el.textContent;
navigator.clipboard.writeText(text).then(function() {
showToast('Copied!', 'success');
}).catch(function() {
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 ---
var currentlyReadingId = null;
var currentAudio = null;
function speakText(elementId) {
if (currentlyReadingId === elementId) { stopReading(); return; }
stopReading();
var el = document.getElementById(elementId);
var text = (el.innerText || el.textContent).trim();
if (!text) { showToast('Nothing to read', 'error'); return; }
var readBtn = findReadButton(elementId);
useBrowserTTS(text, elementId, readBtn);
}
function useBrowserTTS(text, elementId, readBtn) {
if (!('speechSynthesis' in window)) { showToast('TTS not supported', 'error'); return; }
window.speechSynthesis.cancel();
var utter = new SpeechSynthesisUtterance(text);
utter.rate = 0.9;
utter.onend = function() { stopReading(); };
utter.onerror = function() { stopReading(); };
currentlyReadingId = elementId;
setReadButtonActive(readBtn, true);
window.speechSynthesis.speak(utter);
showToast('Reading — click Stop to end', 'info');
}
function stopReading() {
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
if (currentlyReadingId) {
setReadButtonActive(findReadButton(currentlyReadingId), false);
currentlyReadingId = null;
}
}
function findReadButton(elementId) {
var el = document.getElementById(elementId);
if (!el) return null;
var card = el.closest('.output-card') || el.closest('.card');
if (!card) return null;
var buttons = card.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var oc = buttons[i].getAttribute('onclick') || '';
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return buttons[i];
}
return null;
}
function setReadButtonActive(btn, active) {
if (!btn) return;
if (active) { 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(el) {
var s = 0, iv = null;
return {
start: function() { s = 0; this.update(); var self = this; iv = setInterval(function() { s++; self.update(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
update: function() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
};
}
// --- 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 mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
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();
});
};
// --- TRANSCRIBE HELPER ---
function transcribeAudio(blob) {
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
return fetch('/api/transcribe', { method: 'POST', body: formData })
.then(function(r) { return r.json(); });
}
// --- REFINE HELPER ---
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId).innerText.trim();
var instructions = document.getElementById(inputElementId).value.trim();
if (!doc) { showToast('No document to refine', 'error'); return; }
if (!instructions) { showToast('Enter instructions for AI', 'error'); return; }
showLoading('Refining...');
fetch('/api/refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ currentDocument: doc, instructions: instructions, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
document.getElementById(outputElementId).textContent = data.refined;
document.getElementById(inputElementId).value = '';
showToast('Refined!', 'success');
} else {
showToast(data.error || 'Refine failed', 'error');
}
})
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); });
}
// --- SHORTEN HELPER ---
function shortenDocument(outputElementId) {
var doc = document.getElementById(outputElementId).innerText.trim();
if (!doc) { showToast('No document to shorten', 'error'); return; }
showLoading('Shortening...');
fetch('/api/shorten', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ document: doc, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
document.getElementById(outputElementId).textContent = data.shortened;
showToast('Shortened!', 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); });
}
// --- EXPORT NEXTCLOUD HELPER ---
function exportToNextcloud(elementId, docType) {
var text = document.getElementById(elementId).innerText.trim();
if (!text) { showToast('Nothing to export', 'error'); return; }
var date = new Date().toISOString().split('T')[0];
var filename = docType + '-' + date + '-' + Date.now();
fetch('/api/nextcloud/export', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ content: text, filename: filename, type: docType })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) showToast(data.message, 'success');
else showToast(data.error || 'Export failed', 'error');
})
.catch(function(err) { showToast('Nextcloud not connected. Go to Settings.', 'error'); });
}
// --- WEB SPEECH RECOGNITION HELPER ---
function createSpeechRecognition() {
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var rec = new SR();
rec.continuous = true;
rec.interimResults = true;
rec.lang = 'en-US';
return rec;
}
console.log('✅ App.js loaded');
// Register Service Worker for PWA
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function() {
console.log('✅ PWA: Service Worker registered');
}).catch(function(err) {
console.log('PWA: SW registration failed', err);
});
}

294
public/js/auth.js Normal file
View file

@ -0,0 +1,294 @@
// ============================================================
// AUTH.JS — Login, Register, 2FA, Session Management
// ============================================================
(function() {
var authScreen = document.getElementById('auth-screen');
var mainApp = document.getElementById('main-app');
var loginForm = document.getElementById('login-form');
var registerForm = document.getElementById('register-form');
var forgotForm = document.getElementById('forgot-form');
var userName = document.getElementById('user-name');
var TOKEN_KEY = 'ped_scribe_token';
var USER_KEY = 'ped_scribe_user';
// Check existing session
var savedToken = localStorage.getItem(TOKEN_KEY);
var savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
try {
var user = JSON.parse(savedUser);
showApp(user, savedToken);
} catch(e) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
}
// Form toggles
document.getElementById('show-register').addEventListener('click', function(e) {
e.preventDefault();
loginForm.classList.add('hidden');
registerForm.classList.remove('hidden');
forgotForm.classList.add('hidden');
});
document.getElementById('show-login').addEventListener('click', function(e) {
e.preventDefault();
loginForm.classList.remove('hidden');
registerForm.classList.add('hidden');
forgotForm.classList.add('hidden');
});
document.getElementById('show-login-2').addEventListener('click', function(e) {
e.preventDefault();
loginForm.classList.remove('hidden');
registerForm.classList.add('hidden');
forgotForm.classList.add('hidden');
});
document.getElementById('show-forgot').addEventListener('click', function(e) {
e.preventDefault();
loginForm.classList.add('hidden');
registerForm.classList.add('hidden');
forgotForm.classList.remove('hidden');
});
// Login
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
var email = document.getElementById('login-email').value;
var password = document.getElementById('login-password').value;
var totpCode = document.getElementById('login-totp').value;
showLoading('Signing in...');
fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, password: password, totpCode: totpCode || undefined })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.requires2FA) {
document.getElementById('totp-group').classList.remove('hidden');
showToast('Enter your 2FA code', 'info');
return;
}
if (data.success) {
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
showApp(data.user, data.token);
showToast('Welcome back, ' + data.user.name + '!', 'success');
} else {
showToast(data.error || 'Login failed', 'error');
}
})
// Handle email verification needed
if (data.needsVerification) {
showToast(data.message || 'Please verify your email first', 'error');
// Show resend option
var loginLinks = loginForm.querySelector('.auth-links');
if (!document.getElementById('resend-verify-link')) {
var resendLink = document.createElement('a');
resendLink.href = '#';
resendLink.id = 'resend-verify-link';
resendLink.textContent = 'Resend verification email';
resendLink.addEventListener('click', function(e) {
e.preventDefault();
var email = document.getElementById('login-email').value;
if (!email) { showToast('Enter your email first', 'error'); return; }
fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function(r) { return r.json(); })
.then(function(d) { showToast(d.message || 'Verification email sent!', 'success'); });
});
loginLinks.appendChild(resendLink);
}
return;
}
.catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
});
});
// Register
registerForm.addEventListener('submit', function(e) {
e.preventDefault();
var name = document.getElementById('reg-name').value;
var email = document.getElementById('reg-email').value;
var password = document.getElementById('reg-password').value;
if (password.length < 8) {
showToast('Password must be at least 8 characters', 'error');
return;
}
showLoading('Creating account...');
fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
showApp(data.user, data.token);
showToast('Account created! Welcome, ' + data.user.name, 'success');
} else {
showToast(data.error || 'Registration failed', 'error');
}
})
.catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
});
});
// Forgot password
forgotForm.addEventListener('submit', function(e) {
e.preventDefault();
var email = document.getElementById('forgot-email').value;
showLoading('Sending reset link...');
fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
showToast(data.message || 'If account exists, reset email sent', 'success');
loginForm.classList.remove('hidden');
forgotForm.classList.add('hidden');
})
.catch(function(err) {
hideLoading();
showToast('Error: ' + err.message, 'error');
});
});
// Logout
document.getElementById('btn-logout').addEventListener('click', function() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
authScreen.classList.remove('hidden');
mainApp.classList.add('hidden');
showToast('Logged out', 'info');
});
function showApp(user, token) {
authScreen.classList.add('hidden');
mainApp.classList.remove('hidden');
if (userName) userName.textContent = user.name || user.email;
window.AUTH_TOKEN = token;
}
// Helper: add auth header to all API calls
window.getAuthHeaders = function() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem(TOKEN_KEY) || '')
};
};
// Settings modal
document.getElementById('btn-settings').addEventListener('click', function() {
document.getElementById('settings-modal').classList.remove('hidden');
load2FAStatus();
loadNextcloudStatus();
});
document.getElementById('close-settings').addEventListener('click', function() {
document.getElementById('settings-modal').classList.add('hidden');
});
// 2FA Setup
function load2FAStatus() {
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.user) {
var status = document.getElementById('2fa-status');
var setupBtn = document.getElementById('btn-setup-2fa');
var disableBtn = document.getElementById('btn-disable-2fa');
if (data.user.totp_enabled) {
status.textContent = 'Status: ✅ Enabled';
status.style.color = '#10b981';
setupBtn.classList.add('hidden');
disableBtn.classList.remove('hidden');
} else {
status.textContent = 'Status: ❌ Not enabled';
status.style.color = '#ef4444';
setupBtn.classList.remove('hidden');
disableBtn.classList.add('hidden');
}
}
});
}
document.getElementById('btn-setup-2fa').addEventListener('click', function() {
fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
document.getElementById('2fa-qr').src = data.qrCode;
document.getElementById('2fa-secret').textContent = data.secret;
document.getElementById('2fa-setup').classList.remove('hidden');
}
});
});
document.getElementById('btn-verify-2fa').addEventListener('click', function() {
var code = document.getElementById('2fa-verify-code').value;
fetch('/api/auth/verify-2fa', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ code: code })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('2FA enabled!', 'success');
document.getElementById('2fa-setup').classList.add('hidden');
load2FAStatus();
} else {
showToast(data.error || 'Invalid code', 'error');
}
});
});
document.getElementById('btn-disable-2fa').addEventListener('click', function() {
var pw = prompt('Enter your password to disable 2FA:');
if (!pw) return;
fetch('/api/auth/disable-2fa', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: pw })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('2FA disabled', 'info');
load2FAStatus();
} else {
showToast(data.error || 'Failed', 'error');
}
});
});
console.log('✅ Auth module loaded');
})();

117
public/js/chartReview.js Normal file
View file

@ -0,0 +1,117 @@
(function() {
var visitsContainer = document.getElementById('cr-visits-container');
var addVisitBtn = document.getElementById('cr-add-visit');
var labsContainer = document.getElementById('cr-labs-container');
var addLabBtn = document.getElementById('cr-add-lab');
var generateBtn = document.getElementById('cr-generate-btn');
var outputCard = document.getElementById('cr-output');
var reviewText = document.getElementById('cr-review-text');
var modelTag = document.getElementById('cr-model-tag');
var visitIndex = 1;
addVisitBtn.addEventListener('click', function() {
visitIndex++;
var card = document.createElement('div');
card.className = 'card visit-card';
card.innerHTML = '<div class="card-header"><h3><i class="fas fa-calendar"></i> Visit / Note #' + visitIndex + '</h3>' +
'<button class="btn-sm btn-ghost remove-visit-btn"><i class="fas fa-trash"></i></button></div>' +
'<div class="note-entry"><div class="note-meta">' +
'<input type="date" class="visit-date">' +
'<select class="visit-type"><option value="outpatient">Outpatient Visit</option><option value="subspecialty">Subspecialty Note</option><option value="ed">ED Visit</option></select>' +
'<input type="text" class="visit-specialist" placeholder="Specialist name">' +
'<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"></div>' +
'<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>' +
'<input type="text" class="visit-labs" placeholder="Labs from this visit (optional)"></div>';
visitsContainer.appendChild(card);
card.querySelector('.remove-visit-btn').addEventListener('click', function() { card.remove(); });
});
document.querySelectorAll('.remove-visit-btn').forEach(function(btn) {
btn.addEventListener('click', function() { btn.closest('.visit-card').remove(); });
});
addLabBtn.addEventListener('click', function() {
var entry = document.createElement('div');
entry.className = 'lab-entry';
entry.innerHTML = '<input type="date" class="lab-date"><input type="text" class="lab-values" placeholder="Lab results...">' +
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
labsContainer.appendChild(entry);
});
generateBtn.addEventListener('click', function() {
var type = document.getElementById('cr-type').value;
var visits = [];
var subspecialty = [];
var edVisits = [];
visitsContainer.querySelectorAll('.visit-card').forEach(function(card) {
var content = card.querySelector('.visit-content').innerText.trim();
if (!content) return;
var vType = card.querySelector('.visit-type').value;
var entry = {
date: card.querySelector('.visit-date').value || '',
content: content,
labs: card.querySelector('.visit-labs').value || ''
};
if (vType === 'subspecialty') {
entry.specialistName = card.querySelector('.visit-specialist').value || '';
entry.specialty = card.querySelector('.visit-specialty').value || '';
subspecialty.push(entry);
} else if (vType === 'ed') {
edVisits.push(entry);
} else {
entry.type = 'outpatient';
visits.push(entry);
}
});
var labs = [];
labsContainer.querySelectorAll('.lab-entry').forEach(function(entry) {
var vals = entry.querySelector('.lab-values').value.trim();
if (vals) labs.push({ date: entry.querySelector('.lab-date').value || '', values: vals });
});
if (visits.length === 0 && subspecialty.length === 0 && edVisits.length === 0) {
showToast('Add at least one visit/note', 'error');
return;
}
showLoading('Generating chart review...');
fetch('/api/generate-chart-review', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
type: type,
visits: visits,
subspecialty: subspecialty,
edVisits: edVisits,
labs: labs,
patientAge: document.getElementById('cr-age').value,
patientGender: document.getElementById('cr-gender').value,
pmh: document.getElementById('cr-pmh').value,
additionalInstructions: document.getElementById('cr-instructions').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
reviewText.textContent = data.review;
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Chart review generated!', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
document.getElementById('cr-refine-btn').addEventListener('click', function() { refineDocument('cr-review-text', 'cr-refine-input'); });
document.getElementById('cr-shorten-btn').addEventListener('click', function() { shortenDocument('cr-review-text'); });
console.log('✅ Chart Review module loaded');
})();

195
public/js/hospitalCourse.js Normal file
View file

@ -0,0 +1,195 @@
(function() {
var notesContainer = document.getElementById('hc-notes-container');
var addNoteBtn = document.getElementById('hc-add-note');
var labsContainer = document.getElementById('hc-labs-container');
var addLabBtn = document.getElementById('hc-add-lab');
var generateBtn = document.getElementById('hc-generate-btn');
var outputCard = document.getElementById('hc-output');
var courseText = document.getElementById('hc-course-text');
var formatTag = document.getElementById('hc-format-tag');
var modelTag = document.getElementById('hc-model-tag');
var clarifyOutput = document.getElementById('hc-clarify-output');
var noteIndex = 1;
// Add progress note
addNoteBtn.addEventListener('click', function() {
noteIndex++;
var card = document.createElement('div');
card.className = 'card note-card';
card.innerHTML = '<div class="card-header"><h3><i class="fas fa-notes-medical"></i> Progress Note #' + noteIndex + '</h3>' +
'<button class="btn-sm btn-ghost remove-note-btn"><i class="fas fa-trash"></i></button></div>' +
'<div class="note-entry"><div class="note-meta">' +
'<input type="date" class="note-date">' +
'<select class="note-type"><option value="progress-attending">Progress - Attending</option><option value="progress-resident">Progress - Resident</option><option value="progress-np">Progress - NP/PA</option><option value="consult">Consult Note</option><option value="procedure">Procedure Note</option><option value="discharge-summary">Discharge Summary</option></select></div>' +
'<div class="editable-box note-content" contenteditable="true" data-placeholder="Paste or type note..."></div>' +
'<div class="note-dictate"><button class="btn-sm btn-ghost hc-dictate-btn"><i class="fas fa-microphone"></i> Dictate</button></div></div>';
notesContainer.appendChild(card);
card.querySelector('.remove-note-btn').addEventListener('click', function() { card.remove(); });
setupDictateButton(card.querySelector('.hc-dictate-btn'), card.querySelector('.note-content'));
});
// Remove note buttons for initial note
document.querySelectorAll('.remove-note-btn').forEach(function(btn) {
btn.addEventListener('click', function() { btn.closest('.note-card').remove(); });
});
// Add lab
addLabBtn.addEventListener('click', function() {
var entry = document.createElement('div');
entry.className = 'lab-entry';
entry.innerHTML = '<input type="date" class="lab-date"><input type="text" class="lab-values" placeholder="Lab results...">' +
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
labsContainer.appendChild(entry);
});
// Dictate button helper
function setupDictateButton(btn, targetEl) {
var rec = new AudioRecorder();
var isRec = false;
btn.addEventListener('click', function() {
if (!isRec) {
rec.start().then(function() {
isRec = true;
btn.innerHTML = '<i class="fas fa-stop"></i> Stop';
btn.classList.add('btn-recording');
});
} else {
isRec = false;
btn.innerHTML = '<i class="fas fa-microphone"></i> Dictate';
btn.classList.remove('btn-recording');
showLoading('Transcribing...');
rec.stop().then(function(blob) {
if (!blob) { hideLoading(); return; }
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) {
var existing = targetEl.innerText.trim();
targetEl.textContent = existing ? existing + '\n' + data.text : data.text;
showToast('Transcribed!', 'success');
}
});
}).catch(function() { hideLoading(); });
}
});
}
// Setup dictate buttons for initial elements
document.querySelectorAll('.hc-dictate-btn').forEach(function(btn) {
var targetId = btn.getAttribute('data-target');
if (targetId) {
setupDictateButton(btn, document.getElementById(targetId));
} else {
var card = btn.closest('.note-card');
if (card) setupDictateButton(btn, card.querySelector('.note-content'));
}
});
// Collect all data and generate
generateBtn.addEventListener('click', function() {
var notes = [];
notesContainer.querySelectorAll('.note-card').forEach(function(card) {
var content = card.querySelector('.note-content').innerText.trim();
if (content) {
notes.push({
date: card.querySelector('.note-date').value || '',
type: card.querySelector('.note-type').value || 'progress-attending',
content: content
});
}
});
var edContent = document.getElementById('hc-ed-content').innerText.trim();
var edNote = edContent ? {
date: document.getElementById('hc-ed-date').value || '',
content: edContent,
labs: document.getElementById('hc-ed-labs').value || ''
} : null;
var hpContent = document.getElementById('hc-hp-content').innerText.trim();
var hAndP = hpContent ? {
date: document.getElementById('hc-hp-date').value || '',
content: hpContent
} : null;
var labs = [];
labsContainer.querySelectorAll('.lab-entry').forEach(function(entry) {
var vals = entry.querySelector('.lab-values').value.trim();
if (vals) {
labs.push({ date: entry.querySelector('.lab-date').value || '', values: vals });
}
});
if (notes.length === 0 && !edNote && !hAndP) {
showToast('Add at least one note', 'error');
return;
}
showLoading('Generating hospital course...');
fetch('/api/generate-hospital-course', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
notes: notes,
edNote: edNote,
hAndP: hAndP,
labs: labs,
patientAge: document.getElementById('hc-age').value,
patientGender: document.getElementById('hc-gender').value,
pmh: document.getElementById('hc-pmh').value,
setting: document.getElementById('hc-setting').value,
los: document.getElementById('hc-los').value,
formatPreference: document.getElementById('hc-format').value,
additionalInstructions: document.getElementById('hc-instructions').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
courseText.textContent = data.hospitalCourse;
formatTag.textContent = data.format || '';
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
clarifyOutput.classList.add('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Hospital course generated!', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
// Refine, Shorten, Clarify
document.getElementById('hc-refine-btn').addEventListener('click', function() { refineDocument('hc-course-text', 'hc-refine-input'); });
document.getElementById('hc-shorten-btn').addEventListener('click', function() { shortenDocument('hc-course-text'); });
document.getElementById('hc-clarify-btn').addEventListener('click', function() {
var doc = courseText.innerText.trim();
if (!doc) { showToast('Generate first', 'error'); return; }
showLoading('Checking for missing info...');
fetch('/api/clarify', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ document: doc, context: 'hospital course', model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
clarifyOutput.textContent = data.questions;
clarifyOutput.classList.remove('hidden');
showToast('Review missing information below', 'info');
}
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
console.log('✅ Hospital Course module loaded');
})();

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

@ -0,0 +1,100 @@
(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');
var generateBtn = document.getElementById('enc-generate-btn');
var outputCard = document.getElementById('enc-output');
var hpiText = document.getElementById('enc-hpi-text');
var modelTag = document.getElementById('enc-model-tag');
var recorder = new AudioRecorder();
var timer = createTimer(timerEl);
var isRecording = false;
var recognition = createSpeechRecognition();
var finalText = '';
if (recognition) {
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';
indicator.classList.remove('hidden');
timer.start();
if (recognition) try { recognition.start(); } catch(e) {}
showToast('Recording started', 'info');
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
isRecording = false;
var dur = 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...');
recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); return; }
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else { if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(data.error || 'Failed', 'error'); }
});
}).catch(function(err) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(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 transcript', 'error'); return; }
showLoading('Generating HPI...');
fetch('/api/generate-hpi-encounter', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
transcript: text,
patientAge: document.getElementById('enc-age').value,
patientGender: document.getElementById('enc-gender').value,
setting: document.getElementById('enc-setting').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
hpiText.textContent = data.hpi;
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('HPI generated!', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
// Refine & Shorten
document.getElementById('enc-refine-btn').addEventListener('click', function() { refineDocument('enc-hpi-text', 'enc-refine-input'); });
document.getElementById('enc-shorten-btn').addEventListener('click', function() { shortenDocument('enc-hpi-text'); });
console.log('✅ Encounter module loaded');
})();

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

@ -0,0 +1,184 @@
(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 modelTag = document.getElementById('ms-model-tag');
var state = {};
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.</p>';
actionsBar.style.display = 'none';
return;
}
renderChecklist(MILESTONES_DATA[age]);
actionsBar.style.display = 'flex';
});
function safeId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
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 html = '<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">';
items.forEach(function(m, idx) {
var id = domain + '-' + idx;
state[id] = { domain: domain, milestone: m, status: null };
html += '<div class="ms-item" id="item-' + safeId(id) + '">' +
'<div class="ms-toggle">' +
'<button class="toggle-btn" data-id="' + id + '" data-action="yes">✓</button>' +
'<button class="toggle-btn" data-id="' + id + '" data-action="no">✗</button>' +
'</div><span class="ms-text">' + m + '</span></div>';
});
section.innerHTML = html + '</div>';
checklist.appendChild(section);
});
checklist.querySelectorAll('.toggle-btn').forEach(function(btn) {
btn.addEventListener('click', function() { handleToggle(btn.getAttribute('data-id'), btn.getAttribute('data-action')); });
});
}
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"]');
if (action === 'yes') {
if (state[id].status === 'yes') { 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 (state[id].status === '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(d) {
var b = document.getElementById('badge-' + safeId(d));
if (b) { var c = counts[d]; b.textContent = (c.yes + c.no) > 0 ? c.yes + '✓ ' + c.no + '✗ / ' + c.total : c.total + ' items'; }
});
}
allYesBtn.addEventListener('click', function() {
Object.keys(state).forEach(function(id) {
state[id].status = 'yes';
var item = document.getElementById('item-' + safeId(id));
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();
});
allClearBtn.addEventListener('click', function() {
Object.keys(state).forEach(function(id) {
state[id].status = null;
var item = document.getElementById('item-' + safeId(id));
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');
});
generateBtn.addEventListener('click', function() {
if (!ageSelect.value) { showToast('Select age group', 'error'); return; }
var assessed = Object.values(state).filter(function(m) { return m.status !== null; });
if (assessed.length === 0) { showToast('Assess at least one milestone', 'error'); return; }
showLoading('Generating...');
fetch('/api/generate-milestone-narrative', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
milestones: Object.values(state),
ageGroup: ageSelect.value,
patientAge: document.getElementById('ms-age').value,
patientGender: document.getElementById('ms-gender').value,
format: document.getElementById('ms-format').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
narrativeText.textContent = data.narrative;
modelTag.textContent = (data.model || '').split('/').pop();
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');
document.getElementById('ms-quick-summary').classList.add('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Generated!', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
// 3-sentence summary
document.getElementById('ms-quick-summary-btn').addEventListener('click', function() {
var narrative = narrativeText.innerText.trim();
if (!narrative) { showToast('Generate narrative first', 'error'); return; }
var btn = document.getElementById('ms-quick-summary-btn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Summarizing...';
fetch('/api/generate-milestone-summary', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
narrative: narrative,
ageGroup: ageSelect.value,
patientAge: document.getElementById('ms-age').value,
patientGender: document.getElementById('ms-gender').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-compress-alt"></i> Regenerate Summary';
if (data.success) {
document.getElementById('ms-summary-text').textContent = data.summary;
document.getElementById('ms-quick-summary').classList.remove('hidden');
showToast('Summary generated!', 'success');
}
})
.catch(function(err) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-compress-alt"></i> 3-Sentence Summary'; showToast(err.message, 'error'); });
});
console.log('✅ Milestones module loaded');
})();

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

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

54
public/js/nextcloud.js Normal file
View file

@ -0,0 +1,54 @@
(function() {
function loadNextcloudStatus() {
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.user && data.user.nextcloud_url) {
document.getElementById('nc-status').innerHTML = '✅ Connected to <strong>' + data.user.nextcloud_url + '</strong> as ' + data.user.nextcloud_user;
document.getElementById('nc-url').value = data.user.nextcloud_url;
document.getElementById('nc-user').value = data.user.nextcloud_user;
document.getElementById('btn-nc-disconnect').classList.remove('hidden');
} else {
document.getElementById('nc-status').textContent = 'Not connected';
document.getElementById('btn-nc-disconnect').classList.add('hidden');
}
});
}
window.loadNextcloudStatus = loadNextcloudStatus;
document.getElementById('btn-nc-connect').addEventListener('click', function() {
var url = document.getElementById('nc-url').value.trim().replace(/\/+$/, '');
var user = document.getElementById('nc-user').value.trim();
var pass = document.getElementById('nc-pass').value.trim();
if (!url || !user || !pass) { showToast('Fill all Nextcloud fields', 'error'); return; }
showLoading('Connecting to Nextcloud...');
fetch('/api/nextcloud/connect', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ nextcloudUrl: url, username: user, appPassword: pass })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
showToast(data.message, 'success');
loadNextcloudStatus();
document.getElementById('nc-pass').value = '';
} else {
showToast(data.error || 'Connection failed', 'error');
}
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
document.getElementById('btn-nc-disconnect').addEventListener('click', function() {
fetch('/api/nextcloud/disconnect', { method: 'POST', headers: getAuthHeaders() })
.then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); });
});
console.log('✅ Nextcloud module loaded');
})();

99
public/js/soap.js Normal file
View file

@ -0,0 +1,99 @@
(function() {
var recordBtn = document.getElementById('soap-record-btn');
var indicator = document.getElementById('soap-recording-indicator');
var timerEl = document.getElementById('soap-timer');
var transcript = document.getElementById('soap-transcript');
var clearBtn = document.getElementById('soap-clear');
var generateBtn = document.getElementById('soap-generate-btn');
var outputCard = document.getElementById('soap-output');
var soapText = document.getElementById('soap-text');
var modelTag = document.getElementById('soap-model-tag');
var recorder = new AudioRecorder();
var timer = createTimer(timerEl);
var isRecording = false;
var recognition = createSpeechRecognition();
var finalText = '';
if (recognition) {
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) {} };
}
recordBtn.addEventListener('click', function() {
if (!isRecording) {
finalText = '';
recorder.start().then(function() {
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.querySelector('span').textContent = 'Stop';
indicator.classList.remove('hidden');
timer.start();
if (recognition) try { recognition.start(); } catch(e) {}
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
isRecording = false;
var dur = timer.stop();
recordBtn.classList.remove('recording');
recordBtn.querySelector('span').textContent = 'Dictate';
indicator.classList.add('hidden');
if (recognition) try { recognition.stop(); } catch(e) {}
showLoading('Transcribing...');
recorder.stop().then(function(blob) {
if (!blob) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); return; }
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) transcript.textContent = data.text;
else if (finalText.trim()) transcript.textContent = finalText.trim();
});
}).catch(function() { hideLoading(); 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 input', 'error'); return; }
showLoading('Generating SOAP note...');
fetch('/api/generate-soap', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
transcript: text,
patientAge: document.getElementById('soap-age').value,
patientGender: document.getElementById('soap-gender').value,
type: document.getElementById('soap-type').value,
additionalInstructions: document.getElementById('soap-instructions').value,
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
soapText.textContent = data.soap;
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('SOAP note generated!', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); });
document.getElementById('soap-shorten-btn').addEventListener('click', function() { shortenDocument('soap-text'); });
console.log('✅ SOAP module loaded');
})();

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

@ -0,0 +1,111 @@
(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');
var generateBtn = document.getElementById('dict-generate-btn');
var outputCard = document.getElementById('dict-output');
var hpiText = document.getElementById('dict-hpi-text');
var modelTag = document.getElementById('dict-model-tag');
var recorder = new AudioRecorder();
var timer = createTimer(timerEl);
var isRecording = false;
var recognition = createSpeechRecognition();
var finalText = '';
if (recognition) {
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) {} };
}
recordBtn.addEventListener('click', function() {
if (!isRecording) {
finalText = '';
recorder.start().then(function() {
isRecording = true;
recordBtn.classList.add('recording');
recordBtn.querySelector('span').textContent = 'Stop';
indicator.classList.remove('hidden');
timer.start();
if (recognition) try { recognition.start(); } catch(e) {}
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
isRecording = false;
var dur = 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...');
recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); return; }
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else { if (finalText.trim()) transcript.textContent = finalText.trim(); }
});
}).catch(function() { hideLoading(); 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 dictation', 'error'); return; }
var outputType = document.getElementById('dict-output-type').value;
var endpoint, bodyData;
if (outputType === 'soap-full' || outputType === 'soap-subjective') {
endpoint = '/api/generate-soap';
bodyData = {
transcript: text,
patientAge: document.getElementById('dict-age').value,
patientGender: document.getElementById('dict-gender').value,
type: outputType === 'soap-full' ? 'full' : 'subjective',
model: getSelectedModel()
};
} else {
endpoint = '/api/generate-hpi-dictation';
bodyData = {
transcript: text,
patientAge: document.getElementById('dict-age').value,
patientGender: document.getElementById('dict-gender').value,
setting: document.getElementById('dict-setting').value,
model: getSelectedModel()
};
}
showLoading('Generating...');
fetch(endpoint, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(bodyData) })
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (data.success) {
hpiText.textContent = data.hpi || data.soap;
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Generated!', 'success');
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
});
document.getElementById('dict-refine-btn').addEventListener('click', function() { refineDocument('dict-hpi-text', 'dict-refine-input'); });
document.getElementById('dict-shorten-btn').addEventListener('click', function() { shortenDocument('dict-hpi-text'); });
console.log('✅ Dictation module loaded');
})();

22
public/manifest.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "Pediatric AI Scribe",
"short_name": "PedScribe",
"description": "AI-powered pediatric clinical documentation",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

37
public/sw.js Normal file
View file

@ -0,0 +1,37 @@
var CACHE_NAME = 'pedscribe-v2';
var urlsToCache = [
'/',
'/css/styles.css',
'/js/app.js',
'/js/auth.js',
'/js/milestonesData.js',
'/js/milestones.js',
'/js/liveEncounter.js',
'/js/voiceDictation.js',
'/js/hospitalCourse.js',
'/js/chartReview.js',
'/js/soap.js',
'/js/nextcloud.js',
'/manifest.json'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', function(event) {
// Network first for API calls, cache first for static assets
if (event.request.url.includes('/api/')) {
event.respondWith(fetch(event.request));
} else {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
}
});

71
server.js Normal file
View file

@ -0,0 +1,71 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const path = require('path');
const http = require('http');
const app = express();
const server = http.createServer(app);
// Security
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
}));
app.use(cors());
app.use(cookieParser());
app.use(express.json({ limit: '50mb' }));
// Rate limiting
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 60,
message: { error: 'Too many requests, slow down' }
});
app.use('/api/', apiLimiter);
// Static files (login page accessible without auth)
app.use(express.static(path.join(__dirname, 'public')));
// Routes
app.use('/api/auth', require('./src/routes/auth'));
app.use('/api', require('./src/routes/transcribe'));
app.use('/api', require('./src/routes/hpi'));
app.use('/api', require('./src/routes/hospitalCourse'));
app.use('/api', require('./src/routes/chartReview'));
app.use('/api', require('./src/routes/milestones'));
app.use('/api', require('./src/routes/soap'));
app.use('/api', require('./src/routes/tts'));
app.use('/api', require('./src/routes/nextcloud'));
app.use('/api', require('./src/routes/refine'));
// Models endpoint
const { AVAILABLE_MODELS } = require('./src/utils/models');
app.get('/api/models', (req, res) => res.json({ models: AVAILABLE_MODELS }));
// Health
app.get('/api/health', (req, res) => {
res.json({
status: 'running',
timestamp: new Date().toISOString(),
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing'
});
});
// SPA fallback
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log('');
console.log('==========================================');
console.log('🏥 PEDIATRIC AI SCRIBE v2.0');
console.log('==========================================');
console.log(`🌐 http://localhost:${PORT}`);
console.log('==========================================');
});

47
src/db/database.js Normal file
View file

@ -0,0 +1,47 @@
const Database = require('better-sqlite3');
const path = require('path');
const DB_PATH = path.join(__dirname, '../../data/scribe.db');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
email_verified INTEGER DEFAULT 0,
verify_token TEXT,
verify_expires INTEGER,
totp_secret TEXT,
totp_enabled INTEGER DEFAULT 0,
passkey_credentials TEXT,
nextcloud_url TEXT,
nextcloud_user TEXT,
nextcloud_token TEXT,
nextcloud_folder TEXT DEFAULT '/PediatricScribe',
reset_token TEXT,
reset_expires INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
timestamp TEXT DEFAULT (datetime('now'))
);
`);
// Add columns if upgrading from v1
try { db.exec("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0"); } catch(e) {}
try { db.exec("ALTER TABLE users ADD COLUMN verify_token TEXT"); } catch(e) {}
try { db.exec("ALTER TABLE users ADD COLUMN verify_expires INTEGER"); } catch(e) {}
try { db.exec("ALTER TABLE users ADD COLUMN nextcloud_folder TEXT DEFAULT '/PediatricScribe'"); } catch(e) {}
console.log('✅ Database initialized');
module.exports = db;

44
src/middleware/auth.js Normal file
View file

@ -0,0 +1,44 @@
const jwt = require('jsonwebtoken');
const db = require('../db/database');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
function authMiddleware(req, res, next) {
// Get token from header or cookie
let token = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (req.cookies && req.cookies.token) {
token = req.cookies.token;
}
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare('SELECT id, email, name, totp_enabled FROM users WHERE id = ?').get(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function optionalAuth(req, res, next) {
try {
authMiddleware(req, res, next);
} catch (e) {
next();
}
}
module.exports = { authMiddleware, optionalAuth, JWT_SECRET };

282
src/routes/auth.js Normal file
View file

@ -0,0 +1,282 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const crypto = require('crypto');
const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
// ============================================================
// EMAIL HELPER
// ============================================================
async function sendEmail(to, subject, html) {
if (!process.env.SMTP_HOST) {
console.log('[Email] SMTP not configured. Would send to:', to);
console.log('[Email] Subject:', subject);
return false;
}
try {
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});
await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: to,
subject: subject,
html: html
});
console.log('[Email] Sent to:', to);
return true;
} catch (err) {
console.error('[Email] Failed:', err.message);
return false;
}
}
// ============================================================
// REGISTER (with email verification)
// ============================================================
router.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) return res.status(400).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(password, 12);
const verifyToken = crypto.randomBytes(32).toString('hex');
const verifyExpires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
const result = db.prepare(
'INSERT INTO users (email, password, name, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, 0)'
).run(email.toLowerCase(), hash, name, verifyToken, verifyExpires);
// Send verification email
const verifyUrl = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
`<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px;">
<h2 style="color:#2563eb;">Welcome to Pediatric AI Scribe!</h2>
<p>Hi ${name},</p>
<p>Please verify your email address by clicking the button below:</p>
<p style="text-align:center;margin:30px 0;">
<a href="${verifyUrl}" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a>
</p>
<p style="color:#6b7280;font-size:13px;">Or copy this link: ${verifyUrl}</p>
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours.</p>
</div>`
);
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(result.lastInsertRowid, 'register', req.ip);
// If SMTP not configured, auto-verify (for development)
if (!process.env.SMTP_HOST) {
db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL WHERE id = ?').run(result.lastInsertRowid);
const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' });
return res.json({
success: true,
token,
user: { id: result.lastInsertRowid, email: email.toLowerCase(), name, email_verified: 1 },
message: 'Account created (auto-verified, SMTP not configured)'
});
}
res.json({
success: true,
needsVerification: true,
message: 'Account created! Check your email for verification link.'
});
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: err.message });
}
});
// ============================================================
// VERIFY EMAIL
// ============================================================
router.get('/verify-email', (req, res) => {
const { token } = req.query;
if (!token) return res.status(400).send('Missing verification token');
const user = db.prepare('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?').get(token, Date.now());
if (!user) {
return res.send(`
<html><body style="font-family:sans-serif;text-align:center;padding:60px;">
<h2 style="color:#ef4444;"> Invalid or Expired Link</h2>
<p>This verification link is invalid or has expired.</p>
<p><a href="${process.env.APP_URL || '/'}">Go to app</a></p>
</body></html>
`);
}
db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(user.id);
db.prepare('INSERT INTO audit_log (user_id, action) VALUES (?, ?)').run(user.id, 'email_verified');
res.send(`
<html><body style="font-family:sans-serif;text-align:center;padding:60px;">
<h2 style="color:#10b981;"> Email Verified!</h2>
<p>Welcome, ${user.name}! Your account is now active.</p>
<p><a href="${process.env.APP_URL || '/'}" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Open App</a></p>
</body></html>
`);
});
// ============================================================
// RESEND VERIFICATION
// ============================================================
router.post('/resend-verification', async (req, res) => {
try {
const { email } = req.body;
const user = db.prepare('SELECT id, name, email_verified FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) return res.json({ success: true, message: 'If account exists, verification email sent' });
if (user.email_verified) return res.json({ success: true, message: 'Email already verified. You can log in.' });
const verifyToken = crypto.randomBytes(32).toString('hex');
const verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
db.prepare('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?').run(verifyToken, verifyExpires, user.id);
const verifyUrl = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
`<p>Hi ${user.name},</p><p>Click to verify: <a href="${verifyUrl}">${verifyUrl}</a></p><p>Expires in 24 hours.</p>`
);
res.json({ success: true, message: 'Verification email sent' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============================================================
// LOGIN (checks email verification)
// ============================================================
router.post('/login', async (req, res) => {
try {
const { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(user.id, 'login_failed', req.ip);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check email verification
if (!user.email_verified) {
return res.status(403).json({
error: 'Email not verified',
needsVerification: true,
message: 'Please check your email for the verification link.'
});
}
// Check 2FA
if (user.totp_enabled) {
if (!totpCode) return res.json({ requires2FA: true });
const verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' });
}
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(user.id, 'login', req.ip);
res.json({
success: true,
token,
user: { id: user.id, email: user.email, name: user.name, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============================================================
// 2FA SETUP / VERIFY / DISABLE (same as before)
// ============================================================
router.post('/setup-2fa', authMiddleware, async (req, res) => {
try {
const secret = speakeasy.generateSecret({ name: `PedScribe (${req.user.email})`, issuer: 'Pediatric AI Scribe' });
db.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run(secret.base32, req.user.id);
const qrUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ success: true, secret: secret.base32, qrCode: qrUrl });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.post('/verify-2fa', authMiddleware, async (req, res) => {
try {
const user = db.prepare('SELECT totp_secret FROM users WHERE id = ?').get(req.user.id);
const verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
if (!verified) return res.status(400).json({ error: 'Invalid code' });
db.prepare('UPDATE users SET totp_enabled = 1 WHERE id = ?').run(req.user.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.post('/disable-2fa', authMiddleware, async (req, res) => {
try {
const user = db.prepare('SELECT password FROM users WHERE id = ?').get(req.user.id);
const valid = await bcrypt.compare(req.body.password, user.password);
if (!valid) return res.status(401).json({ error: 'Wrong password' });
db.prepare('UPDATE users SET totp_enabled = 0, totp_secret = NULL WHERE id = ?').run(req.user.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// FORGOT / RESET PASSWORD (same as before)
// ============================================================
router.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body;
const user = db.prepare('SELECT id, name FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
const token = crypto.randomBytes(32).toString('hex');
db.prepare('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?').run(token, Date.now() + 3600000, user.id);
await sendEmail(email, 'Password Reset - Pediatric AI Scribe',
`<p>Hi ${user.name},</p><p>Reset your password: <a href="${process.env.APP_URL}/reset-password?token=${token}">Click here</a></p><p>Expires in 1 hour.</p>`
);
res.json({ success: true, message: 'If account exists, reset email sent' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.post('/reset-password', async (req, res) => {
try {
const { token, newPassword } = req.body;
if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' });
const user = db.prepare('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?').get(token, Date.now());
if (!user) return res.status(400).json({ error: 'Invalid or expired token' });
const hash = await bcrypt.hash(newPassword, 12);
db.prepare('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?').run(hash, user.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ============================================================
// GET CURRENT USER
// ============================================================
router.get('/me', authMiddleware, (req, res) => {
const user = db.prepare('SELECT id, email, name, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?').get(req.user.id);
res.json({ user });
});
module.exports = router;

62
src/routes/chartReview.js Normal file
View file

@ -0,0 +1,62 @@
const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
// Generate chart review
router.post('/generate-chart-review', async (req, res) => {
try {
const {
type, // 'outpatient' | 'subspecialty' | 'ed'
patientAge, patientGender, pmh,
visits, // Array of { date, type, content }
subspecialty, // Array of { date, specialistName, specialty, content }
edVisits, // Array of { date, content, labs }
labs, // Array of { date, values }
model,
additionalInstructions
} = req.body;
let prompt;
let clinicalData = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`;
if (pmh) clinicalData += `\nPMH: ${pmh}`;
if (type === 'ed' || (edVisits && edVisits.length > 0)) {
prompt = PROMPTS.chartReviewED;
(edVisits || []).forEach(v => {
clinicalData += `\n\n=== ED VISIT (${v.date}) ===\n${v.content}`;
if (v.labs) clinicalData += `\nLabs: ${v.labs}`;
});
} else if (type === 'subspecialty' || (subspecialty && subspecialty.length > 0)) {
prompt = PROMPTS.chartReviewSubspecialty;
(subspecialty || []).forEach(s => {
clinicalData += `\n\n=== ${(s.specialty || 'Subspecialty').toUpperCase()}${s.specialistName || 'Unknown'} (${s.date}) ===\n${s.content}`;
});
} else {
prompt = PROMPTS.chartReviewOutpatient;
(visits || []).forEach(v => {
clinicalData += `\n\n=== ${(v.type || 'Visit').toUpperCase()} (${v.date}) ===\n${v.content}`;
});
}
if (labs && labs.length > 0) {
clinicalData += '\n\n=== LABS ===';
labs.forEach(l => { clinicalData += `\n${l.date}: ${l.values}`; });
}
if (additionalInstructions) {
prompt += `\n\nADDITIONAL INSTRUCTIONS:\n${additionalInstructions}`;
}
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: clinicalData }
], { model, maxTokens: 4000 });
res.json({ success: true, review: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View file

@ -0,0 +1,138 @@
const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
// Generate hospital course
router.post('/generate-hospital-course', async (req, res) => {
try {
const {
notes, // Array of { date, type, content }
edNote, // { date, content, labs }
hAndP, // { date, content }
labs, // Array of { date, values }
patientAge, patientGender, pmh,
setting, // 'floor' | 'picu' | 'nicu' | 'psych'
los, // length of stay
model,
formatPreference, // 'auto' | 'prose' | 'dayByDay' | 'organSystem'
additionalInstructions
} = req.body;
if (!notes || notes.length === 0) {
return res.status(400).json({ error: 'No notes provided' });
}
// Determine format
let prompt;
let format = formatPreference || 'auto';
if (format === 'auto') {
if (setting === 'picu' || setting === 'nicu') {
format = 'organSystem';
} else if (setting === 'psych') {
format = 'psych';
} else if (los && parseInt(los) > 3) {
format = 'dayByDay';
} else {
format = 'prose';
}
}
switch (format) {
case 'organSystem': prompt = PROMPTS.hospitalCourseICU; break;
case 'dayByDay': prompt = PROMPTS.hospitalCourseLong; break;
case 'psych': prompt = PROMPTS.hospitalCoursePsych; break;
default: prompt = PROMPTS.hospitalCourseShort;
}
// Build the complete clinical data
let clinicalData = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`;
if (pmh) clinicalData += `\nPMH: ${pmh}`;
if (setting) clinicalData += `\nSetting: ${setting}`;
if (los) clinicalData += `\nLength of Stay: ${los} days`;
clinicalData += `\nFormat: ${format}`;
if (edNote) {
clinicalData += `\n\n=== ED NOTE (${edNote.date || 'date unknown'}) ===\n${edNote.content}`;
if (edNote.labs) clinicalData += `\nED Labs: ${edNote.labs}`;
}
if (hAndP) {
clinicalData += `\n\n=== H&P (${hAndP.date || 'date unknown'}) ===\n${hAndP.content}`;
}
// Sort notes by date
const sortedNotes = [...notes].sort((a, b) => new Date(a.date) - new Date(b.date));
sortedNotes.forEach((note, i) => {
clinicalData += `\n\n=== ${(note.type || 'Progress Note').toUpperCase()} - ${note.date || `Note ${i + 1}`} ===\n${note.content}`;
});
if (labs && labs.length > 0) {
clinicalData += '\n\n=== LABS ===';
labs.forEach(lab => {
clinicalData += `\n${lab.date}: ${lab.values}`;
});
}
if (additionalInstructions) {
prompt += `\n\nADDITIONAL INSTRUCTIONS FROM PHYSICIAN:\n${additionalInstructions}`;
}
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: clinicalData }
], { model, maxTokens: 6000 });
res.json({
success: true,
hospitalCourse: result.content,
format: format,
model: result.model
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Ask for clarification/missing info
router.post('/hospital-course-clarify', async (req, res) => {
try {
const { currentDraft, notes } = req.body;
const result = await callAI([
{ role: 'system', content: PROMPTS.askClarification },
{ role: 'user', content: `Current draft:\n${currentDraft}\n\nSource notes:\n${JSON.stringify(notes)}` }
], { model: req.body.model });
res.json({ success: true, questions: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Add discharge day info
router.post('/hospital-course-update', async (req, res) => {
try {
const { currentCourse, updates, model, instructions } = req.body;
if (!currentCourse) return res.status(400).json({ error: 'No current course' });
let userMsg = `CURRENT HOSPITAL COURSE:\n${currentCourse}\n\n`;
if (updates) userMsg += `NEW UPDATES TO ADD:\n${updates}\n\n`;
if (instructions) userMsg += `INSTRUCTIONS:\n${instructions}`;
const result = await callAI([
{ role: 'system', content: `You are updating a hospital course document with new information.
Add the new information in the appropriate place. Maintain the same format and style.
If adding discharge day information, add it at the end.
${PROMPTS.refine.split('\n').slice(0, -1).join('\n')}` },
{ role: 'user', content: userMsg }
], { model, maxTokens: 6000 });
res.json({ success: true, hospitalCourse: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

44
src/routes/hpi.js Normal file
View file

@ -0,0 +1,44 @@
const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
// HPI from encounter
router.post('/generate-hpi-encounter', async (req, res) => {
try {
const { transcript, patientAge, patientGender, model, setting } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' });
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiEncounter;
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nTRANSCRIPT:\n${transcript}` }
], { model });
res.json({ success: true, hpi: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// HPI from dictation
router.post('/generate-hpi-dictation', async (req, res) => {
try {
const { transcript, patientAge, patientGender, model, setting } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' });
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiDictation;
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nDICTATION:\n${transcript}` }
], { model });
res.json({ success: true, hpi: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

66
src/routes/milestones.js Normal file
View file

@ -0,0 +1,66 @@
const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
// Narrative format
router.post('/generate-milestone-narrative', async (req, res) => {
try {
const { milestones, ageGroup, patientAge, patientGender, model, format } = req.body;
if (!milestones) return res.status(400).json({ error: 'No milestones' });
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 (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 } });
}
let milestoneText = '';
if (achieved.length > 0) {
milestoneText += 'ACHIEVED:\n';
achieved.forEach(m => { milestoneText += ` - [${m.domain}] ${m.milestone}\n`; });
}
if (notAchieved.length > 0) {
milestoneText += '\nNOT YET ACHIEVED:\n';
notAchieved.forEach(m => { milestoneText += ` - [${m.domain}] ${m.milestone}\n`; });
}
// Choose narrative or list format
const prompt = (format === 'list') ? PROMPTS.milestoneList : PROMPTS.milestoneNarrative;
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nAge Group: ${ageGroup}\n\n${milestoneText}\n\nAssessed: ${achieved.length + notAchieved.length} | Achieved: ${achieved.length} | Not achieved: ${notAchieved.length} | Not assessed (OMIT): ${notAssessed.length}` }
], { model });
res.json({
success: true,
narrative: result.content,
model: result.model,
summary: { achieved: achieved.length, notAchieved: notAchieved.length, notAssessed: notAssessed.length }
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3-sentence summary
router.post('/generate-milestone-summary', async (req, res) => {
try {
const { narrative, ageGroup, patientAge, patientGender, model } = req.body;
if (!narrative) return res.status(400).json({ error: 'No narrative' });
const result = await callAI([
{ role: 'system', content: PROMPTS.milestoneSummary },
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nAge Group: ${ageGroup}\n\nNARRATIVE:\n${narrative}` }
], { model, maxTokens: 500 });
res.json({ success: true, summary: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

140
src/routes/nextcloud.js Normal file
View file

@ -0,0 +1,140 @@
const express = require('express');
const router = express.Router();
const axios = require('axios');
const { authMiddleware } = require('../middleware/auth');
const db = require('../db/database');
// Connect
router.post('/nextcloud/connect', authMiddleware, async (req, res) => {
try {
const { nextcloudUrl, username, appPassword, folder } = req.body;
if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' });
var cleanUrl = nextcloudUrl.replace(/\/+$/, '');
var targetFolder = (folder || '/PediatricScribe').replace(/\/+$/, '');
// Test connection
try {
await axios({ method: 'PROPFIND', url: `${cleanUrl}/remote.php/dav/files/${username}/`, auth: { username, password: appPassword }, headers: { Depth: '0' } });
} catch (e) {
return res.status(400).json({ error: 'Cannot connect. Check URL, username, and app password.' });
}
// Create folder
try {
var parts = targetFolder.split('/').filter(Boolean);
var current = '';
for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i];
try {
await axios({ method: 'MKCOL', url: `${cleanUrl}/remote.php/dav/files/${username}${current}/`, auth: { username, password: appPassword } });
} catch (e) { /* exists */ }
}
} catch (e) { /* folder exists */ }
db.prepare('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?')
.run(cleanUrl, username, appPassword, targetFolder, req.user.id);
res.json({ success: true, message: `Connected! Files saved to ${targetFolder}/` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// List folders (for picker)
router.get('/nextcloud/folders', authMiddleware, async (req, res) => {
try {
var user = db.prepare('SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?').get(req.user.id);
if (!user.nextcloud_url) return res.status(400).json({ error: 'Not connected' });
var path = req.query.path || '/';
var url = `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${path}`;
var response = await axios({
method: 'PROPFIND',
url: url,
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
headers: { Depth: '1' }
});
// Parse WebDAV XML response for folders
var folders = [];
var matches = response.data.match(/<d:href>([^<]+)<\/d:href>/g) || [];
matches.forEach(function(match) {
var href = match.replace(/<\/?d:href>/g, '');
if (href.endsWith('/')) {
var decoded = decodeURIComponent(href);
var parts = decoded.split('/').filter(Boolean);
var name = parts[parts.length - 1];
if (name && name !== user.nextcloud_user) {
folders.push({ name: name, path: decoded.replace(/.*\/remote\.php\/dav\/files\/[^/]+/, '') });
}
}
});
res.json({ success: true, folders: folders, currentPath: path });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Export with subfolder support
router.post('/nextcloud/export', authMiddleware, async (req, res) => {
try {
const { content, filename, type, subfolder } = req.body;
var user = db.prepare('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?').get(req.user.id);
if (!user.nextcloud_url || !user.nextcloud_token) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
var baseFolder = user.nextcloud_folder || '/PediatricScribe';
var targetPath = baseFolder;
// Create date subfolder automatically
var today = new Date().toISOString().split('T')[0];
targetPath += '/' + today;
// Additional subfolder if specified
if (subfolder) targetPath += '/' + subfolder.replace(/[^a-zA-Z0-9-_ ]/g, '');
// Create folder path
var parts = targetPath.split('/').filter(Boolean);
var current = '';
for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i];
try {
await axios({ method: 'MKCOL', url: `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${current}/`, auth: { username: user.nextcloud_user, password: user.nextcloud_token } });
} catch (e) { /* exists */ }
}
var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
var filePath = `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${targetPath}/${safeName}.txt`;
await axios({
method: 'PUT',
url: filePath,
data: content,
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
res.json({ success: true, message: `Saved to ${targetPath}/${safeName}.txt`, path: `${targetPath}/${safeName}.txt` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Update folder setting
router.post('/nextcloud/set-folder', authMiddleware, (req, res) => {
const { folder } = req.body;
db.prepare('UPDATE users SET nextcloud_folder = ? WHERE id = ?').run(folder || '/PediatricScribe', req.user.id);
res.json({ success: true });
});
// Disconnect
router.post('/nextcloud/disconnect', authMiddleware, (req, res) => {
db.prepare('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?').run(req.user.id);
res.json({ success: true });
});
module.exports = router;

57
src/routes/refine.js Normal file
View file

@ -0,0 +1,57 @@
const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
// Refine/modify any generated document
router.post('/refine', async (req, res) => {
try {
const { currentDocument, instructions, model } = req.body;
if (!currentDocument) return res.status(400).json({ error: 'No document to refine' });
if (!instructions) return res.status(400).json({ error: 'No instructions provided' });
const result = await callAI([
{ role: 'system', content: PROMPTS.refine },
{ role: 'user', content: `CURRENT DOCUMENT:\n${currentDocument}\n\nUSER INSTRUCTIONS:\n${instructions}` }
], { model, maxTokens: 6000 });
res.json({ success: true, refined: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Shorten any document
router.post('/shorten', async (req, res) => {
try {
const { document, model } = req.body;
if (!document) return res.status(400).json({ error: 'No document' });
const result = await callAI([
{ role: 'system', content: PROMPTS.shortenDocument },
{ role: 'user', content: document }
], { model });
res.json({ success: true, shortened: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Ask AI for clarification questions
router.post('/clarify', async (req, res) => {
try {
const { document, context, model } = req.body;
const result = await callAI([
{ role: 'system', content: PROMPTS.askClarification },
{ role: 'user', content: `Document type: ${context || 'medical document'}\n\nDOCUMENT:\n${document}` }
], { model, maxTokens: 1000 });
res.json({ success: true, questions: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

33
src/routes/soap.js Normal file
View file

@ -0,0 +1,33 @@
const express = require('express');
const router = express.Router();
const { callAI } = require('../utils/ai');
const PROMPTS = require('../utils/prompts');
router.post('/generate-soap', async (req, res) => {
try {
const { transcript, patientAge, patientGender, model, type, additionalInstructions } = req.body;
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' });
let prompt;
switch (type) {
case 'subjective': prompt = PROMPTS.soapSubjective; break;
case 'full':
default: prompt = PROMPTS.soapFull;
}
if (additionalInstructions) {
prompt += `\n\nADDITIONAL INSTRUCTIONS:\n${additionalInstructions}`;
}
const result = await callAI([
{ role: 'system', content: prompt },
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\n\nINPUT:\n${transcript}` }
], { model });
res.json({ success: true, soap: result.content, model: result.model });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

24
src/routes/transcribe.js Normal file
View file

@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const { whisperClient } = require('../utils/ai');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
router.post('/transcribe', upload.single('audio'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No audio' });
if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured' });
const file = new File([req.file.buffer], 'audio.webm', { type: req.file.mimetype || 'audio/webm' });
const result = await whisperClient.audio.transcriptions.create({
file, model: 'whisper-1', language: 'en',
prompt: 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications.'
});
res.json({ success: true, text: result.text });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

22
src/routes/tts.js Normal file
View file

@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
const axios = require('axios');
router.post('/text-to-speech', async (req, res) => {
try {
if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'Not configured' });
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: req.body.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' });
}
});
module.exports = router;

48
src/utils/ai.js Normal file
View file

@ -0,0 +1,48 @@
const { OpenAI } = require('openai');
const { DEFAULT_MODEL, FALLBACK_MODEL } = require('./models');
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'
}
});
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: configured');
}
async function callAI(messages, options = {}) {
const model = options.model || DEFAULT_MODEL;
const temperature = options.temperature || 0.3;
const maxTokens = options.maxTokens || 4000;
try {
console.log(`[AI] ${model} | ${messages[messages.length - 1].content.substring(0, 60)}...`);
const completion = await openrouter.chat.completions.create({
model, messages, temperature, max_tokens: maxTokens
});
const content = completion.choices[0].message.content;
console.log(`[AI] Done: ${content.length} chars`);
return { success: true, content, model, usage: completion.usage };
} catch (err) {
console.error(`[AI] ${model} failed: ${err.message}`);
if (model !== FALLBACK_MODEL) {
try {
const completion = await openrouter.chat.completions.create({
model: FALLBACK_MODEL, messages, temperature, max_tokens: maxTokens
});
return { success: true, content: completion.choices[0].message.content, model: FALLBACK_MODEL, fallback: true };
} catch (err2) {
throw new Error(`All models failed: ${err2.message}`);
}
}
throw err;
}
}
module.exports = { callAI, whisperClient, openrouter };

35
src/utils/models.js Normal file
View file

@ -0,0 +1,35 @@
const AVAILABLE_MODELS = [
// Best value
{ id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' },
{ id: 'google/gemini-2.5-pro', name: 'Gemini Pro 2.5', cost: '~$0.01', tag: 'SMART', category: 'premium' },
{ id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking', cost: '~$0.002', tag: 'REASONING', category: 'smart' },
// DeepSeek
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', cost: '~$0.001', tag: 'CHEAP', category: 'fast' },
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', cost: '~$0.005', tag: 'REASONING', category: 'smart' },
{ id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 Free', cost: 'FREE', tag: 'FREE', category: 'free' },
// Qwen
{ id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', cost: '~$0.005', tag: 'SMART', category: 'smart' },
{ id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B Free', cost: 'FREE', tag: 'FREE', category: 'free' },
// Meta Llama
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', cost: '~$0.002', tag: 'NEW', category: 'smart' },
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', cost: '~$0.001', tag: 'GOOD', category: 'fast' },
// Premium
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'PREMIUM', category: 'premium' },
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'SMART', category: 'smart' },
{ id: 'openai/o4-mini', name: 'o4-mini (Reasoning)', cost: '~$0.01', tag: 'REASONING', category: 'premium' },
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', cost: '~$0.015', tag: 'PREMIUM', category: 'premium' },
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet', cost: '~$0.015', tag: 'MEDICAL', category: 'premium' },
// Mistral
{ id: 'mistralai/mistral-large-2411', name: 'Mistral Large', cost: '~$0.006', tag: 'GOOD', category: 'smart' },
{ id: 'mistralai/mistral-small-3.2-24b-instruct:free', name: 'Mistral Small Free', cost: 'FREE', tag: 'FREE', category: 'free' }
];
const DEFAULT_MODEL = 'google/gemini-2.5-flash';
const FALLBACK_MODEL = 'deepseek/deepseek-chat-v3-0324';
module.exports = { AVAILABLE_MODELS, DEFAULT_MODEL, FALLBACK_MODEL };

202
src/utils/prompts.js Normal file
View file

@ -0,0 +1,202 @@
// ============================================================
// ALL AI SYSTEM PROMPTS — centralized, no confabulation
// ============================================================
const CORE_RULES = `
CRITICAL RULES FOR ALL OUTPUTS:
- NEVER fabricate, invent, or assume any clinical information not explicitly provided
- If information is missing or unclear, state what is missing or ask for clarification
- Use professional medical language
- Be concise but thorough
- Output ONLY the requested text, no meta-commentary
`;
const PROMPTS = {
// ======================== HPI ========================
hpiEncounter: `You are an expert pediatric medical scribe.
Generate a professional HPI from the doctor-patient encounter transcript.
${CORE_RULES}
- Write in third person
- Use OLDCARTS framework (Onset, Location, Duration, Character, Aggravating/Alleviating, Radiation, Timing, Severity)
- Include pertinent positives and negatives MENTIONED
- Note the historian (parent, guardian)
- Chronological flow`,
hpiDictation: `You are an expert medical scribe. Restructure physician dictation into a polished HPI.
${CORE_RULES}
- Reorganize chronologically
- Convert casual speech to professional medical language
- Keep ALL clinical details
- Remove filler words, repetitions
- Fix speech-to-text errors in medical terms`,
hpiInpatient: `You are an expert inpatient pediatric medical scribe.
Generate a professional INPATIENT HPI. This is different from outpatient:
${CORE_RULES}
- Start with: "[Age] [gender] with [PMH] who presents with / admitted for..."
- Include number of previous hospitalizations if mentioned
- Include ED course before admission if mentioned
- Include relevant labs/imaging from ED
- Include what was done in ED (IVF, meds, etc)
- More detailed than outpatient HPI
- Include relevant social history if pertinent`,
// ======================== HOSPITAL COURSE ========================
hospitalCourseShort: `You are an expert pediatric inpatient medical documentation specialist.
Generate a hospital course summary in PROSE format for a short stay (3 days).
${CORE_RULES}
- Write as flowing narrative paragraphs
- Start with arrival vital signs and initial assessment
- Chronological progression of care
- Include treatments, response to treatment, diet progression
- End with discharge readiness statement
- Include "medically and socially cleared for discharge" if appropriate
- Mention key labs and their trends
- Do NOT organize by organ system for short stays unless ICU`,
hospitalCourseLong: `You are an expert pediatric inpatient medical documentation specialist.
Generate a hospital course organized BY DAY OF HOSPITALIZATION.
${CORE_RULES}
- Format: Day 1 (Date), Day 2 (Date), etc.
- H&P is before Day 1. First progress note = Day 1
- For each day: key events, assessments, interventions, response
- Include relevant labs for each day
- Include changes in medications or treatment plan
- Include consultant recommendations if any
- End with discharge day summary`,
hospitalCourseICU: `You are an expert pediatric ICU documentation specialist.
Generate a hospital course organized BY ORGAN SYSTEM for ICU stay.
${CORE_RULES}
Format:
- LOS: [days]
- General: overall course summary
- Neurologic: mental status, neuro exams
- Respiratory: ventilation, O2 support, treatments, CXR findings, ABG/VBG
- Cardiovascular: hemodynamics, HR, BP, perfusion, cardiac monitoring
- FEN/GI: nutrition, fluids, electrolytes, GI function
- Endocrine: if relevant
- Hematology: CBC trends, coags, transfusions
- GU/Renal: UOP, BUN/Cr, renal function
- ID: fever curve, cultures, antibiotics, respiratory panels
- Dermatology: if relevant
- Psychosocial: family involvement, social work
- Include specific values (vital signs, lab values) not vague statements
- Be precise with medication names, doses, frequencies`,
hospitalCoursePsych: `You are an expert pediatric documentation specialist for psychiatric admissions.
Generate a hospital course for a psychiatric/behavioral health admission.
${CORE_RULES}
- Start with arrival vitals and physical exam
- Note medical clearance status
- Psychiatric team assessment
- Safety plan and interventions
- Family meetings, ACS involvement if any
- Include behavioral observations
- End with clearance for discharge and disposition`,
// ======================== CHART REVIEW ========================
chartReviewOutpatient: `You are an expert at summarizing outpatient medical records for chart review/precharting.
${CORE_RULES}
Format:
- Start with: "[Name] is a [age] [gender] with [conditions] here today for [reason]"
- Include relevant past medical/surgical history
- Last visit summary: date, what was discussed, plan, labs
- Recent labs with dates and values
- Current medications
- Subspecialty notes: date, specialist, findings, recommendations
- Pending items
- Be concise this is for quick precharting reference`,
chartReviewSubspecialty: `You are an expert at summarizing subspecialty consultation notes.
${CORE_RULES}
For each subspecialty note:
- Date of visit
- Specialist name/type
- Key findings
- Assessment
- Recommendations/plan
- Follow-up plan`,
chartReviewED: `You are summarizing an ED visit for chart review/hospital course.
${CORE_RULES}
Include:
- Date and time of ED visit
- Chief complaint
- Key findings (vitals, exam, labs, imaging)
- Interventions (IVF, medications, procedures)
- Disposition (admitted to where, discharge)`,
// ======================== SOAP NOTE ========================
soapFull: `You are an expert medical scribe. Generate a complete SOAP note.
${CORE_RULES}
Format:
S (Subjective): Chief complaint, HPI, ROS, medications, allergies
O (Objective): Vitals, physical exam, labs, imaging
A (Assessment): Diagnoses with reasoning
P (Plan): Treatment plan, medications, follow-up, patient education`,
soapSubjective: `You are an expert medical scribe. Generate ONLY the Subjective portion of a SOAP note.
${CORE_RULES}
Include:
- Chief complaint
- HPI (OLDCARTS)
- Relevant ROS
- Current medications if mentioned
- Allergies if mentioned`,
// ======================== MILESTONES ========================
milestoneNarrative: `You are a pediatric documentation specialist.
Generate a professional developmental assessment NARRATIVE.
${CORE_RULES}
- ONLY write about milestones listed (marked Yes or No)
- Milestones NOT listed were NOT ASSESSED do NOT mention them
- Do NOT say "patient cannot do X" for unassessed milestones
- Group by domain: Gross Motor, Fine Motor, Language, Social/Emotional, Cognitive
- Write as flowing narrative paragraphs
- Start with overall developmental status summary
- End with overall impression`,
milestoneList: `You are a pediatric documentation specialist.
Generate a developmental assessment as a STRUCTURED LIST with subsections.
${CORE_RULES}
- ONLY list milestones that were assessed (Yes or No)
- Do NOT list unassessed milestones
- Group by domain with clear subsection headers
- Format: "✓ [milestone]" for achieved, "✗ [milestone]" for not achieved
- Add brief overall summary at end`,
milestoneSummary: `You are a pediatric documentation specialist.
Condense the developmental narrative into EXACTLY 3 sentences.
Sentence 1: Overall developmental status
Sentence 2: Key strengths domains on track
Sentence 3: Any concerns or delays. If none, state no concerns identified.
EXACTLY 3 sentences. Professional language. Only mention assessed milestones.`,
// ======================== REFINE ========================
refine: `You are a medical documentation editor.
The user wants to modify a previously generated medical document.
${CORE_RULES}
- Apply the user's requested changes
- Maintain professional medical language
- Keep all information not specifically asked to change
- Output the complete revised document`,
shortenDocument: `You are a medical documentation editor.
Shorten the following medical document while keeping all critical clinical information.
${CORE_RULES}
- Remove redundant phrases
- Combine related sentences
- Keep all diagnoses, medications, vital signs, lab values
- Keep all clinical decisions and plans
- Aim for roughly 50% shorter`,
askClarification: `You are reviewing medical documentation for completeness.
Identify what information is MISSING or UNCLEAR.
List specific questions the physician should answer to complete the document.
Be specific: "What was the discharge weight?" not "Is there more info?"
List as numbered questions.`
};
module.exports = PROMPTS;