- 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
499 lines
17 KiB
JavaScript
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('');
|
|
});
|