Security: - Add session management: users can view/revoke active sessions in Settings - Add password change in Settings (requires current password, HIBP check) - Force logout all sessions on password reset - Fix logout to destroy server-side session (was only clearing cookie) - Add trust proxy for correct client IP in rate limiting and audit logs - Add CORS support for multiple domains (CORS_ORIGINS env var) - Add HIBP breach check endpoint and inline warnings on password fields Audit logging: - Add audit logging to all 24 PHI-handling endpoints across 13 route files - Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud - All fire-and-forget (no response delay) AI improvements: - Refine now includes original source material (transcript, notes, labs) so AI can reference the full input when modifying output - Add correction tracking (trackAIOutput) to sick visit and well visit tabs - Fix sickvisit missing from encounter save noteIdMap UI fixes: - Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay) - Fix encounter recording: hide record button during recording (was showing two stop buttons) - Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons - Enlarge AI instructions textarea in Learning Hub CMS Domain: - Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
174 lines
8.8 KiB
JavaScript
174 lines
8.8 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const multer = require('multer');
|
|
const { whisperClient, litellmClient } = require('../utils/ai');
|
|
const { transcribeWithAWS, isAWSTranscribeConfigured } = require('../utils/transcribeAWS');
|
|
const { transcribeWithLocal, isLocalWhisperConfigured } = require('../utils/transcribeLocal');
|
|
const { transcribeWithGemini, isGoogleSTTConfigured } = require('../utils/transcribeGoogle');
|
|
const { authMiddleware } = require('../middleware/auth');
|
|
var logger = require('../utils/logger');
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
|
|
|
// Provider priority (auto-detect):
|
|
// TRANSCRIBE_PROVIDER=google → Vertex AI / Gemini (direct, HIPAA eligible)
|
|
// TRANSCRIBE_PROVIDER=aws → Amazon Transcribe (HIPAA eligible)
|
|
// TRANSCRIBE_PROVIDER=local → local whisper.cpp / faster-whisper
|
|
// TRANSCRIBE_PROVIDER=openai → OpenAI Whisper (direct)
|
|
// TRANSCRIBE_PROVIDER=litellm→ LiteLLM proxy (explicit only — audio routing bugs)
|
|
// Auto: google > aws > openai
|
|
function getTranscribeProvider() {
|
|
var env = process.env.TRANSCRIBE_PROVIDER;
|
|
if (env === 'google') return 'google';
|
|
if (env === 'aws') return 'aws';
|
|
if (env === 'local') return 'local';
|
|
if (env === 'openai') return 'openai';
|
|
if (env === 'litellm') return 'litellm';
|
|
// Auto-detect
|
|
if (isGoogleSTTConfigured()) return 'google';
|
|
if (isAWSTranscribeConfigured()) return 'aws';
|
|
return 'openai';
|
|
}
|
|
|
|
function isTranscribeAvailable() {
|
|
if (isGoogleSTTConfigured()) return true;
|
|
if (isLocalWhisperConfigured()) return true;
|
|
if (isAWSTranscribeConfigured()) return true;
|
|
if (litellmClient) return true;
|
|
if (whisperClient) return true;
|
|
return false;
|
|
}
|
|
|
|
var provider = getTranscribeProvider();
|
|
var medical = process.env.AWS_TRANSCRIBE_MEDICAL === 'true';
|
|
var available = isTranscribeAvailable();
|
|
console.log('🎙️ Transcribe provider:', provider +
|
|
(provider === 'aws' && medical ? ' (Medical)' : '') +
|
|
(provider === 'google' ? ' (model: ' + (process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash') + ')' : '') +
|
|
(available ? '' : ' (NOT CONFIGURED — browser speech only)'));
|
|
|
|
router.get('/transcribe/status', authMiddleware, (req, res) => {
|
|
res.json({ available: available, provider: available ? provider : 'none' });
|
|
});
|
|
|
|
router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, res) => {
|
|
try {
|
|
if (!req.file) return res.status(400).json({ error: 'No audio' });
|
|
var startTime = Date.now();
|
|
var fileSize = req.file.size;
|
|
|
|
// Get user's preferred STT model (if set), then admin DB default, then env
|
|
var db = require('../db/database');
|
|
var userPrefs = await db.get('SELECT stt_model FROM users WHERE id = ?', [req.user.id]);
|
|
var userModel = userPrefs?.stt_model;
|
|
var adminSttModel = await db.getSetting('stt.model') || '';
|
|
|
|
console.log('[Transcribe] Received ' + (fileSize / 1024).toFixed(0) + 'KB audio (' + (req.file.mimetype || 'unknown') + ') via ' + provider + (userModel ? ' (user model: ' + userModel + ')' : ''));
|
|
|
|
if (provider === 'google') {
|
|
var model = userModel || adminSttModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
|
|
var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm', model);
|
|
console.log('[Transcribe] Google/' + model + ' done in ' + (Date.now() - startTime) + 'ms');
|
|
logger.audit(req.user.id, 'transcribe', 'Transcribed audio via google', req, { category: 'clinical' });
|
|
return res.json({ success: true, text: text, provider: 'google-' + model, duration: Date.now() - startTime });
|
|
}
|
|
|
|
if (provider === 'local') {
|
|
var text = await transcribeWithLocal(req.file.buffer, req.file.mimetype || 'audio/webm');
|
|
console.log('[Transcribe] Local done in ' + (Date.now() - startTime) + 'ms');
|
|
logger.audit(req.user.id, 'transcribe', 'Transcribed audio via local', req, { category: 'clinical' });
|
|
return res.json({ success: true, text: text, provider: 'local-whisper', duration: Date.now() - startTime });
|
|
}
|
|
|
|
if (provider === 'aws') {
|
|
if (!isAWSTranscribeConfigured()) return res.status(400).json({ error: 'AWS Transcribe not configured.' });
|
|
var text = await transcribeWithAWS(req.file.buffer, req.file.mimetype || 'audio/webm');
|
|
console.log('[Transcribe] AWS done in ' + (Date.now() - startTime) + 'ms');
|
|
logger.audit(req.user.id, 'transcribe', 'Transcribed audio via aws', req, { category: 'clinical' });
|
|
return res.json({ success: true, text: text, provider: 'aws-transcribe', duration: Date.now() - startTime });
|
|
}
|
|
|
|
if (provider === 'litellm') {
|
|
if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' });
|
|
var sttModel = userModel || adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
|
|
var axios = require('axios');
|
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
|
var mimeType = req.file.mimetype || 'audio/webm';
|
|
|
|
// Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.)
|
|
var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel);
|
|
|
|
if (isTranscriptionModel) {
|
|
// Use /v1/audio/transcriptions endpoint (OpenAI-compatible)
|
|
var ext = mimeType.split('/')[1] || 'webm';
|
|
var file = new File([req.file.buffer], 'audio.' + ext, { type: mimeType });
|
|
var form = new FormData();
|
|
form.append('file', file);
|
|
form.append('model', sttModel);
|
|
var fetchHeaders = {};
|
|
if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
|
|
|
var sttResp = await fetch(base + '/v1/audio/transcriptions', {
|
|
method: 'POST', headers: fetchHeaders, body: form
|
|
}).then(function(r) { return r.json().then(function(d) { return { data: d }; }); });
|
|
var text = sttResp.data && sttResp.data.text ? sttResp.data.text : '';
|
|
console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms');
|
|
logger.audit(req.user.id, 'transcribe', 'Transcribed audio via litellm', req, { category: 'clinical' });
|
|
return res.json({ success: true, text: text.trim(), provider: 'litellm/' + sttModel, duration: Date.now() - startTime });
|
|
}
|
|
|
|
// Gemini-style models — use /v1/chat/completions with inline audio
|
|
var base64audio = req.file.buffer.toString('base64');
|
|
var headers = { 'Content-Type': 'application/json' };
|
|
if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
|
|
|
var sttResp = await axios.post(base + '/v1/chat/completions', {
|
|
model: sttModel,
|
|
messages: [{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'input_audio',
|
|
input_audio: { data: base64audio, format: mimeType.split('/')[1] || 'webm' }
|
|
},
|
|
{
|
|
type: 'text',
|
|
text: 'Transcribe this audio of a medical encounter. Output the spoken words only, exactly as heard. No commentary, no formatting.'
|
|
}
|
|
]
|
|
}]
|
|
}, { headers: headers, timeout: 120000 });
|
|
|
|
var text = '';
|
|
if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) {
|
|
var msg = sttResp.data.choices[0].message;
|
|
text = (msg && msg.content) ? msg.content : '';
|
|
}
|
|
console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms');
|
|
logger.audit(req.user.id, 'transcribe', 'Transcribed audio via litellm', req, { category: 'clinical' });
|
|
return res.json({ success: true, text: text.trim(), provider: 'litellm/' + sttModel, duration: Date.now() - startTime });
|
|
}
|
|
|
|
// OpenAI Whisper (direct)
|
|
if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured. Set OPENAI_API_KEY.' });
|
|
var file = new File([req.file.buffer], 'audio.webm', { type: req.file.mimetype || 'audio/webm' });
|
|
var result = await whisperClient.audio.transcriptions.create({
|
|
file, model: 'whisper-1', language: 'en',
|
|
response_format: 'text',
|
|
prompt: 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications.'
|
|
});
|
|
var text = typeof result === 'string' ? result : result.text;
|
|
console.log('[Transcribe] Whisper done in ' + (Date.now() - startTime) + 'ms');
|
|
logger.audit(req.user.id, 'transcribe', 'Transcribed audio via openai', req, { category: 'clinical' });
|
|
res.json({ success: true, text: text, provider: 'openai-whisper', duration: Date.now() - startTime });
|
|
|
|
} catch (err) {
|
|
var detail = err.response && err.response.data
|
|
? JSON.stringify(err.response.data).substring(0, 500)
|
|
: err.message;
|
|
console.error('[Transcribe] Error (' + provider + '):', detail);
|
|
res.status(500).json({ error: detail });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|