From 2f6e5a7d8f2d284e4a576ec0b4afb3f40ec2d772 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 19 Apr 2026 02:17:06 +0200 Subject: [PATCH] feat: neonatal calculator, DOCX/PPTX/ODT/EPUB support, gateway-agnostic URL helper, TTS/STT fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add neonatal assessment calculator: GA classification (extremely preterm through post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data, birth weight category (ELBW/VLBW/LBW/normal/macrosomia) - Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content generator file upload - Add gatewayUrl() helper for consistent API URL construction — handles LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible gateway (LiteLLM, Bifrost, etc.) - Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE, auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI) - Fix STT discovery to include ElevenLabs Scribe and Chirp models - Fix TTS discovery to include ElevenLabs and Vertex voices alongside models - Fix admin model test to bypass allowlist check (skipAllowlistCheck) so discovered models can be tested before adding - Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import - Fix admin embedding test to show DB model name instead of hardcoded default - Fix admin STT test to use correct endpoint for Whisper models - Add AI gateway migration guide to configuration docs - Add Grafana dashboard JSON for Loki log visualization --- docs/configuration.md | 44 ++++++++- grafana-dashboard.json | 58 +++++++++++ package.json | 1 + public/components/calculators.html | 37 +++++++ public/js/admin.js | 7 +- public/js/calculators.js | 151 +++++++++++++++++++++++++++++ src/routes/adminConfig.js | 123 ++++++++++++++++------- src/routes/learningAI.js | 81 +++++++++++++++- src/routes/transcribe.js | 6 +- src/routes/tts.js | 24 +++-- src/utils/ai.js | 3 +- src/utils/errors.js | 15 ++- 12 files changed, 493 insertions(+), 57 deletions(-) create mode 100644 grafana-dashboard.json diff --git a/docs/configuration.md b/docs/configuration.md index b1be83e..9c0d8fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ keys): | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. | | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. | | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). | -| `LITELLM_API_BASE`, `LITELLM_API_KEY` | Self-hosted LiteLLM proxy. | +| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). | ### Speech-to-text @@ -160,3 +160,45 @@ The Admin Panel (`/admin` route, admin-only) exposes everything above plus: provider. - Prompt editor: live-edit any `PROMPTS.*` key. - Test SMTP / test STT / test TTS. + +## Switching AI gateways + +The `LITELLM_API_BASE` and `LITELLM_API_KEY` variables work with any +OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies. + +### Migration steps + +1. **Set the base URL** — `LITELLM_API_BASE` should include `/v1` if the + gateway serves on that path (e.g., `https://gateway.example.com/v1`). + The application normalizes double `/v1` paths internally for TTS, STT, + and embedding endpoints. + +2. **Set the API key** — `LITELLM_API_KEY` accepts any key format the + gateway issues (virtual keys, bearer tokens, etc.). + +3. **Update model names** — Different gateways use different naming + conventions. Bifrost requires `provider/model` format + (e.g., `openrouter/vendor-model-sonnet-4.6`), while LiteLLM uses aliases + (e.g., `openrouter-vendor-model-sonnet-4.6`). Update model names in: + - Admin Panel → Models (chat models) + - Admin Panel → Settings → `stt.model` (speech-to-text) + - Admin Panel → Settings → `tts.model` (text-to-speech) + - `LITELLM_TTS_MODEL` env var (if set) + +4. **Embedding model** — Set via Admin Panel → Settings → + `embeddings.model`. The embedding vector column is `VECTOR(768)`, so + any model producing 768 dimensions works without re-embedding + (e.g., `vertex/text-embedding-005`). Switching to a model with + different dimensions requires altering the column and re-embedding all + content. + +5. **Restart the container** — `docker compose up -d --force-recreate` to + pick up `.env` changes (a plain `restart` does not re-read `.env`). + +### Verified gateways + +| Gateway | Model format | Notes | +|---|---|---| +| Bifrost | `provider/model` | Virtual keys, semantic caching, MCP gateway | +| LiteLLM | Custom aliases | Requires PostgreSQL + Redis | +| Any OpenAI-compatible | Varies | Must serve `/v1/chat/completions`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/embeddings` | diff --git a/grafana-dashboard.json b/grafana-dashboard.json new file mode 100644 index 0000000..4d135c3 --- /dev/null +++ b/grafana-dashboard.json @@ -0,0 +1,58 @@ +{ + "dashboard": { + "title": "PedScribe Overview", + "tags": ["pedscribe"], + "timezone": "browser", + "refresh": "30s", + "time": {"from": "now-24h", "to": "now"}, + "panels": [ + { + "id": 1, "title": "Audit Events", "type": "timeseries", + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 0}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "count_over_time({app=\"pedscribe\"} [5m])", "legendFormat": "{{action}}"}], + "fieldConfig": {"defaults": {"custom": {"drawStyle": "bars", "fillOpacity": 30}}} + }, + { + "id": 2, "title": "By Category", "type": "piechart", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 8}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "sum by (category) (count_over_time({app=\"pedscribe\"} [24h]))"}] + }, + { + "id": 3, "title": "By Action", "type": "piechart", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 8}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "sum by (action) (count_over_time({app=\"pedscribe\"} [24h]))"}] + }, + { + "id": 4, "title": "Success vs Failure", "type": "piechart", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 8}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "sum by (status) (count_over_time({app=\"pedscribe\"} [24h]))"}] + }, + { + "id": 5, "title": "Auth Events", "type": "logs", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "{app=\"pedscribe\", category=\"auth\"}"}], + "options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true} + }, + { + "id": 6, "title": "Clinical Events", "type": "logs", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 16}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "{app=\"pedscribe\", category=\"clinical\"}"}], + "options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true} + }, + { + "id": 7, "title": "All Logs", "type": "logs", + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 24}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "{app=\"pedscribe\"}"}], + "options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true} + } + ] + }, + "overwrite": true +} diff --git a/package.json b/package.json index 98789e8..8933487 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "express-rate-limit": "^7.4.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.8.0", "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.7.0", "nodemailer": "^8.0.5", diff --git a/public/components/calculators.html b/public/components/calculators.html index 9f2df9c..8823e4b 100644 --- a/public/components/calculators.html +++ b/public/components/calculators.html @@ -15,6 +15,7 @@ + @@ -636,3 +637,39 @@ + + + diff --git a/public/js/admin.js b/public/js/admin.js index e621bd0..7b60e69 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1088,9 +1088,12 @@ } container.innerHTML = '

Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')

' + items.slice(0, 100).map(function(v) { + var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1; + var setType = isModel ? 'model' : 'voice'; + var badge = isModel ? 'MODEL' : 'VOICE'; return '
' + - '' + - '' + esc(v.name) + '' + + '' + + '' + esc(v.name) + badge + '' + '' + esc(v.source || '') + '' + '
'; }).join(''); diff --git a/public/js/calculators.js b/public/js/calculators.js index 2f355ea..a6463f9 100644 --- a/public/js/calculators.js +++ b/public/js/calculators.js @@ -2217,3 +2217,154 @@ } } })(); + +// ============================================================ +// NEONATAL ASSESSMENT +// ============================================================ +(function() { + document.addEventListener('click', function(e) { + if (e.target.id === 'btn-neo-assess' || e.target.closest('#btn-neo-assess')) neoAssess(); + }); + + // Fenton 2013 LMS data (weight in grams, GA in weeks) + var fentonLMS = { + male: { + 22:{L:0.21,M:496,S:0.17},23:{L:0.21,M:575,S:0.17},24:{L:0.21,M:660,S:0.17},25:{L:0.21,M:762,S:0.16}, + 26:{L:0.21,M:870,S:0.16},27:{L:0.20,M:993,S:0.15},28:{L:0.20,M:1124,S:0.15},29:{L:0.19,M:1272,S:0.14}, + 30:{L:0.18,M:1430,S:0.14},31:{L:0.17,M:1607,S:0.14},32:{L:0.15,M:1795,S:0.14},33:{L:0.13,M:2008,S:0.13}, + 34:{L:0.12,M:2230,S:0.13},35:{L:0.10,M:2467,S:0.13},36:{L:0.08,M:2710,S:0.13},37:{L:0.06,M:2948,S:0.12}, + 38:{L:0.04,M:3195,S:0.12},39:{L:0.02,M:3380,S:0.12},40:{L:0.01,M:3530,S:0.12},41:{L:0.00,M:3660,S:0.12}, + 42:{L:-0.02,M:3820,S:0.12} + }, + female: { + 22:{L:0.23,M:474,S:0.17},23:{L:0.22,M:538,S:0.17},24:{L:0.22,M:610,S:0.17},25:{L:0.22,M:705,S:0.16}, + 26:{L:0.22,M:810,S:0.16},27:{L:0.21,M:920,S:0.15},28:{L:0.21,M:1040,S:0.15},29:{L:0.20,M:1178,S:0.14}, + 30:{L:0.19,M:1330,S:0.14},31:{L:0.18,M:1500,S:0.14},32:{L:0.16,M:1680,S:0.14},33:{L:0.14,M:1880,S:0.13}, + 34:{L:0.12,M:2090,S:0.13},35:{L:0.10,M:2310,S:0.13},36:{L:0.08,M:2540,S:0.13},37:{L:0.06,M:2766,S:0.12}, + 38:{L:0.04,M:3000,S:0.12},39:{L:0.02,M:3180,S:0.12},40:{L:0.01,M:3340,S:0.12},41:{L:0.00,M:3480,S:0.12}, + 42:{L:-0.02,M:3630,S:0.12} + } + }; + + function interpolateLMS(table, val) { + var keys = Object.keys(table).map(Number).sort(function(a,b){return a-b;}); + if (val <= keys[0]) return table[keys[0]]; + if (val >= keys[keys.length-1]) return table[keys[keys.length-1]]; + for (var i = 0; i < keys.length - 1; i++) { + if (val >= keys[i] && val <= keys[i+1]) { + var t = (val - keys[i]) / (keys[i+1] - keys[i]); + var a = table[keys[i]], b = table[keys[i+1]]; + return { L: a.L + t*(b.L-a.L), M: a.M + t*(b.M-a.M), S: a.S + t*(b.S-a.S) }; + } + } + return table[keys[0]]; + } + + function calcZ(value, L, M, S) { + if (Math.abs(L) < 0.001) return Math.log(value / M) / S; + return (Math.pow(value / M, L) - 1) / (L * S); + } + + function zToPercentile(z) { + // Approximation of the standard normal CDF + var a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911; + var sign = z < 0 ? -1 : 1; + var x = Math.abs(z) / Math.sqrt(2); + var t = 1 / (1 + p * x); + var y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-x*x); + return Math.round(((1 + sign * y) / 2) * 1000) / 10; + } + + function classifyGA(weeks, days) { + var total = weeks + (days || 0) / 7; + if (total < 28) return { label: 'Extremely Preterm', color: '#dc2626', icon: 'fa-triangle-exclamation' }; + if (total < 32) return { label: 'Very Preterm', color: '#ea580c', icon: 'fa-triangle-exclamation' }; + if (total < 34) return { label: 'Moderate Preterm', color: '#d97706', icon: 'fa-circle-exclamation' }; + if (total < 37) return { label: 'Late Preterm', color: '#ca8a04', icon: 'fa-circle-info' }; + if (total < 39) return { label: 'Early Term', color: '#2563eb', icon: 'fa-circle-info' }; + if (total < 41) return { label: 'Full Term', color: '#16a34a', icon: 'fa-circle-check' }; + if (total < 42) return { label: 'Late Term', color: '#d97706', icon: 'fa-circle-info' }; + return { label: 'Post Term', color: '#dc2626', icon: 'fa-triangle-exclamation' }; + } + + function classifyWeight(percentile) { + if (percentile < 3) return { label: 'Severely SGA', color: '#dc2626', detail: '<3rd percentile' }; + if (percentile < 10) return { label: 'SGA', color: '#ea580c', detail: '<10th percentile' }; + if (percentile > 97) return { label: 'Severely LGA', color: '#dc2626', detail: '>97th percentile' }; + if (percentile > 90) return { label: 'LGA', color: '#ea580c', detail: '>90th percentile' }; + return { label: 'AGA', color: '#16a34a', detail: '10th-90th percentile' }; + } + + function classifyBirthWeight(grams) { + if (grams < 1000) return { label: 'Extremely Low Birth Weight (ELBW)', color: '#dc2626' }; + if (grams < 1500) return { label: 'Very Low Birth Weight (VLBW)', color: '#ea580c' }; + if (grams < 2500) return { label: 'Low Birth Weight (LBW)', color: '#d97706' }; + if (grams <= 4000) return { label: 'Normal Birth Weight', color: '#16a34a' }; + return { label: 'Macrosomia (>4000g)', color: '#ea580c' }; + } + + function neoAssess() { + var weeks = parseInt(document.getElementById('neo-ga-weeks').value); + var days = parseInt(document.getElementById('neo-ga-days').value) || 0; + var weight = parseFloat(document.getElementById('neo-weight').value); + var sex = document.getElementById('neo-sex').value; + var resultDiv = document.getElementById('neo-result'); + + if (!weeks || !weight) { + resultDiv.innerHTML = '

Enter GA and birth weight.

'; + return; + } + + var gaDecimal = weeks + days / 7; + var gaClass = classifyGA(weeks, days); + var bwClass = classifyBirthWeight(weight); + + // Calculate percentile using Fenton LMS + var lms = interpolateLMS(fentonLMS[sex], gaDecimal); + var z = calcZ(weight, lms.L, lms.M, lms.S); + var percentile = zToPercentile(z); + var wtClass = classifyWeight(percentile); + var expectedWeight = Math.round(lms.M); + + var html = ''; + + // GA Classification + html += '
'; + html += '
'; + html += '
Gestational Age Classification
'; + html += '
' + gaClass.label + '
'; + html += '
' + weeks + ' weeks ' + days + ' days (' + gaDecimal.toFixed(1) + ' weeks)
'; + html += '
'; + + // Weight Classification (AGA/SGA/LGA) + html += '
'; + html += '
Weight for Gestational Age
'; + html += '
' + wtClass.label + '
'; + html += '
' + percentile.toFixed(1) + 'th percentile (' + wtClass.detail + ')
'; + html += '
'; + html += '
'; + + // Birth Weight Category + html += '
'; + html += '
'; + html += '
Birth Weight Category
'; + html += '
' + bwClass.label + '
'; + html += '
' + weight + 'g (' + (weight/1000).toFixed(2) + ' kg)
'; + html += '
'; + + // Stats + html += '
'; + html += '
Fenton Growth Data (' + (sex === 'male' ? 'Male' : 'Female') + ')
'; + html += '
'; + html += 'Expected weight (50th %ile): ' + expectedWeight + 'g
'; + html += 'Z-score: ' + z.toFixed(2) + '
'; + html += 'Percentile: ' + percentile.toFixed(1) + '%'; + html += '
'; + html += '
'; + + // Reference + html += '

Fenton TR, Kim JH. A systematic review and meta-analysis to revise the Fenton growth chart for preterm infants. BMC Pediatrics 2013;13:59. GA classification per ACOG/AAP definitions.

'; + + resultDiv.innerHTML = html; + } +})(); diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 3bf82b8..5796434 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -8,6 +8,7 @@ var db = require('../db/database'); var { authMiddleware, adminMiddleware } = require('../middleware/auth'); var PROMPTS = require('../utils/prompts'); var logger = require('../utils/logger'); +var { gatewayUrl } = require('../utils/errors'); router.use(authMiddleware); @@ -378,7 +379,7 @@ router.post('/config/models/test', async function(req, res) { var start = Date.now(); var result = await callAI( [{ role: 'user', content: 'Reply with only the word: OK' }], - { model: modelId, maxTokens: 20, temperature: 0 } + { model: modelId, maxTokens: 20, temperature: 0, skipAllowlistCheck: true } ); res.json({ success: true, @@ -473,25 +474,44 @@ router.get('/config/tts/discover', async function(req, res) { } if (provider === 'litellm' && process.env.LITELLM_API_BASE) { - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + // base URL handled by gatewayUrl() helper var lHeaders = {}; if (process.env.LITELLM_API_KEY) lHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var lResp = await axios.get(base + '/v1/models', { headers: lHeaders, timeout: 10000 }); + var lResp = await axios.get(gatewayUrl('/models'), { headers: lHeaders, timeout: 10000 }); if (lResp.data && lResp.data.data) { lResp.data.data.forEach(function(m) { var mid = (m.id || '').toLowerCase(); - if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1) { - discovered.push({ id: m.id, name: m.id, source: 'litellm-api' }); + if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('playai') !== -1) { + discovered.push({ id: m.id, name: m.id, source: 'gateway-api' }); } }); } } catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); } - ['alloy','echo','fable','onyx','nova','shimmer'].forEach(function(v) { - if (!discovered.find(function(d) { return d.id === v; })) { - discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'builtin' }); - } + + // OpenAI voices + ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'].forEach(function(v) { + discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'openai-voices' }); }); + + // Vertex Gemini TTS voices + ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'].forEach(function(v) { + discovered.push({ id: v, name: v + ' (Vertex voice)', source: 'vertex-voices' }); + }); + + // ElevenLabs voices — fetch from API if key available + if (process.env.ELEVENLABS_API_KEY) { + try { + var elResp = await axios.get('https://api.elevenlabs.io/v1/voices', { + headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY }, timeout: 10000 + }); + if (elResp.data && elResp.data.voices) { + elResp.data.voices.forEach(function(v) { + discovered.push({ id: v.voice_id, name: v.name + ' (ElevenLabs)', source: 'elevenlabs-api' }); + }); + } + } catch (e) { logger.warn('ElevenLabs voice fetch failed: ' + e.message); } + } } if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) { @@ -540,13 +560,23 @@ router.post('/config/tts/test', async function(req, res) { buffer = await synthesizeWithGoogleTTS(text, usedVoice); } else if (provider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); - var rawModel = 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 : 'openai/' + rawModel; - usedVoice = voice || process.env.LITELLM_TTS_VOICE || 'alloy'; - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + var db = require('../db/database'); + var adminModel = await db.getSetting('tts.model') || ''; + var adminVoice = await db.getSetting('tts.voice') || ''; + if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } + var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; + var resolvedVoice = voice || adminVoice || ''; + if (!adminModel && 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'; + } + 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; + usedVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; + // base URL handled by gatewayUrl() helper var ttsHeaders = { 'Content-Type': 'application/json' }; if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var ttsResp = await axios.post(base + '/v1/audio/speech', + var ttsResp = await axios.post(gatewayUrl('/audio/speech'), { model: ttsModel, voice: usedVoice, input: text }, { headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 } ); @@ -665,20 +695,20 @@ router.get('/config/stt/discover', async function(req, res) { } if (provider === 'litellm' && process.env.LITELLM_API_BASE) { - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + // base URL handled by gatewayUrl() helper var sHeaders = {}; if (process.env.LITELLM_API_KEY) sHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var sResp = await axios.get(base + '/v1/models', { headers: sHeaders, timeout: 10000 }); + var sResp = await axios.get(gatewayUrl('/models'), { headers: sHeaders, timeout: 10000 }); if (sResp.data && sResp.data.data) { sResp.data.data.forEach(function(m) { var mid = (m.id || '').toLowerCase(); - if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1) { - discovered.push({ id: m.id, name: m.id, source: 'litellm-api' }); + if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1 || mid.indexOf('scribe') !== -1 || mid.indexOf('nova') !== -1 || mid.indexOf('deepgram') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('chirp') !== -1) { + discovered.push({ id: m.id, name: m.id, source: 'gateway-api' }); } }); } - } catch(e) { logger.warn('LiteLLM STT discovery failed: ' + e.message); } + } catch(e) { logger.warn('STT discovery failed: ' + e.message); } } if (provider === 'local') { @@ -735,19 +765,38 @@ router.post('/config/stt/test', async function(req, res) { text = await transcribeWithLocal(audioBuffer, mime); } else if (provider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); - var sttModel = process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); - 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: audioBase64, format: mime.split('/')[1] || 'webm' } }, - { type: 'text', text: 'Transcribe this audio. Output the spoken words only.' } - ]}] - }, { headers: headers, timeout: 60000 }); - if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) { - text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || ''; + var db = require('../db/database'); + var adminSttModel = await db.getSetting('stt.model') || ''; + var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; + // base URL handled by gatewayUrl() helper + + var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); + + if (isTranscriptionModel) { + var ext = mime.split('/')[1] || 'webm'; + var file = new File([audioBuffer], 'audio.' + ext, { type: mime }); + 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 }; }); }); + text = sttResp.data && sttResp.data.text ? sttResp.data.text : ''; + } else { + 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: audioBase64, format: mime.split('/')[1] || 'webm' } }, + { type: 'text', text: 'Transcribe this audio. Output the spoken words only.' } + ]}] + }, { headers: headers, timeout: 60000 }); + if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) { + text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || ''; + } } } else if (provider === 'openai') { if (!whisperClient) return res.json({ success: false, error: 'Whisper not configured' }); @@ -821,16 +870,16 @@ router.get('/config/embeddings/discover', async function(req, res) { ]; if (provider === 'litellm') { - var eBase = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + // base URL handled by gatewayUrl() helper var eHeaders = {}; if (process.env.LITELLM_API_KEY) eHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var eResp = await axios.get(eBase + '/v1/models', { headers: eHeaders, timeout: 10000 }); + var eResp = await axios.get(gatewayUrl('/models'), { headers: eHeaders, timeout: 10000 }); if (eResp.data && eResp.data.data) { eResp.data.data.forEach(function(m) { if ((m.id || '').toLowerCase().indexOf('embed') !== -1) { var dims = m.id.indexOf('3-large') !== -1 ? 3072 : m.id.indexOf('3-small') !== -1 ? 1536 : m.id.indexOf('005') !== -1 ? 768 : '?'; - discovered.push({ id: m.id, name: m.id, dims: dims, source: 'litellm-api' }); + discovered.push({ id: m.id, name: m.id, dims: dims, source: 'gateway-api' }); } }); } @@ -880,6 +929,8 @@ router.post('/config/embeddings/test', async function(req, res) { try { var text = (req.body.text || 'Pediatric patient with fever').substring(0, 500); var { generateEmbedding, DEFAULT_MODEL } = require('../utils/embeddings'); + var db = require('../db/database'); + var dbModel = await db.getSetting('embeddings.model') || ''; var start = Date.now(); var vector = await generateEmbedding(text); var dims = Array.isArray(vector) ? vector.length : 0; @@ -888,7 +939,7 @@ router.post('/config/embeddings/test', async function(req, res) { success: true, dimensions: dims, sample: sample, - model: process.env.EMBEDDING_MODEL || DEFAULT_MODEL, + model: dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL, duration: Date.now() - start }); } catch (e) { diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js index 6ddd698..2da2ef1 100644 --- a/src/routes/learningAI.js +++ b/src/routes/learningAI.js @@ -10,6 +10,7 @@ var path = require('path'); var { callAI } = require('../utils/ai'); var { authMiddleware, moderatorMiddleware } = require('../middleware/auth'); var db = require('../db/database'); +var cryptoUtil = require('../utils/crypto'); router.use(authMiddleware); router.use(moderatorMiddleware); @@ -28,12 +29,16 @@ var upload = multer({ 'text/markdown', 'text/html', 'text/csv', - 'application/json' + 'application/json', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text', + 'application/epub+zip' ]; - if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json)$/i)) { + if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json|docx|pptx|odt|epub)$/i)) { cb(null, true); } else { - cb(new Error('File type not allowed. Only PDF, TXT, MD, HTML, CSV, JSON are supported.')); + cb(new Error('File type not allowed. Supported: PDF, DOCX, PPTX, ODT, EPUB, TXT, MD, HTML, CSV, JSON.')); } } }); @@ -64,6 +69,65 @@ async function extractText(buffer, mimetype, filename) { return buffer.toString('utf8'); } + // DOCX + if (mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || ext === 'docx') { + try { + var mammoth = require('mammoth'); + var result = await mammoth.extractRawText({ buffer: buffer }); + return result.value || ''; + } catch (e) { + throw new Error('Could not parse DOCX: ' + e.message); + } + } + + // PPTX — extract text from slide XML inside the zip + if (mimetype === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || ext === 'pptx') { + try { + var JSZip = require('jszip'); + var zip = await JSZip.loadAsync(buffer); + var slides = Object.keys(zip.files).filter(function(f) { return /^ppt\/slides\/slide\d+\.xml$/.test(f); }).sort(); + var texts = []; + for (var i = 0; i < slides.length; i++) { + var xml = await zip.files[slides[i]].async('string'); + var slideText = xml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + if (slideText) texts.push('--- Slide ' + (i + 1) + ' ---\n' + slideText); + } + return texts.join('\n\n'); + } catch (e) { + throw new Error('Could not parse PPTX: ' + e.message); + } + } + + // ODT — extract text from content.xml inside the zip + if (mimetype === 'application/vnd.oasis.opendocument.text' || ext === 'odt') { + try { + var JSZip = require('jszip'); + var zip = await JSZip.loadAsync(buffer); + var contentXml = await zip.files['content.xml'].async('string'); + return contentXml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + } catch (e) { + throw new Error('Could not parse ODT: ' + e.message); + } + } + + // EPUB — extract text from XHTML chapters inside the zip + if (mimetype === 'application/epub+zip' || ext === 'epub') { + try { + var JSZip = require('jszip'); + var zip = await JSZip.loadAsync(buffer); + var chapters = Object.keys(zip.files).filter(function(f) { return /\.(xhtml|html|htm)$/i.test(f) && !zip.files[f].dir; }).sort(); + var texts = []; + for (var i = 0; i < chapters.length; i++) { + var html = await zip.files[chapters[i]].async('string'); + var chapterText = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + if (chapterText) texts.push(chapterText); + } + return texts.join('\n\n'); + } catch (e) { + throw new Error('Could not parse EPUB: ' + e.message); + } + } + // Fallback — try utf8 return buffer.toString('utf8'); } @@ -215,9 +279,12 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) if (!user || !user.nextcloud_url) { return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings first.' }); } + var ncPassword; + try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); } + catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); } var fileUrl = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + webdavPath; var response = await axios.get(fileUrl, { - auth: { username: user.nextcloud_user, password: user.nextcloud_token }, + auth: { username: user.nextcloud_user, password: ncPassword }, responseType: 'arraybuffer', timeout: 30000 }); @@ -372,6 +439,10 @@ router.get('/webdav-browse', async function(req, res) { return res.status(400).json({ error: 'Nextcloud not connected' }); } + var ncPassword; + try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); } + catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); } + var browsePath = req.query.path || user.webdav_learning_path || user.nextcloud_folder || '/'; // Ensure it starts with / if (!browsePath.startsWith('/')) browsePath = '/' + browsePath; @@ -381,7 +452,7 @@ router.get('/webdav-browse', async function(req, res) { var davResponse = await axios({ method: 'PROPFIND', url: davUrl, - auth: { username: user.nextcloud_user, password: user.nextcloud_token }, + auth: { username: user.nextcloud_user, password: ncPassword }, headers: { Depth: '1', 'Content-Type': 'application/xml' }, data: ``, timeout: 15000 diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js index 7ad35d6..5cff735 100644 --- a/src/routes/transcribe.js +++ b/src/routes/transcribe.js @@ -7,6 +7,7 @@ const { transcribeWithLocal, isLocalWhisperConfigured } = require('../utils/tran 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 } }); @@ -92,7 +93,6 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r 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.) @@ -108,7 +108,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var fetchHeaders = {}; if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var sttResp = await fetch(base + '/v1/audio/transcriptions', { + 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 : ''; @@ -122,7 +122,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r 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', { + var sttResp = await axios.post(gatewayUrl('/chat/completions'), { model: sttModel, messages: [{ role: 'user', diff --git a/src/routes/tts.js b/src/routes/tts.js index d3f76ea..a98368a 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -4,6 +4,7 @@ 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'); // Provider priority (auto-detect): // TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible) @@ -38,9 +39,20 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { 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 + 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 name when no explicit model is set + var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; + var OPENAI_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral']; + var resolvedVoice = userVoice || adminVoice || ''; + if (!adminModel && 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'; // ElevenLabs voice IDs are long alphanumeric + } if (ttsProvider === 'google') { - var voice = userVoice || adminVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F'; + var voice = resolvedVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F'; var buffer = await synthesizeWithGoogleTTS(text, voice); res.set('Content-Type', 'audio/mpeg'); res.set('X-TTS-Provider', 'google-tts/' + voice); @@ -50,16 +62,12 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { if (ttsProvider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' }); - // Use the full model path (e.g. vertex_ai/google-tts) or the model_name alias - // from your LiteLLM model_list. Full path is safer if your config uses it directly. - // Use model name as-is if set explicitly, else prefix with openai/ for generic names like 'tts-1' 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 : 'openai/' + rawModel; - var ttsVoice = userVoice || adminVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + 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' }; if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var ttsResp = await axios.post(base + '/v1/audio/speech', + var ttsResp = await axios.post(gatewayUrl('/audio/speech'), { model: ttsModel, voice: ttsVoice, input: text }, { headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 } ); diff --git a/src/utils/ai.js b/src/utils/ai.js index f224756..b6c884d 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -410,7 +410,8 @@ async function callAI(messages, options) { // Prevents a client from passing e.g. model:"openai/o1" and draining // the budget on a reasoning model outside the configured roster. // Falls back silently to DEFAULT_MODEL when no model was provided. - if (requestedModel) { + // Admin test endpoints pass skipAllowlistCheck to test before adding. + if (requestedModel && !options.skipAllowlistCheck) { try { var db = require('../db/database'); var { getAllowedModelIds } = require('./models'); diff --git a/src/utils/errors.js b/src/utils/errors.js index 3355ef1..65b1c9a 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -12,4 +12,17 @@ function serverError(res, scope, err, publicMessage) { return res.status(500).json({ error: publicMessage || 'Request failed. Please try again.' }); } -module.exports = { serverError: serverError }; +// ============================================================ +// AI gateway URL helper — builds endpoint URLs from +// LITELLM_API_BASE, handling both with and without /v1 suffix. +// Usage: gatewayUrl('/audio/speech') → 'https://gw.example.com/v1/audio/speech' +// The OpenAI SDK (ai.js) uses LITELLM_API_BASE directly as +// baseURL and must NOT use this helper. +// ============================================================ + +function gatewayUrl(path) { + var base = (process.env.LITELLM_API_BASE || '').replace(/\/+$/, '').replace(/\/v1\/?$/, ''); + return base + '/v1' + (path.startsWith('/') ? path : '/' + path); +} + +module.exports = { serverError: serverError, gatewayUrl: gatewayUrl };