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/);
+});