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
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:
parent
9df2230479
commit
a505244b97
17 changed files with 17 additions and 692 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 & 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 & test.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 + ' | ' +
|
||||
'<strong>Model:</strong> ' + esc(data.model) + ' | ' +
|
||||
'<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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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\)/,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '?' }
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue