refactor: remove the embedding settings, whose only consumer is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 21:47:09 +02:00
parent 9df2230479
commit a505244b97
17 changed files with 17 additions and 692 deletions

View file

@ -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.

View file

@ -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`).
---

View file

@ -47,7 +47,6 @@ src/
promptSafe.js # <UNTRUSTED_*> 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 |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -225,13 +225,12 @@
<!-- One search for every kind of model. The kind decides which
gateway list is asked and what the row buttons do: chat and
image models are added to a roster; speech, transcription and
embedding models are single defaults, so their button is Set. -->
transcription models are single defaults, so their button is Set. -->
<div class="admin-kind-switch" role="group" aria-label="Kind of model">
<button type="button" class="admin-discover-kind" id="admin-discover-kind-chat" data-kind="chat" aria-pressed="true"><i class="fas fa-comments"></i> Chat</button>
<button type="button" class="admin-discover-kind" id="admin-discover-kind-image" data-kind="image" aria-pressed="false"><i class="fas fa-image"></i> Image</button>
<button type="button" class="admin-discover-kind" id="admin-discover-kind-tts" data-kind="tts" aria-pressed="false"><i class="fas fa-volume-up"></i> Speech (TTS)</button>
<button type="button" class="admin-discover-kind" id="admin-discover-kind-stt" data-kind="stt" aria-pressed="false"><i class="fas fa-microphone"></i> Transcription (STT)</button>
<button type="button" class="admin-discover-kind" id="admin-discover-kind-embedding" data-kind="embedding" aria-pressed="false"><i class="fas fa-network-wired"></i> Embedding</button>
</div>
<!-- Provider status for the chosen kind. -->
@ -253,11 +252,6 @@
<span id="admin-stt-info" class="admin-note">Loading...</span>
<span class="admin-note"><strong>Set</strong> makes a model the default for dictation.</span>
</div>
<div class="admin-kind-panel admin-kind-status" data-kind="embedding" hidden>
<span id="admin-embed-provider-badge" class="admin-badge">Loading...</span>
<span id="admin-embed-info" class="admin-note">Loading...</span>
<span class="admin-note">Learning Hub semantic search. Corpus (MCP) embeddings are configured on the indexing service, not here. <strong>Set</strong> makes a model the default.</span>
</div>
<div class="admin-search-row">
<input type="search" id="admin-discover-search" placeholder="Filter by name (e.g. gemini, gpt, llama)" aria-label="Filter models by name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
@ -309,13 +303,6 @@
<div id="admin-stt-meta" style="font-size:11px;color:var(--g400);margin-top:4px;"></div>
</div>
</div>
<div class="admin-kind-panel" data-kind="embedding" hidden>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" id="admin-embed-test-text" value="Pediatric patient with fever and cough" aria-label="Sample text to embed" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;" placeholder="Sample text to embed...">
<button id="btn-test-embedding" class="btn-sm btn-primary" type="button"><i class="fas fa-code-branch"></i> Generate</button>
</div>
<div id="admin-embed-result" role="status" class="admin-note" style="margin-top:8px;"></div>
</div>
</div>
</div>
</details>
@ -348,7 +335,7 @@
<p class="admin-note">Loading...</p>
</div>
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Speech, transcription and embedding models are single defaults rather than a roster: choose them with <strong>Set</strong> under Discover &amp; test.</p>
<p class="admin-note" style="border-top:1px solid var(--g100);padding-top:10px;">Speech and transcription models are single defaults rather than a roster: choose them with <strong>Set</strong> under Discover &amp; test.</p>
</div>
</details>

View file

@ -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 '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm admin-embed-set-btn" data-id="' + esc(m.id) + '" data-dims="' + m.dims + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Set</button>' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + m.dims + 'd)</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
(isCurrent ? '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--green-light,#d1fae5);color:var(--green);">ACTIVE</span>' : '') +
'</div>';
}).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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
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 = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.models || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-embed-set-btn" data-id="' + esc(m.id) + '" data-dims="' + (m.dims || '') + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<span style="flex:1;">' + esc(m.name || m.id) + (m.dims && m.dims !== '?' ? ' <span style="color:var(--g500);font-size:11px;">(' + m.dims + 'd)</span>' : '') + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
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, '<i class="fas fa-spinner fa-spin"></i>', 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, '<i class="fas fa-code-branch"></i> Generate', false);
if (!data.success) {
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
return;
}
if (resultEl) {
resultEl.innerHTML =
'<strong>Dimensions:</strong> ' + data.dimensions + ' &nbsp;|&nbsp; ' +
'<strong>Model:</strong> ' + esc(data.model) + ' &nbsp;|&nbsp; ' +
'<strong>' + data.duration + 'ms</strong>' +
'<div style="margin-top:4px;font-family:monospace;font-size:11px;color:var(--g400);">Sample: [' + (data.sample || []).join(', ') + ', ...]</div>';
}
})
.catch(function(err) {
adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
});
}
}

View file

@ -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' });

View file

@ -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
]);

View file

@ -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<number[]>} - 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<Array>} - 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
};

View file

@ -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\)/,

View file

@ -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',

View file

@ -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: '?' }
]);
});

View file

@ -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);