feat: the library index reconciles when the admin asks — status and Run indexing now in the Clinical Assistant card
Some checks failed
Forgejo Docker Build / Root app tests (push) Failing after 58s
Forgejo Docker Build / Build Docker image (push) Has been skipped
Forgejo Docker Build / End-to-end (browser) (push) Has been skipped

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 16:02:35 +02:00
parent fdbb9326c1
commit 93426ac089
6 changed files with 163 additions and 1 deletions

View file

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

View file

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

View file

@ -412,6 +412,30 @@
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control">
</div>
</div>
<div class="admin-row" id="assistant-library-index">
<strong class="admin-row-label">Library index</strong>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;">
<div id="assistant-index-status" class="admin-note" aria-live="polite">Checking the indexer…</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button type="button" class="btn btn-secondary btn-sm" id="btn-assistant-index-now"><i class="fas fa-sync"></i> Run indexing now</button>
<button type="button" class="btn btn-link btn-sm" id="btn-assistant-index-refresh">Refresh status</button>
</div>
<span class="admin-note">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.</span>
<details style="margin-top:4px;">
<summary style="cursor:pointer;font-size:13px;">Indexer address and token</summary>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px;">
<div class="admin-row">
<label for="assistant-indexer-url" class="admin-row-label">Indexer URL</label>
<input id="assistant-indexer-url" type="url" placeholder="http://mcp-indexer:8001" class="admin-control" autocomplete="off">
</div>
<div class="admin-row">
<label for="assistant-indexer-token" class="admin-row-label">Trigger token</label>
<input id="assistant-indexer-token" type="password" placeholder="leave blank to keep" class="admin-control" autocomplete="new-password">
</div>
</div>
</details>
</div>
</div>
<div class="admin-row">
<strong class="admin-row-label">Citations</strong>
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">

View file

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

View file

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

View file

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