From 93426ac089256f951aa93270c292754558a75eb9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 16:02:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20the=20library=20index=20reconciles=20wh?= =?UTF-8?q?en=20the=20admin=20asks=20=E2=80=94=20status=20and=20Run=20inde?= =?UTF-8?q?xing=20now=20in=20the=20Clinical=20Assistant=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clinical-assist indexer no longer polls Nextcloud every five minutes; it scans once at start and then on POST /api/v1/vector-sync/scan with a bearer token. The admin panel shows what it reports and carries the button; the address and token are settings (or the environment). The button stays usable under lockdown, the fields do not. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- .env.example | 4 +++ docs/clinical-assistant.md | 31 +++++++++++++++++++ public/components/admin.html | 24 +++++++++++++++ public/js/admin/clinicalAssistant.js | 46 ++++++++++++++++++++++++++++ src/routes/adminConfig.js | 46 +++++++++++++++++++++++++++- test/backend-hardening.test.js | 13 ++++++++ 6 files changed, 163 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index d1356ad7..95692a74 100644 --- a/.env.example +++ b/.env.example @@ -248,6 +248,10 @@ DB_PASSWORD=pedscribe_secret_change_me # ── Clinical Assistant: retrieval ─────────────────────────────────────────── # CLINICAL_ASSISTANT_MCP_URL=http://mcp:8000/mcp +# The indexer (a separate container) reconciles the library only when the +# admin panel asks. Address and token can also be set in the admin panel. +# CLINICAL_ASSISTANT_INDEXER_URL=http://mcp-indexer:8001 +# CLINICAL_ASSISTANT_INDEXER_TOKEN= # CLINICAL_ASSISTANT_MCP_URLS= # comma-separated, tried in order # CLINICAL_ASSISTANT_SEARCH_TOOL=clinical_semantic_search # the only accepted value # CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS=30000 diff --git a/docs/clinical-assistant.md b/docs/clinical-assistant.md index 48d44914..444cc1ff 100644 --- a/docs/clinical-assistant.md +++ b/docs/clinical-assistant.md @@ -205,3 +205,34 @@ Add or update tests when changing: - translation validation, caching and provider fallback, - MCP result normalization, - model discovery or settings behavior. + + +## Library indexing runs when asked, not on a timer + +The clinical library lives in Nextcloud folders and is indexed by the +clinical-assist indexer. It used to rescan every five minutes; a scan that +also walked ten thousand news-feed items took half an hour, so the indexer was +never idle. It now reconciles once when it starts and then only when an admin +presses **Run indexing now** (Admin → Clinical Assistant → Library index). + +A reconciliation lists the library folders, queues documents that are new or +whose modification time changed, and drops the index rows of documents that +are gone — after a second consecutive scan confirms the absence, so one +listing that happened to fail deletes nothing. Documents already indexed and +unchanged are not touched, and nothing is re-extracted. + +The button calls `POST /api/v1/vector-sync/scan` on the indexer with a bearer +token. Two settings tell the app where and how (or the environment, if the +settings are empty): + +| Setting | Environment | Default | +|---|---|---| +| `clinical_assistant.indexer_url` | `CLINICAL_ASSISTANT_INDEXER_URL` | `http://mcp-indexer:8001` | +| `clinical_assistant.indexer_token` | `CLINICAL_ASSISTANT_INDEXER_TOKEN` | none — the button refuses without one | + +The token must equal `VECTOR_SYNC_TRIGGER_TOKEN` in the indexer's environment. +On the indexer side the mode is `VECTOR_SYNC_ON_DEMAND=true`; `VECTOR_SYNC_SCAN_NEWS` +and `VECTOR_SYNC_SCAN_DECK` are `false` for a documents-only library. Under +[lockdown](authentication.md#lockdown-the-admin-panel-as-view-only) the button +still works — it is an operation, not a setting — but the address and token +are read-only. diff --git a/public/components/admin.html b/public/components/admin.html index c73cc848..4a107f30 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -412,6 +412,30 @@ +
+ Library index +
+
Checking the indexer…
+
+ + +
+ Indexing no longer runs on a timer. This queues documents added or changed in the library folders, drops what has been removed (after a second scan confirms it), and leaves everything else alone. +
+ Indexer address and token +
+
+ + +
+
+ + +
+
+
+
+
Citations
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index fa83b777..28dc97fc 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -223,6 +223,9 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed); renderAssistantImageModelCheckboxes(); setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8'); + setValue('assistant-indexer-url', cfg['clinical_assistant.indexer_url'] || ''); + setValue('assistant-indexer-token', ''); // never echoed back; blank means keep + loadLibraryIndexStatus(); setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400'); setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts var sourcesBox = document.getElementById('assistant-show-sources'); @@ -367,6 +370,47 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { }); } + // The library index: what the indexer reports, and the one button that asks + // it to reconcile. Neither depends on the settings load succeeding. + function describeIndexStatus(data) { + if (!data || !data.success) return (data && data.error) || 'Indexer not reachable.'; + var st = data.status || {}; + var sc = st.scanner || {}; + var parts = []; + if (st.indexed_documents != null) parts.push(st.indexed_documents + ' documents indexed'); + if (st.pending_documents) parts.push(st.pending_documents + ' waiting'); + if (sc.scan_running) parts.push('a scan is running now'); + else if (sc.last_scan_finished_at) parts.push('last scan finished ' + new Date(sc.last_scan_finished_at * 1000).toLocaleString()); + parts.push(sc.on_demand ? 'runs only when asked' : 'runs every ' + Math.round((sc.scan_interval_seconds || 0) / 60) + ' min'); + if (!data.tokenConfigured) parts.push('no trigger token set'); + return parts.join(' · '); + } + function loadLibraryIndexStatus() { + var box = document.getElementById('assistant-index-status'); + if (!box) return; + fetch('/api/admin/config/library-index', { credentials: 'same-origin' }).then(function(r) { return r.json(); }).then(function(data) { + box.textContent = describeIndexStatus(data); + }).catch(function(err) { box.textContent = 'Could not read the indexer status: ' + err.message; }); + } + function runLibraryIndexNow() { + var button = document.getElementById('btn-assistant-index-now'); + var box = document.getElementById('assistant-index-status'); + if (button) button.disabled = true; + fetch('/api/admin/config/library-index/scan', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + .then(function(r) { return r.json(); }).then(function(data) { + if (!data.success) throw new Error(data.error || 'The indexer refused'); + var r = data.result || {}; + showToast(r.status === 'already running' ? 'A scan is already running' : 'Indexing started', 'success'); + if (box) box.textContent = r.status === 'already running' ? 'A scan is already running; it will pick up the same changes.' : 'Scan requested. New and changed documents are being queued…'; + setTimeout(loadLibraryIndexStatus, 4000); + }).catch(function(err) { showToast(err.message, 'error'); if (box) box.textContent = err.message; }) + .finally(function() { if (button) button.disabled = false; }); + } + document.addEventListener('click', function(e) { + if (e.target.closest && e.target.closest('#btn-assistant-index-now')) runLibraryIndexNow(); + else if (e.target.closest && e.target.closest('#btn-assistant-index-refresh')) loadLibraryIndexStatus(); + }); + // Each card saves exactly what it shows, so no card needs a note explaining // what its Save covers. These were one button writing all eight keys from // the bottom of a card that also held a second Save for the image settings. @@ -377,6 +421,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { Promise.all([ putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')), putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'), + putAssistantConfig('clinical_assistant.indexer_url', getValue('assistant-indexer-url') || ''), + getValue('assistant-indexer-token') ? putAssistantConfig('clinical_assistant.indexer_token', getValue('assistant-indexer-token')) : Promise.resolve(), putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'), putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'), putAssistantConfig('clinical_assistant.show_sources', diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 107688aa..e6e60236 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -30,7 +30,7 @@ var lockdown = require('../utils/adminLockdown'); // - /config/:key, which decides per key — some keys stay editable, and that // route applies lockdown.isLocked() itself. // Everything else — model policy, SMTP, prompts, resets — is configuration. -var OPERATIONAL_WRITE = /\/test(-email)?$|^\/config\/[^/]+$/; +var OPERATIONAL_WRITE = /\/test(-email)?$|\/library-index\/scan$|^\/config\/[^/]+$/; router.use(function(req, res, next) { if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return next(); @@ -765,6 +765,50 @@ router.put('/config/tts/default', async function(req, res) { // The admin's test says exactly what it will send. It does not quietly swap in // a compatible voice: being told "this model refuses that voice" is the // answer the test exists to give. +// ── Library index ────────────────────────────────────────────────────── +// The clinical library is indexed by the clinical-assist indexer, which no +// longer polls Nextcloud every five minutes: it reconciles once when it starts +// and then only when asked. "Run indexing now" is that ask — new and changed +// documents are queued, documents that have gone are dropped after a second +// scan confirms it, and nothing already indexed is touched. The indexer's +// address and its trigger token are settings (or the environment), so the +// button works without a rebuild when the indexer moves. +async function indexerTarget() { + var url = String(await db.getSetting('clinical_assistant.indexer_url') || process.env.CLINICAL_ASSISTANT_INDEXER_URL || 'http://mcp-indexer:8001').trim().replace(/\/+$/, ''); + var token = String(await db.getSetting('clinical_assistant.indexer_token') || process.env.CLINICAL_ASSISTANT_INDEXER_TOKEN || '').trim(); + return { url: url, token: token }; +} + +router.get('/config/library-index', async function(req, res) { + try { + var axios = require('axios'); + var target = await indexerTarget(); + var resp = await axios.get(target.url + '/api/v1/vector-sync/status', { timeout: 15000, validateStatus: function() { return true; } }); + if (resp.status !== 200) return res.json({ success: false, error: 'Indexer answered ' + resp.status, url: target.url, tokenConfigured: !!target.token }); + res.json({ success: true, url: target.url, tokenConfigured: !!target.token, status: resp.data }); + } catch (e) { + res.json({ success: false, error: 'Could not reach the indexer: ' + e.message }); + } +}); + +router.post('/config/library-index/scan', async function(req, res) { + try { + var axios = require('axios'); + var target = await indexerTarget(); + if (!target.token) return res.json({ success: false, error: 'No indexer token is set (clinical_assistant.indexer_token or CLINICAL_ASSISTANT_INDEXER_TOKEN)' }); + var resp = await axios.post(target.url + '/api/v1/vector-sync/scan', {}, { + headers: { Authorization: 'Bearer ' + target.token }, timeout: 15000, validateStatus: function() { return true; } + }); + if (resp.status !== 202 && resp.status !== 200) { + return res.json({ success: false, error: 'Indexer refused (' + resp.status + '): ' + ((resp.data && resp.data.error) || '') }); + } + logger.audit(req.user.id, 'library_index_scan', 'Asked the clinical library indexer to reconcile now', req, { category: 'admin' }); + res.json({ success: true, result: resp.data }); + } catch (e) { + res.json({ success: false, error: 'Could not reach the indexer: ' + e.message }); + } +}); + router.post('/config/tts/test', async function(req, res) { try { var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500); diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index deecdd81..5513e0ee 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -270,3 +270,16 @@ test('a share row is inserted through query, since run() appends RETURNING id an assert.doesNotMatch(src, /db\.run\(\s*'INSERT INTO user_resource_shares/); assert.match(src, /db\.query\('INSERT INTO user_resource_shares/); }); + + +test('the library index button is an operation, allowed under lockdown, and the token is never echoed', () => { + const admin = read('src/routes/adminConfig.js'); + assert.match(admin, /OPERATIONAL_WRITE = .*library-index\\\/scan/); + assert.match(admin, /router\.get\('\/config\/library-index'/); + assert.match(admin, /router\.post\('\/config\/library-index\/scan'/); + assert.match(admin, /Authorization: 'Bearer ' \+ target\.token/); + assert.doesNotMatch(admin.slice(admin.indexOf("router.get('/config/library-index'"), admin.indexOf("router.post('/config/library-index/scan'")), /token: target\.token/, 'the status reply must not carry the token'); + const js = read('public/js/admin/clinicalAssistant.js'); + assert.match(js, /setValue\('assistant-indexer-token', ''\)/); + assert.match(read('docs/clinical-assistant.md'), /## Library indexing runs when asked/); +});