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