From 004e36cd678b9cbce82f0d8c4c718dfcec1162e8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 23 Apr 2026 19:36:43 +0200 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20day=203=20batch=202=20=E2=80=94?= =?UTF-8?q?=20hpi,=20soap,=20refine,=20tts,=20transcribe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same CJS-compatible pattern as batch 1 (import express = require; const {...} = require for internal utils; export = router). No behavior change — TS only strips annotations during compile. AI route touchpoints: /api/generate-hpi-encounter, /generate-hpi-dictation → hpi.ts /api/generate-soap → soap.ts /api/refine, /shorten, /clarify → refine.ts /api/text-to-speech → tts.ts /api/transcribe, /transcribe/status → transcribe.ts All five handlers retain identical request/response shapes; the shared/types.ts contract was defined on day 2 from these very files. One fetch() typing fix: the transcribe route's LiteLLM branch uses DOM fetch, but the file imports Express's Response type, so the `.then((r: Response) => ...)` annotation was shadowing the global Response. Removed the explicit annotation — tsc infers correctly. Progress: 9 of 54 files migrated. tsc --noEmit green. --- src/routes/{hpi.js => hpi.ts} | 30 ++--- src/routes/{refine.js => refine.ts} | 68 +++++------ src/routes/{soap.js => soap.ts} | 22 ++-- src/routes/transcribe.js | 174 ---------------------------- src/routes/transcribe.ts | 162 ++++++++++++++++++++++++++ src/routes/{tts.js => tts.ts} | 83 +++++++------ 6 files changed, 258 insertions(+), 281 deletions(-) rename src/routes/{hpi.js => hpi.ts} (74%) rename src/routes/{refine.js => refine.ts} (53%) rename src/routes/{soap.js => soap.ts} (71%) delete mode 100644 src/routes/transcribe.js create mode 100644 src/routes/transcribe.ts rename src/routes/{tts.js => tts.ts} (55%) diff --git a/src/routes/hpi.js b/src/routes/hpi.ts similarity index 74% rename from src/routes/hpi.js rename to src/routes/hpi.ts index d22b588..8de3e03 100644 --- a/src/routes/hpi.js +++ b/src/routes/hpi.ts @@ -1,59 +1,59 @@ -const express = require('express'); -const router = express.Router(); +import express = require('express'); +import type { Request, Response } from 'express'; const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); -var logger = require('../utils/logger'); -var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); +const logger = require('../utils/logger'); +const { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); -// HPI from encounter -router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => { +const router = express.Router(); + +router.post('/generate-hpi-encounter', authMiddleware, async (req: Request, res: Response) => { try { const { transcript, patientAge, patientGender, model, setting, physicianMemories } = req.body; if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' }); const prompt = (setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiEncounter) + INJECTION_GUARD; - var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + let context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\nSetting: ' + (setting || 'outpatient') + '\n\n' + wrapUserText('transcript', transcript); if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]'; const result = await callAI([ { role: 'system', content: prompt }, - { role: 'user', content: context } + { role: 'user', content: context }, ], { model }); res.json({ success: true, hpi: result.content, model: result.model }); - logger.audit(req.user.id, 'generate_hpi_encounter', 'Generated HPI from encounter', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'generate_hpi_encounter', 'Generated HPI from encounter', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); -// HPI from dictation -router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => { +router.post('/generate-hpi-dictation', authMiddleware, async (req: Request, res: Response) => { try { const { transcript, patientAge, patientGender, model, setting, physicianMemories } = req.body; if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' }); const prompt = (setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiDictation) + INJECTION_GUARD; - var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + let context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\nSetting: ' + (setting || 'outpatient') + '\n\n' + wrapUserText('dictation', transcript); if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + wrapUserText('style_hints', physicianMemories) + '\n[END STYLE HINTS]'; const result = await callAI([ { role: 'system', content: prompt }, - { role: 'user', content: context } + { role: 'user', content: context }, ], { model }); res.json({ success: true, hpi: result.content, model: result.model }); - logger.audit(req.user.id, 'generate_hpi_dictation', 'Generated HPI from dictation', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'generate_hpi_dictation', 'Generated HPI from dictation', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); -module.exports = router; +export = router; diff --git a/src/routes/refine.js b/src/routes/refine.ts similarity index 53% rename from src/routes/refine.js rename to src/routes/refine.ts index 0b9c453..ed21852 100644 --- a/src/routes/refine.js +++ b/src/routes/refine.ts @@ -2,26 +2,23 @@ // REFINE ROUTES — Modify, shorten, clarify any document // ============================================================ -var express = require('express'); -var router = express.Router(); -var { callAI } = require('../utils/ai'); -var PROMPTS = require('../utils/prompts'); -var { authMiddleware } = require('../middleware/auth'); -var logger = require('../utils/logger'); -var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); +import express = require('express'); +import type { Request, Response } from 'express'; +const { callAI } = require('../utils/ai'); +const PROMPTS = require('../utils/prompts'); +const { authMiddleware } = require('../middleware/auth'); +const logger = require('../utils/logger'); +const { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); -// Refine/modify document with instructions -router.post('/refine', authMiddleware, async function(req, res) { +const router = express.Router(); + +router.post('/refine', authMiddleware, async function (req: Request, res: Response) { try { - var currentDocument = req.body.currentDocument; - var instructions = req.body.instructions; - var sourceContext = req.body.sourceContext; - var model = req.body.model; - + const { currentDocument, instructions, sourceContext, 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' }); - var userContent = ''; + let userContent = ''; if (sourceContext) { userContent += 'ORIGINAL SOURCE MATERIAL (reference this for any data lookups — labs, notes, history):\n' + wrapUserText('source', sourceContext) + '\n\n'; @@ -31,57 +28,50 @@ router.post('/refine', authMiddleware, async function(req, res) { + '\n\nUSER INSTRUCTIONS:\n' + wrapUserText('instructions', instructions); - var result = await callAI([ + const result = await callAI([ { role: 'system', content: PROMPTS.refine + INJECTION_GUARD }, - { role: 'user', content: userContent } - ], { model: model, maxTokens: 6000 }); + { role: 'user', content: userContent }, + ], { model, maxTokens: 6000 }); res.json({ success: true, refined: result.content, model: result.model }); - logger.audit(req.user.id, 'refine_document', 'Refined document', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'refine_document', 'Refined document', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); -// Shorten document -router.post('/shorten', authMiddleware, async function(req, res) { +router.post('/shorten', authMiddleware, async function (req: Request, res: Response) { try { - var document = req.body.document; - var model = req.body.model; - + const { document, model } = req.body; if (!document) return res.status(400).json({ error: 'No document' }); - var result = await callAI([ + const result = await callAI([ { role: 'system', content: PROMPTS.shortenDocument + INJECTION_GUARD }, - { role: 'user', content: wrapUserText('document', document) } - ], { model: model }); + { role: 'user', content: wrapUserText('document', document) }, + ], { model }); res.json({ success: true, shortened: result.content, model: result.model }); - logger.audit(req.user.id, 'shorten_document', 'Shortened document', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'shorten_document', 'Shortened document', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); -// Ask AI for clarification questions -router.post('/clarify', authMiddleware, async function(req, res) { +router.post('/clarify', authMiddleware, async function (req: Request, res: Response) { try { - var document = req.body.document; - var context = req.body.context; - var model = req.body.model; - + const { document, context, model } = req.body; if (!document) return res.status(400).json({ error: 'No document' }); - var result = await callAI([ + const result = await callAI([ { role: 'system', content: PROMPTS.askClarification + INJECTION_GUARD }, - { role: 'user', content: 'Document type: ' + (context || 'medical document') + '\n\n' + wrapUserText('document', document) } - ], { model: model, maxTokens: 1000 }); + { role: 'user', content: 'Document type: ' + (context || 'medical document') + '\n\n' + wrapUserText('document', document) }, + ], { model, maxTokens: 1000 }); res.json({ success: true, questions: result.content, model: result.model }); - logger.audit(req.user.id, 'clarify_document', 'Generated clarification questions', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'clarify_document', 'Generated clarification questions', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); -module.exports = router; +export = router; diff --git a/src/routes/soap.js b/src/routes/soap.ts similarity index 71% rename from src/routes/soap.js rename to src/routes/soap.ts index 47f54da..9c74aad 100644 --- a/src/routes/soap.js +++ b/src/routes/soap.ts @@ -1,17 +1,19 @@ -const express = require('express'); -const router = express.Router(); +import express = require('express'); +import type { Request, Response } from 'express'; const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); -var logger = require('../utils/logger'); -var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); +const logger = require('../utils/logger'); +const { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); -router.post('/generate-soap', authMiddleware, async (req, res) => { +const router = express.Router(); + +router.post('/generate-soap', authMiddleware, async (req: Request, res: Response) => { try { const { transcript, patientAge, patientGender, model, type, additionalInstructions, physicianMemories } = req.body; if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' }); - let prompt; + let prompt: string; switch (type) { case 'subjective': prompt = PROMPTS.soapSubjective; break; case 'full': @@ -23,7 +25,7 @@ router.post('/generate-soap', authMiddleware, async (req, res) => { } prompt += INJECTION_GUARD; - var context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\n\n' + let context = 'Patient: ' + (patientAge || 'Unknown') + ', ' + (patientGender || 'Unknown') + '\n\n' + wrapUserText('transcript', transcript); if (physicianMemories) { context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' @@ -33,14 +35,14 @@ router.post('/generate-soap', authMiddleware, async (req, res) => { const result = await callAI([ { role: 'system', content: prompt }, - { role: 'user', content: context } + { role: 'user', content: context }, ], { model }); res.json({ success: true, soap: result.content, model: result.model }); - logger.audit(req.user.id, 'generate_soap', 'Generated SOAP note', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'generate_soap', 'Generated SOAP note', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); -module.exports = router; +export = router; diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js deleted file mode 100644 index dbe2351..0000000 --- a/src/routes/transcribe.js +++ /dev/null @@ -1,174 +0,0 @@ -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'); -var { gatewayUrl } = require('../utils/errors'); - -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 mimeType = req.file.mimetype || 'audio/webm'; - - // Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.) - var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/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(gatewayUrl('/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(gatewayUrl('/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; diff --git a/src/routes/transcribe.ts b/src/routes/transcribe.ts new file mode 100644 index 0000000..eadf48b --- /dev/null +++ b/src/routes/transcribe.ts @@ -0,0 +1,162 @@ +import express = require('express'); +import type { Request, Response } from 'express'; +import 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'); +const logger = require('../utils/logger'); +const { gatewayUrl } = require('../utils/errors'); + +const router = express.Router(); +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(): string { + const 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'; + if (isGoogleSTTConfigured()) return 'google'; + if (isAWSTranscribeConfigured()) return 'aws'; + return 'openai'; +} + +function isTranscribeAvailable(): boolean { + if (isGoogleSTTConfigured()) return true; + if (isLocalWhisperConfigured()) return true; + if (isAWSTranscribeConfigured()) return true; + if (litellmClient) return true; + if (whisperClient) return true; + return false; +} + +const provider = getTranscribeProvider(); +const medical = process.env.AWS_TRANSCRIBE_MEDICAL === 'true'; +const 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: Request, res: Response) => { + res.json({ available, provider: available ? provider : 'none' }); +}); + +router.post('/transcribe', authMiddleware, upload.single('audio'), async (req: Request, res: Response) => { + try { + if (!req.file) return res.status(400).json({ error: 'No audio' }); + const startTime = Date.now(); + const fileSize = req.file.size; + + const db = require('../db/database'); + const userPrefs = await db.get('SELECT stt_model FROM users WHERE id = ?', [req.user!.id]); + const userModel = userPrefs?.stt_model; + const adminSttModel: string = (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') { + const model = userModel || adminSttModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash'; + const 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, provider: 'google-' + model, duration: Date.now() - startTime }); + } + + if (provider === 'local') { + const 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, provider: 'local-whisper', duration: Date.now() - startTime }); + } + + if (provider === 'aws') { + if (!isAWSTranscribeConfigured()) return res.status(400).json({ error: 'AWS Transcribe not configured.' }); + const 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, 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.' }); + const sttModel: string = userModel || adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; + const axios = require('axios'); + const mimeType = req.file.mimetype || 'audio/webm'; + + const isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel); + + if (isTranscriptionModel) { + const ext = mimeType.split('/')[1] || 'webm'; + const file = new File([new Uint8Array(req.file.buffer)], 'audio.' + ext, { type: mimeType }); + const form = new FormData(); + form.append('file', file); + form.append('model', sttModel); + const fetchHeaders: Record = {}; + if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; + + const sttResp = await fetch(gatewayUrl('/audio/transcriptions'), { + method: 'POST', headers: fetchHeaders, body: form, + }).then(r => r.json().then((d: any) => ({ data: d }))); + const text: string = 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 }); + } + + const base64audio = req.file.buffer.toString('base64'); + const headers: Record = { 'Content-Type': 'application/json' }; + if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; + + const sttResp = await axios.post(gatewayUrl('/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, timeout: 120000 }); + + let text = ''; + if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) { + const 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 }); + } + + if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured. Set OPENAI_API_KEY.' }); + const file = new File([new Uint8Array(req.file.buffer)], 'audio.webm', { type: req.file.mimetype || 'audio/webm' }); + const result = await whisperClient.audio.transcriptions.create({ + file, model: 'whisper-1', language: 'en', + response_format: 'text', + prompt: 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications.', + }); + const 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, provider: 'openai-whisper', duration: Date.now() - startTime }); + } catch (err: any) { + const 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 }); + } +}); + +export = router; diff --git a/src/routes/tts.js b/src/routes/tts.ts similarity index 55% rename from src/routes/tts.js rename to src/routes/tts.ts index 6f26524..799d3eb 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.ts @@ -1,99 +1,96 @@ -const express = require('express'); -const router = express.Router(); -const axios = require('axios'); +import express = require('express'); +import type { Request, Response } from 'express'; +const axios = require('axios'); const { synthesizeWithGoogleTTS, isGoogleTTSConfigured } = require('../utils/ttsGoogle'); const { authMiddleware } = require('../middleware/auth'); -var logger = require('../utils/logger'); -var { gatewayUrl } = require('../utils/errors'); +const logger = require('../utils/logger'); +const { gatewayUrl } = require('../utils/errors'); + +const router = express.Router(); // Provider priority (auto-detect): // TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible) // TTS_PROVIDER=litellm → LiteLLM proxy // TTS_PROVIDER=elevenlabs → ElevenLabs (not HIPAA) -// Auto: google > litellm > elevenlabs -function getTTSProvider() { - var env = process.env.TTS_PROVIDER; - if (env === 'google') return 'google'; - if (env === 'litellm') return 'litellm'; +// Auto: litellm > google > elevenlabs +function getTTSProvider(): string { + const env = process.env.TTS_PROVIDER; + if (env === 'google') return 'google'; + if (env === 'litellm') return 'litellm'; if (env === 'elevenlabs') return 'elevenlabs'; - // Auto-detect: litellm > google > elevenlabs - // LiteLLM Vertex TTS works correctly via alias (tts-1) - if (process.env.LITELLM_API_BASE) return 'litellm'; - if (isGoogleTTSConfigured()) return 'google'; + if (process.env.LITELLM_API_BASE) return 'litellm'; + if (isGoogleTTSConfigured()) return 'google'; if (process.env.ELEVENLABS_API_KEY) return 'elevenlabs'; return 'none'; } -var ttsProvider = getTTSProvider(); +const ttsProvider = getTTSProvider(); console.log('🔊 TTS provider:', ttsProvider + (ttsProvider === 'google' ? ' (voice: ' + (process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F') + ')' : '')); -router.post('/text-to-speech', authMiddleware, async (req, res) => { +router.post('/text-to-speech', authMiddleware, async (req: Request, res: Response) => { try { - var text = (req.body.text || '').substring(0, 5000); + const text = (req.body.text || '').substring(0, 5000); if (!text) return res.status(400).json({ error: 'No text provided' }); - // Get user's preferred TTS voice (if set), then fall back to DB admin default, then env - var db = require('../db/database'); - var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]); - var userVoice = userPrefs?.tts_voice; - var adminVoice = await db.getSetting('tts.voice') || ''; - var adminModel = await db.getSetting('tts.model') || ''; - // tts.voice may contain a model ID (from discovery "Set" button) — detect and use as model + const db = require('../db/database'); + const userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user!.id]); + const userVoice = userPrefs?.tts_voice; + let adminVoice: string = (await db.getSetting('tts.voice')) || ''; + let adminModel: string = (await db.getSetting('tts.model')) || ''; if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } - // Auto-detect model from voice — ensures model matches the voice's provider - var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; - var resolvedVoice = userVoice || adminVoice || ''; + const VERTEX_VOICES = ['Puck', 'Charon', 'Kore', 'Fenrir', 'Aoede', 'Leda', 'Orus', 'Zephyr']; + const resolvedVoice: string = userVoice || adminVoice || ''; if (resolvedVoice) { if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; } if (ttsProvider === 'google') { - var voice = resolvedVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F'; - var buffer = await synthesizeWithGoogleTTS(text, voice); + const voice = resolvedVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F'; + const buffer = await synthesizeWithGoogleTTS(text, voice); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'google-tts/' + voice); - logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); return res.send(buffer); } if (ttsProvider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' }); - var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1'; - var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') || rawModel.startsWith('vertex-') ? rawModel : 'openai/' + rawModel; - var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; - var ttsHeaders = { 'Content-Type': 'application/json' }; + const rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1'; + const ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') || rawModel.startsWith('vertex-') ? rawModel : 'openai/' + rawModel; + const ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; + const ttsHeaders: Record = { 'Content-Type': 'application/json' }; if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var ttsResp = await axios.post(gatewayUrl('/audio/speech'), + const ttsResp = await axios.post(gatewayUrl('/audio/speech'), { model: ttsModel, voice: ttsVoice, input: text }, { headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 } ); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'litellm/' + ttsModel); - logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); return res.send(Buffer.from(ttsResp.data)); } if (ttsProvider === 'elevenlabs') { if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'ElevenLabs not configured' }); - var resp = await axios({ + const resp = await axios({ method: 'POST', url: 'https://api.elevenlabs.io/v1/text-to-speech/pNInz6obpgDQGcFmaJgB', headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY, 'Content-Type': 'application/json' }, - data: { text: text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.75 } }, - responseType: 'arraybuffer' + data: { text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.75 } }, + responseType: 'arraybuffer', }); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'elevenlabs'); - logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); + logger.audit(req.user!.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' }); return res.send(Buffer.from(resp.data)); } res.status(400).json({ error: 'TTS not configured. Set GOOGLE_VERTEX_PROJECT, LITELLM_API_BASE, or ELEVENLABS_API_KEY.' }); - } catch (err) { - var detail = err.response && err.response.data + } catch (err: any) { + const detail = err.response && err.response.data ? JSON.stringify(err.response.data).substring(0, 500) : err.message; console.error('[TTS] Error (' + ttsProvider + '):', detail); @@ -101,4 +98,4 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { } }); -module.exports = router; +export = router;