pediatric-ai-scribe/server.js
ifedan-ed 993e46f720 Initial commit: Pediatric AI Scribe v1.0.0
- Live encounter recording → HPI generation
- Physician voice dictation → polished HPI
- Developmental milestones checklist (AAP/Nelson)
- 3-sentence summary generator
- OpenRouter integration with model selector
- Docker support
- Mobile responsive UI
2026-03-17 21:46:05 -04:00

499 lines
17 KiB
JavaScript

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