From a505244b97bea578addcf6f85f7875746ba107b2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 21:47:09 +0200 Subject: [PATCH] refactor: remove the embedding settings, whose only consumer is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeddings existed here for Learning Hub semantic search — the card said so itself. Learning Hub was removed, and nothing took its place: the clinical corpus is embedded by the indexing service, not by this app. What was left was a settings page that configured a model, tested it, reported its dimensions, and fed nothing. src/utils/embeddings.js had exactly one importer, src/routes/adminConfig .js, which used it for the three routes this deletes. Outside those, the only mentions of embedding in the server were a comment and a settings prefix. Gone: the module, its three admin routes, the dimension probe, the Discover & test kind and its two panels, the admin.js block behind them, the embeddings. prefix from both the writable-settings allowlist and the lockdown list (it can no longer be written at all, so locking it says nothing), and docs/embeddings-setup.md, which documented Learning Hub search end to end. 'embedding' stays in NON_CHAT_MODES — that is the filter keeping embedding models out of the chat-model list, and the gateway still serves them. Docs still describe nine /api/learning endpoints that no longer exist, left from the Learning Hub removal. Not touched here; that is its own subject. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- README.md | 1 - docs/api-reference.md | 4 +- docs/architecture.md | 3 +- docs/configuration.md | 14 +- docs/deployment.md | 2 +- docs/developer-guide.md | 1 - docs/embeddings-setup.md | 241 ------------------- docs/logic/auth-admin-learning.md | 8 - public/components/admin.html | 17 +- public/js/admin.js | 161 +------------ src/routes/adminConfig.js | 98 +------- src/utils/adminLockdown.js | 1 - src/utils/embeddings.js | 123 ---------- test/admin-clinical-assistant-wiring.test.js | 6 +- test/backend-hardening.test.js | 2 +- test/embeddings-provider.test.js | 25 -- test/prompt-administration.test.js | 2 +- 17 files changed, 17 insertions(+), 692 deletions(-) delete mode 100644 docs/embeddings-setup.md delete mode 100644 src/utils/embeddings.js delete mode 100644 test/embeddings-provider.test.js diff --git a/README.md b/README.md index 660f9312..c88a4d72 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,6 @@ Primary references: - `docs/ai-providers.md` — provider selection, prompts, injection hardening. - `docs/clinical-assistant.md` — MCP-backed assistant behavior and safety rules. - `docs/retrieval-tuning.md` — how much corpus each feature retrieves, and what it costs. -- `docs/embeddings-setup.md` — embedding model configuration. - `docs/global-prompt-administration.md` — prompt overrides and the conversation budget. - `docs/speech.md` — STT, TTS, recording, and audio backups. - `docs/my-resources.md` — private teaching material, the slide renderer, and search sources. diff --git a/docs/api-reference.md b/docs/api-reference.md index 2b4dfee1..1bbfc485 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2070,7 +2070,7 @@ Get all application configuration settings. ### PUT /api/admin/config/:key -Update one application configuration setting. The key must match an allowed prefix: `announcement.`, `feature.`, `email.`, `prompt.`, `registration_enabled`, `registration_invite_only`, `site.`, `smtp.`, `models.`, `tts.`, `stt.`, `embeddings.`, `clinical_assistant.`, or `my_resources.`. Anything else is rejected with 400. +Update one application configuration setting. The key must match an allowed prefix: `announcement.`, `feature.`, `email.`, `prompt.`, `registration_enabled`, `registration_invite_only`, `site.`, `smtp.`, `models.`, `tts.`, `stt.`, `clinical_assistant.`, or `my_resources.`. Anything else is rejected with 400. Some keys are refused here even when allowed: `models.*` must go through the validated model endpoints, `feature.*` values must be `true` or `false`, and any key under lockdown returns 403. @@ -2604,7 +2604,7 @@ available, so a code that never arrives is never a lockout. The rest manage | `POST` | `/api/auth/login-code/request` | | `POST` | `/api/auth/login-code/verify` | -Remaining endpoints not listed above are additional admin configuration, model/STT/TTS/embedding discovery and test calls, Learning Hub CMS operations, and the per-feature AI helpers (`/api/dont-miss`, `/api/suggest-codes`, `/api/generate-pe-narrative`, `/api/hospital-course-update`, `/api/hospital-course-clarify`, `/api/well-visit/note`, `/api/milestones-data`, `/api/user/features`, `/api/logs/client-error`, `/api/logs/client-event`, `/api/generated-images/:id`, `/api/image-jobs/:workflow`). +Remaining endpoints not listed above are additional admin configuration, model/STT/TTS discovery and test calls, and the per-feature AI helpers (`/api/dont-miss`, `/api/suggest-codes`, `/api/generate-pe-narrative`, `/api/hospital-course-update`, `/api/hospital-course-clarify`, `/api/well-visit/note`, `/api/milestones-data`, `/api/user/features`, `/api/logs/client-error`, `/api/logs/client-event`, `/api/generated-images/:id`, `/api/image-jobs/:workflow`). --- diff --git a/docs/architecture.md b/docs/architecture.md index 0d8b7b1f..635146c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,6 @@ src/ promptSafe.js # LLM prompt wrapper logger.js # audit/api/access + Loki shipper errors.js # generic 500 responder - embeddings.js # LiteLLM embeddings sttProvider.js, ttsProvider.js # speech-to-text and text-to-speech routing documentExport.js # pptx/docx/pdf export slideSpec.js, docSpec.js # markdown -> typed spec for the renderers @@ -187,7 +186,7 @@ Ped-AI is a self-hosted Express application with a browser frontend, PostgreSQL | Web app | Ped-AI | Auth, UI, clinical workflows, admin settings, notes, Learning Hub, bedside tools | | Database | PostgreSQL | Users, sessions, settings, saved app data, audit/API/access logs | | Operational cache | Redis | Prompt suggestions, lightweight state, queue groundwork; not clinical answer caching | -| Model gateway | LiteLLM | Text, speech, image, embedding model discovery and routing | +| Model gateway | LiteLLM | Text, speech and image model discovery and routing | | Clinical retrieval | MCP service | Nextcloud access, indexing, search, rerank, source metadata | | Reverse proxy | Caddy or equivalent | TLS and public routing | diff --git a/docs/configuration.md b/docs/configuration.md index 3a52c4fd..0e3be24f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -150,7 +150,6 @@ with 2-minute in-memory cache. Writes invalidate the cache immediately. | `ai.allow_model_fallback` | Enable silent fallback to secondary model on primary failure. **Default false** — fallback could spill to a non-BAA provider. | | `stt.model`, `tts.model`, `tts.voice` | System-wide STT/TTS defaults (users can override per-account). | | `prompt.{name}` | Prompt overrides. Any template in `src/utils/prompts.js` can be replaced live. | -| `embeddings.model`, `embeddings.dimensions` | Override embedding config. | ### Feature flags @@ -197,18 +196,11 @@ OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies. (e.g., `openrouter/gpt-4.1`), while LiteLLM can use deployment aliases (e.g., `openrouter-gpt-4.1`). 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) + - Admin Panel → Models → Discover & test → Speech / Transcription + (**Set** makes a model the default) - `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 +4. **Restart the container** — `docker compose up -d --force-recreate` to pick up `.env` changes (a plain `restart` does not re-read `.env`). ### Verified gateways diff --git a/docs/deployment.md b/docs/deployment.md index 116bab19..35c386ce 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -155,7 +155,7 @@ App sets `trust proxy: 1` so rate limiting uses the original client IP. | Volume | Contents | Backup priority | |---|---|---| -| `pgdata` | All user data, encounters, memories, audit logs, settings, embeddings | Critical | +| `pgdata` | All user data, encounters, memories, audit logs, settings | Critical | | `scribe-logs` | Filesystem audit log files (JSONL by day) | High for compliance evidence; Postgres also has audit/API/access tables | ### Postgres backup / restore diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 59c747dd..f9d0d4dd 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -37,7 +37,6 @@ src/ fileType.js magic-byte upload verifier errors.js generic 500 responder logger.js audit + api + access + Loki shipper - embeddings.js LiteLLM embeddings notify.js ntfy push transcribe.js, tts.js LiteLLM STT / TTS routes routes/ Express routers for auth, AI workflows, education, logs, and user data diff --git a/docs/embeddings-setup.md b/docs/embeddings-setup.md deleted file mode 100644 index b3747fe0..00000000 --- a/docs/embeddings-setup.md +++ /dev/null @@ -1,241 +0,0 @@ -# Embeddings And Semantic Search Setup - -This guide explains how to set up and use the new vector-based semantic search for the Learning Hub. - -## What This Enables - -- **Semantic search** - Find content by meaning, not just keywords -- **3 search modes**: - - **Keyword** (`/api/learning/search`) - Traditional text matching - - **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity - - **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results -- **Auto-embedding** - Content is automatically vectorized when created/updated -- **Gateway-routed** - Uses LiteLLM embeddings so provider policy stays in one place - -## Prerequisites - -### 1. Install pgvector Extension - -The database needs the `pgvector` extension for vector operations: - -```bash -# For PostgreSQL 16 on Ubuntu/Debian -sudo apt-get install postgresql-16-pgvector - -# For PostgreSQL 15 -sudo apt-get install postgresql-15-pgvector - -# For Docker (add to Dockerfile or docker-compose) -# The postgres:16-alpine base image doesn't include pgvector by default -# You'll need to use a custom image or install at runtime -``` - -**For Docker deployments**, use this postgres image instead: -```yaml -postgres: - image: pgvector/pgvector:pg16 - # ... rest of your config -``` - -### 2. Configure LiteLLM Embeddings - -Add to your `.env` file: - -```bash -LITELLM_API_BASE=http://localhost:4000 -LITELLM_API_KEY=your-key -EMBEDDING_MODEL=openai-text-embedding-3-large -EMBEDDING_DIMENSIONS=3072 -``` - -## Available Embedding Models - -The Admin embedding search reads LiteLLM `/model/info` and only shows models with `model_info.mode = "embedding"`. Do not add app-side built-in Vertex/OpenAI embedding lists; configure those choices in LiteLLM. - -The local LiteLLM instance currently exposes examples such as `openai-text-embedding-3-large`, `openai-text-embedding-3-small`, and Mistral embedding models. Dimensions are read from LiteLLM metadata when available. - -## Setup Steps - -### 1. Database Migration - -The database will automatically: -- Enable the `pgvector` extension -- Add `embedding vector(768)` column to `learning_content` -- Create IVFFLAT index for fast similarity search (after 10+ embeddings) - -Just restart your server after installing pgvector. - -### 2. Generate Embeddings for Existing Content - -Two options: - -**Option A: Admin API (recommended)** -```bash -curl -X POST http://localhost:3000/api/admin/learning/embeddings/generate \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"regenerateAll": false}' -``` - -**Option B: Via Admin Panel** -- Go to Admin → Learning Hub → Settings -- Click "Generate Embeddings" button -- Check status at `/api/admin/learning/embeddings/status` - -### 3. Verify Setup - -Check embedding status: -```bash -curl http://localhost:3000/api/admin/learning/embeddings/status \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" -``` - -Response: -```json -{ - "success": true, - "enabled": true, - "total": 50, - "withEmbeddings": 50, - "missing": 0, - "model": "openai-text-embedding-3-large", - "dimensions": 3072 -} -``` - -## Using Semantic Search - -### Keyword Search (existing) -```bash -GET /api/learning/search?q=pneumonia -``` -Returns exact text matches in title/subject/body. - -### Semantic Search (new) -```bash -GET /api/learning/search/semantic?q=childhood breathing problems&limit=10&threshold=0.5 -``` -Returns content similar by **meaning** (e.g., finds "pediatric asthma" articles). - -**Parameters:** -- `q` (required) - Search query -- `limit` (optional, default 10, max 50) - Max results -- `threshold` (optional, default 0.5) - Similarity threshold (0-1, higher = more similar) -- `contentType` (optional) - Filter by type: article, quiz, pearl, presentation - -### Hybrid Search (recommended) -```bash -GET /api/learning/search/hybrid?q=fever management -``` -Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance. - -## How It Works - -1. **Content Creation/Update**: - - Text is extracted from `title`, `subject`, and `body` (HTML stripped) - - Sent to the configured LiteLLM embedding model - - Returns an embedding vector - - Stored in `learning_content.embedding` column - -2. **Semantic Search**: - - Query text → embedding vector - - PostgreSQL pgvector computes cosine similarity - - Returns top N most similar documents - - Similarity score 0-1 (1 = identical, 0 = unrelated) - -3. **Hybrid Search**: - - Runs both keyword + semantic searches in parallel - - Merges results (semantic first for quality) - - Deduplicates by content ID - - Sorts by relevance score - -## Cost Estimate - -Embedding cost depends on the upstream configured in LiteLLM. - -## Troubleshooting - -### "pgvector extension not available" -- Install: `apt-get install postgresql-16-pgvector` -- For Docker: Use `pgvector/pgvector:pg16` image - -### "Embeddings not configured" -- Verify `.env` has `LITELLM_API_BASE` -- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status` - -### "Embedding generation failed" -- Check logs for API errors -- Verify LiteLLM `/model/info` shows the selected model with `mode: embedding` -- Check content isn't empty (skips empty bodies) - -### "No results from semantic search" -- Check if embeddings exist: `/api/admin/learning/embeddings/status` -- Lower threshold: `?threshold=0.3` (default 0.5) -- Verify pgvector index exists: `\di` in psql - -## Performance - -- **Embedding generation**: latency depends on the LiteLLM upstream -- **Search latency**: - - Keyword: 10-50ms - - Semantic: 20-100ms (with IVFFLAT index) - - Hybrid: 30-150ms -- **Index build time**: ~1-5 seconds per 1,000 articles - -## Security And Compliance - -- **Compliance**: controlled by the upstream provider configured in LiteLLM -- **Data retention**: Embeddings stored in your database only -- **No PHI**: Only article content (not patient data) is embedded -- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL - -## Example Queries - -**Before (keyword):** -``` -Query: "fever in babies" -Results: Only articles with exact words "fever" or "babies" -``` - -**After (semantic):** -``` -Query: "fever in babies" -Results: -- Infant hyperthermia management (similarity: 0.89) -- Pediatric fever evaluation (similarity: 0.87) -- Febrile seizures in toddlers (similarity: 0.82) -- Neonatal temperature regulation (similarity: 0.78) -``` - -**Hybrid (best):** -``` -Query: "asthma" -Results: -- Childhood asthma management (keyword + semantic: 1.0) -- Pediatric breathing difficulties (semantic: 0.91) -- Reactive airway disease (semantic: 0.86) -- Bronchiolitis vs asthma (keyword: 1.0) -``` - -## API Reference - -### Admin Endpoints - -- `POST /api/admin/learning/embeddings/generate` - Backfill embeddings -- `GET /api/admin/learning/embeddings/status` - Check status -- `GET /api/admin/learning/stats` - Includes embedding count - -### User Endpoints - -- `GET /api/learning/search` - Keyword search -- `GET /api/learning/search/semantic` - Semantic search -- `GET /api/learning/search/hybrid` - Hybrid search (recommended) - -All endpoints require authentication (JWT token). - ---- - -**Questions?** Check logs for detailed error messages, or review the code in: -- `/src/utils/embeddings.js` - Core embedding logic -- `/src/routes/learningHub.js` - Search endpoints -- `/src/routes/learningAdmin.js` - Admin management diff --git a/docs/logic/auth-admin-learning.md b/docs/logic/auth-admin-learning.md index bb907e53..c2521087 100644 --- a/docs/logic/auth-admin-learning.md +++ b/docs/logic/auth-admin-learning.md @@ -38,14 +38,6 @@ Learning Hub has two surfaces: - learner/user-facing routes under `/api/learning` - moderator/admin CMS routes under `/api/admin/learning` -Content types include articles, pearls, quizzes, and presentations. AI content -generation can use topic text, uploaded files, or connected Nextcloud/WebDAV -sources. Semantic search uses pgvector embeddings on `learning_content` when an -embedding provider is configured. - -See -[`../embeddings-setup.md`](../embeddings-setup.md). - ## Security Rules - Never expose raw secrets in admin health/config responses. diff --git a/public/components/admin.html b/public/components/admin.html index 9d6d3dab..93bb2e88 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -225,13 +225,12 @@ + transcription models are single defaults, so their button is Set. -->
-
@@ -253,11 +252,6 @@ Loading... Set makes a model the default for dictation. -
@@ -309,13 +303,6 @@
- @@ -348,7 +335,7 @@

Loading...

-

Speech, transcription and embedding models are single defaults rather than a roster: choose them with Set under Discover & test.

+

Speech and transcription models are single defaults rather than a roster: choose them with Set under Discover & test.

diff --git a/public/js/admin.js b/public/js/admin.js index e4309db1..39ff6f47 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1210,7 +1210,7 @@ initImageSettings(); // ============================================================ // ADMIN DISCOVERY — one search box for every kind of model // ============================================================ -// Chat, image, speech, transcription and embedding models each have their own +// Chat, image, speech and transcription models each have their own // gateway list and their own row buttons, and they used to have a card each, // scattered down the page. The kind switch keeps the five discovery calls as // they are and only decides which one the Search button asks. The switch @@ -1221,8 +1221,7 @@ initImageSettings(); chat: 'Filter by name (e.g. gemini, gpt, llama)', image: 'Filter by name (e.g. dall-e, imagen, flux)', tts: 'Filter voices or models (e.g. Journey, Neural, alloy)', - stt: 'Filter by name (e.g. gemini, whisper)', - embedding: 'Filter by name (e.g. embedding, vertex)' + stt: 'Filter by name (e.g. gemini, whisper)' }; function activeDiscoverKind() { @@ -2124,159 +2123,3 @@ initImageSettings(); }); } } - -// ============================================================ -// ADMIN EMBEDDING MODELS MANAGEMENT -// ============================================================ -{ - document.addEventListener('tabChanged', function(e) { - if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig(); - }); - // Catch-up for a tab that is already active and loaded at module init. - if (adminTabActive()) loadEmbeddingConfig(); - document.addEventListener('click', function(e) { - if (e.target.closest('#btn-test-embedding')) testEmbedding(); - if (e.target.closest('.admin-embed-set-btn')) { - var btn = e.target.closest('.admin-embed-set-btn'); - setEmbeddingDefault(btn.dataset.id, btn.dataset.dims, btn); - } - }); - document.addEventListener('admin-discover', function(e) { - if (e.detail && e.detail.kind === 'embedding') discoverEmbeddings(); - }); - - const esc = adminEscapeHtml; - - function loadEmbeddingConfig() { - fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (!data.success) return; - var badge = document.getElementById('admin-embed-provider-badge'); - if (badge) { - badge.textContent = (data.provider || 'none').toUpperCase(); - badge.style.background = data.configured ? 'var(--g100)' : 'var(--red-light)'; - badge.style.color = data.configured ? 'var(--g600)' : 'var(--red)'; - } - var info = document.getElementById('admin-embed-info'); - if (info) { - var parts = []; - if (data.dbModel) parts.push('DB model: ' + data.dbModel); - else if (data.envModel) parts.push('Env model: ' + data.envModel); - parts.push('Dims: ' + (data.currentDimensions || '?')); - if (!data.configured) parts.push('⚠️ Not configured'); - info.textContent = parts.join(' · '); - } - var modelsEl = document.getElementById('admin-embed-models'); - if (modelsEl && data.models) { - modelsEl.innerHTML = data.models.map(function(m) { - var isCurrent = m.id === data.currentModel; - return '
' + - '' + - '' + esc(m.name) + ' (' + m.dims + 'd)' + - '' + esc(m.tag || '') + '' + - (isCurrent ? 'ACTIVE' : '') + - '
'; - }).join(''); - } - }) - .catch(function() {}); - } - - function discoverEmbeddings() { - var search = (document.getElementById('admin-discover-search') || {}).value || ''; - var container = document.getElementById('admin-discover-results'); - var hint = document.getElementById('admin-discover-hint'); - if (!container) return; - container.innerHTML = '

Querying provider...

'; - if (hint) hint.hidden = true; - - fetch('/api/admin/config/embeddings/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (!data.success) { - container.innerHTML = '

Error: ' + esc(data.error || 'Unknown') + '

'; - return; - } - var items = data.models || []; - if (items.length === 0) { - container.innerHTML = '

No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; - return; - } - container.innerHTML = '

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

' + - items.map(function(m) { - return '
' + - '' + - '' + esc(m.name || m.id) + (m.dims && m.dims !== '?' ? ' (' + m.dims + 'd)' : '') + '' + - '' + esc(m.source || '') + '' + - '
'; - }).join(''); - }) - .catch(function(err) { - container.innerHTML = '

Request failed: ' + esc(err.message) + '

'; - }); - } - - function setEmbeddingDefault(modelId, dims, btn) { - var origText = btn ? btn.textContent : ''; - adminSetButtonText(btn, '...', true); - var promises = [ - fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), { - method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId }) - }).then(function(r) { return r.json(); }) - ]; - if (dims && dims !== '?') { - promises.push( - fetch('/api/admin/config/' + encodeURIComponent('embeddings.dimensions'), { - method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: String(dims) }) - }).then(function(r) { return r.json(); }) - ); - } - Promise.all(promises) - .then(function(results) { - var ok = results.every(function(r) { return r.success; }); - adminSetButtonText(btn, 'Set', false); - adminFlashButtonBackground(btn, ok ? 'var(--green)' : ''); - if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); } - else showToast(results[0].error || 'Failed', 'error'); - }) - .catch(function() { - adminSetButtonText(btn, origText, false); - showToast('Request failed', 'error'); - }); - } - - function testEmbedding() { - var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test'; - var resultEl = document.getElementById('admin-embed-result'); - var btn = document.getElementById('btn-test-embedding'); - adminSetButtonHtml(btn, '', true); - if (resultEl) resultEl.textContent = 'Generating...'; - - fetch('/api/admin/config/embeddings/test', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ text: text }) - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - adminSetButtonHtml(btn, ' Generate', false); - if (!data.success) { - if (resultEl) resultEl.innerHTML = 'Error: ' + esc(data.error || 'Failed') + ''; - return; - } - if (resultEl) { - resultEl.innerHTML = - 'Dimensions: ' + data.dimensions + '  |  ' + - 'Model: ' + esc(data.model) + '  |  ' + - '' + data.duration + 'ms' + - '
Sample: [' + (data.sample || []).join(', ') + ', ...]
'; - } - }) - .catch(function(err) { - adminSetButtonHtml(btn, ' Generate', false); - if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; - }); - } - -} diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 40f1da29..18e599b9 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -15,7 +15,6 @@ var { gatewayUrl, serverError } = require('../utils/errors'); var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm'); var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); -var { getLiteLLMEmbeddingModels } = require('../utils/embeddings'); router.use(authMiddleware); @@ -86,20 +85,6 @@ async function liteLLMVisionSupport(modelId) { } } -async function probeLiteLLMEmbeddingDimensions(modelId) { - try { - var axios = require('axios'); - var resp = await axios.post(gatewayUrl('/embeddings'), { - model: modelId, - input: 'dimension probe' - }, { headers: getLiteLLMHeaders('application/json'), timeout: 30000 }); - var embedding = resp.data && resp.data.data && resp.data.data[0] && resp.data.data[0].embedding; - return Array.isArray(embedding) ? embedding.length : '?'; - } catch (e) { - logger.warn('LiteLLM embedding dimension probe failed for ' + modelId + ': ' + e.message); - return '?'; - } -} // ── GET announcement (any authenticated user) ────────────────────────────── router.get('/config/announcement', async function(req, res) { @@ -925,87 +910,6 @@ router.post('/config/stt/test', async function(req, res) { } }); -// ── GET embedding model config ──────────────────────────────────────────── -router.get('/config/embeddings', async function(req, res) { - try { - var { isEmbeddingsAvailable, DEFAULT_MODEL, DEFAULT_DIMS } = require('../utils/embeddings'); - var dbModel = await db.getSetting('embeddings.model') || ''; - var dbDims = await db.getSetting('embeddings.dimensions') || ''; - var envModel = process.env.EMBEDDING_MODEL || DEFAULT_MODEL; - var envDims = parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS; - - var provider = 'none'; - if (process.env.LITELLM_API_BASE) provider = 'litellm'; - - res.json({ - success: true, - provider: provider, - configured: isEmbeddingsAvailable(), - currentModel: dbModel || envModel, - currentDimensions: dbDims ? parseInt(dbDims) : envDims, - dbModel: dbModel, - dbDimensions: dbDims, - envModel: envModel, - envDimensions: envDims, - models: [] - }); - } catch (e) { res.status(500).json({ error: 'Request failed' }); } -}); - -// ── GET discover embedding models from provider ─────────────────────────── -router.get('/config/embeddings/discover', async function(req, res) { - try { - var search = (req.query.q || '').toLowerCase().trim(); - var axios = require('axios'); - var discovered = []; - - var provider = 'none'; - if (process.env.LITELLM_API_BASE) provider = 'litellm'; - - if (provider === 'litellm') { - try { - var eResp = await axios.get(liteLLMBaseUrl() + '/model/info', { headers: getLiteLLMAdminHeaders(), timeout: 10000 }); - var embeddingModels = getLiteLLMEmbeddingModels(eResp.data && eResp.data.data); - for (var i = 0; i < embeddingModels.length; i++) { - var m = embeddingModels[i]; - var dims = m.dims === '?' ? await probeLiteLLMEmbeddingDimensions(m.id) : m.dims; - discovered.push({ id: m.id, name: m.name, dims: dims, source: 'gateway-api', mode: 'embedding', capability: 'embedding' }); - } - } catch(e) { logger.warn('LiteLLM embedding metadata discovery failed: ' + e.message); } - } - - if (search) { - discovered = discovered.filter(function(d) { - return matchesDiscoverySearch(d, search, 'embedding vector'); - }); - } - res.json({ success: true, provider: provider, models: discovered, count: discovered.length }); - } catch (e) { res.status(500).json({ error: 'Request failed' }); } -}); - -// ── POST test embedding ─────────────────────────────────────────────────── -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; - var sample = Array.isArray(vector) ? vector.slice(0, 8).map(function(v) { return v.toFixed(4); }) : []; - res.json({ - success: true, - dimensions: dims, - sample: sample, - model: dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL, - duration: Date.now() - start - }); - } catch (e) { - res.json({ success: false, error: e.message }); - } -}); - // ============================================================ // WILDCARD CONFIG — Must come AFTER all specific model routes above // :key(*) matches slashes, so it would intercept /config/models/toggle @@ -1023,7 +927,7 @@ router.put('/config/:key(*)', async function(req, res) { } // Security: only allow known key prefixes - var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'registration_invite_only', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.', 'my_resources.']; + var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'registration_invite_only', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.']; var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); }); if (!isAllowed) { return res.status(400).json({ error: 'Unknown config key' }); diff --git a/src/utils/adminLockdown.js b/src/utils/adminLockdown.js index 329773b8..eb50d466 100644 --- a/src/utils/adminLockdown.js +++ b/src/utils/adminLockdown.js @@ -21,7 +21,6 @@ var LOCKED_PREFIXES = Object.freeze([ 'models.', // model policy: default, custom, enabled set 'tts.', 'stt.', - 'embeddings.', 'smtp.', // where mail goes and who it authenticates as 'email.' // the templates that mail sends ]); diff --git a/src/utils/embeddings.js b/src/utils/embeddings.js deleted file mode 100644 index 31aa7bf5..00000000 --- a/src/utils/embeddings.js +++ /dev/null @@ -1,123 +0,0 @@ -// ============================================================ -// EMBEDDINGS UTILITY — Generate & search through LiteLLM embeddings -// ============================================================ - -var axios = require('axios'); -var { gatewayUrl } = require('./errors'); -var { getLiteLLMHeaders } = require('./litellm'); - -var DEFAULT_MODEL = 'openai-text-embedding-3-large'; -var DEFAULT_DIMS = 3072; - -/** - * Generate embedding for text using configured provider - * @param {string} text - Text to embed (max ~2000 tokens) - * @param {object} opts - Options: { model, dimensions } - * @returns {Promise} - Embedding vector - */ -async function generateEmbedding(text, opts) { - opts = opts || {}; - var dbModel, dbDims; - try { - var db = require('../db/database'); - dbModel = await db.getSetting('embeddings.model') || ''; - dbDims = await db.getSetting('embeddings.dimensions') || ''; - } catch(e) { /* DB not available during startup */ } - var model = opts.model || dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL; - var dimensions = opts.dimensions || (dbDims ? parseInt(dbDims) : 0) || parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS; - - // Truncate text to ~2000 tokens (~8000 chars) to avoid API errors - // NOTE: A large PDF is truncated to the first ~8000 chars for embedding. - // The full PDF content is still extracted and stored in the database body field. - // This is expected behavior - embeddings are semantic representations, not full-text storage. - var truncated = text.substring(0, 8000); - if (!truncated.trim()) { - throw new Error('Empty text provided for embedding'); - } - - if (process.env.LITELLM_API_BASE) { - return await generateEmbeddingLiteLLM(truncated, model, dimensions); - } - - throw new Error('No embedding provider configured. Set LITELLM_API_BASE'); -} - -/** - * Generate embedding via LiteLLM proxy - */ -async function generateEmbeddingLiteLLM(text, model, dimensions) { - try { - var payload = { - model: model, - input: text - }; - - if (dimensions) { - payload.dimensions = dimensions; - } - - var response = await axios.post(gatewayUrl('/embeddings'), payload, { - headers: getLiteLLMHeaders('application/json'), - timeout: 30000 - }); - - if (!response.data || !response.data.data || !response.data.data[0]) { - throw new Error('Invalid response from LiteLLM embeddings API'); - } - - return response.data.data[0].embedding; - } catch (err) { - console.error('[Embeddings] LiteLLM error:', err.response?.data || err.message); - throw new Error('LiteLLM embedding failed: ' + (err.response?.data?.error || err.message)); - } -} - -/** - * Search for similar content using cosine similarity - * @param {string} queryText - Search query - * @param {object} opts - Options: { limit, threshold, contentType } - * @returns {Promise} - Matching content with similarity scores - */ -function getLiteLLMModelId(model) { - if (!model) return ''; - if (typeof model === 'string') return model; - return model.id || model.model_name || ''; -} - -function isLiteLLMEmbeddingModel(model) { - var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : ''; - return mode === 'embedding'; -} - -function getLiteLLMEmbeddingDimensions(model) { - var info = model && model.model_info ? model.model_info : {}; - var dims = info.output_vector_size || info.dimensions || info.embedding_dimensions || model.output_vector_size || model.dimensions || model.embedding_dimensions; - var parsed = parseInt(dims, 10); - return Number.isFinite(parsed) ? parsed : '?'; -} - -function getLiteLLMEmbeddingModels(models) { - return (models || []) - .filter(isLiteLLMEmbeddingModel) - .map(function(model) { - var id = getLiteLLMModelId(model); - return { id: id, name: id, dims: getLiteLLMEmbeddingDimensions(model) }; - }) - .filter(function(model) { return !!model.id; }); -} - -/** - * Check if embeddings are available (provider configured) - */ -function isEmbeddingsAvailable() { - return !!process.env.LITELLM_API_BASE; -} - -module.exports = { - generateEmbedding, - getLiteLLMEmbeddingModels, - isEmbeddingsAvailable, - isLiteLLMEmbeddingModel, - DEFAULT_MODEL, - DEFAULT_DIMS -}; diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index 636106c4..ebb2010a 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -178,18 +178,18 @@ test('image model discovery is a kind in the shared Discover & test card, and en // One search box, one Search button, one result list and one hint for every kind. ['admin-discover-search', 'btn-discover', 'admin-discover-results', 'admin-discover-hint'] .forEach(id => assert.ok(html.includes('id="' + id + '"'), 'admin.html has #' + id)); - for (const kind of ['chat', 'image', 'tts', 'stt', 'embedding']) { + for (const kind of ['chat', 'image', 'tts', 'stt']) { assert.ok(html.includes('id="admin-discover-kind-' + kind + '"'), 'a kind switch for ' + kind); } assert.equal((html.match(/id="admin-discover-search"/g) || []).length, 1, 'exactly one search box'); // No leftover per-kind search boxes from the five cards this replaced. - for (const id of ['admin-model-search', 'admin-image-search', 'admin-tts-search', 'admin-stt-search', 'admin-embed-search']) { + for (const id of ['admin-model-search', 'admin-image-search', 'admin-tts-search', 'admin-stt-search']) { assert.doesNotMatch(html, new RegExp('id="' + id + '"'), 'no separate #' + id); } // The kind switch dispatches; each discovery loader answers for its own kind. assert.match(js, /CustomEvent\('admin-discover'/); - for (const kind of ['chat', 'image', 'tts', 'stt', 'embedding']) { + for (const kind of ['chat', 'image', 'tts', 'stt']) { assert.match(js, new RegExp("e\\.detail\\.kind === '" + kind + "'\\) discover\\w+\\(\\);"), kind + ' listens'); } assert.match(js, /'\/api\/admin\/config\/image-models\/discover\?q=' \+ encodeURIComponent\(search\)/, diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index aa56fecc..f610a13f 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -237,7 +237,7 @@ test('admin lockdown refuses configuration writes at the server', () => { // On, configuration is locked and day-to-day operation is not. for (const key of ['prompt.hpi', 'clinical_assistant.chat_model', 'models.default', - 'tts.voice', 'stt.model', 'embeddings.model', 'smtp.host', 'email.verify.subject']) { + 'tts.voice', 'stt.model', 'smtp.host', 'email.verify.subject']) { assert.equal(lockdown.isLocked(key, on), true, key + ' is locked'); } for (const key of ['announcement.text', 'registration_enabled', diff --git a/test/embeddings-provider.test.js b/test/embeddings-provider.test.js deleted file mode 100644 index a8c361ef..00000000 --- a/test/embeddings-provider.test.js +++ /dev/null @@ -1,25 +0,0 @@ -const { test } = require('node:test'); -const assert = require('node:assert/strict'); - -test('LiteLLM embedding discovery uses embedding metadata only', () => { - const embeddings = require('../src/utils/embeddings'); - assert.equal(embeddings.isLiteLLMEmbeddingModel({ id: 'openai-text-embedding-3-large' }), false); - assert.equal(embeddings.isLiteLLMEmbeddingModel({ id: 'mistral-codestral-embed' }), false); - assert.equal(embeddings.isLiteLLMEmbeddingModel({ model_name: 'openai-text-embedding-3-large', model_info: { mode: 'embedding' } }), true); - assert.equal(embeddings.isLiteLLMEmbeddingModel({ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }), false); -}); - -test('LiteLLM embedding extraction includes metadata dimensions', () => { - const embeddings = require('../src/utils/embeddings'); - assert.deepEqual(embeddings.getLiteLLMEmbeddingModels([ - { model_name: 'openai-text-embedding-3-large', model_info: { mode: 'embedding', output_vector_size: 3072 } }, - { model_name: 'openai-text-embedding-3-small', model_info: { mode: 'embedding', dimensions: '1536' } }, - { model_name: 'mistral-embed', model_info: { mode: 'embedding' } }, - { model_name: 'looks-like-embed' }, - { model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } } - ]), [ - { id: 'openai-text-embedding-3-large', name: 'openai-text-embedding-3-large', dims: 3072 }, - { id: 'openai-text-embedding-3-small', name: 'openai-text-embedding-3-small', dims: 1536 }, - { id: 'mistral-embed', name: 'mistral-embed', dims: '?' } - ]); -}); diff --git a/test/prompt-administration.test.js b/test/prompt-administration.test.js index 81d651c4..46dbab0b 100644 --- a/test/prompt-administration.test.js +++ b/test/prompt-administration.test.js @@ -110,7 +110,7 @@ async function application(t, svc, env = {}) { '../db/database': svc.db, '../middleware/auth': auth, '../utils/prompts': svc.prompts, '../utils/promptCatalog': svc.catalog, '../utils/promptRevisions': svc.revisions, '../utils/logger': { audit(actor, action, detail, req, meta) { logs.push({ actor, action, detail, meta }); } }, '../utils/errors': {}, - '../utils/ttsProvider': {}, '../utils/litellm': {}, '../utils/sttProvider': {}, '../utils/embeddings': {} + '../utils/ttsProvider': {}, '../utils/litellm': {}, '../utils/sttProvider': {} }, env); const app = express(); app.use(express.json()); app.use('/api/admin', router);