refactor(ts): day 3 batch 2 — hpi, soap, refine, tts, transcribe
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.
This commit is contained in:
parent
c1d61f5a72
commit
004e36cd67
6 changed files with 258 additions and 281 deletions
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
162
src/routes/transcribe.ts
Normal file
162
src/routes/transcribe.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||
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<string, string> = { '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;
|
||||
|
|
@ -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<string, string> = { '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;
|
||||
Loading…
Reference in a new issue