feat: the library index reconciles when the admin asks — status and Run indexing now in the Clinical Assistant card
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:
parent
fdbb9326c1
commit
93426ac089
6 changed files with 163 additions and 1 deletions
|
|
@ -248,6 +248,10 @@ DB_PASSWORD=pedscribe_secret_change_me
|
||||||
|
|
||||||
# ── Clinical Assistant: retrieval ───────────────────────────────────────────
|
# ── Clinical Assistant: retrieval ───────────────────────────────────────────
|
||||||
# CLINICAL_ASSISTANT_MCP_URL=http://mcp:8000/mcp
|
# 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_MCP_URLS= # comma-separated, tried in order
|
||||||
# CLINICAL_ASSISTANT_SEARCH_TOOL=clinical_semantic_search # the only accepted value
|
# CLINICAL_ASSISTANT_SEARCH_TOOL=clinical_semantic_search # the only accepted value
|
||||||
# CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS=30000
|
# CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS=30000
|
||||||
|
|
|
||||||
|
|
@ -205,3 +205,34 @@ Add or update tests when changing:
|
||||||
- translation validation, caching and provider fallback,
|
- translation validation, caching and provider fallback,
|
||||||
- MCP result normalization,
|
- MCP result normalization,
|
||||||
- model discovery or settings behavior.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,30 @@
|
||||||
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control">
|
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control">
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="admin-row">
|
||||||
<strong class="admin-row-label">Citations</strong>
|
<strong class="admin-row-label">Citations</strong>
|
||||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,9 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
|
||||||
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
|
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
|
||||||
renderAssistantImageModelCheckboxes();
|
renderAssistantImageModelCheckboxes();
|
||||||
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
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-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
||||||
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
|
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
|
||||||
var sourcesBox = document.getElementById('assistant-show-sources');
|
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
|
// 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
|
// 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.
|
// 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([
|
Promise.all([
|
||||||
putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')),
|
putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')),
|
||||||
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
|
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.context_chars', getValue('assistant-context-chars') || '1400'),
|
||||||
putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'),
|
putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'),
|
||||||
putAssistantConfig('clinical_assistant.show_sources',
|
putAssistantConfig('clinical_assistant.show_sources',
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var lockdown = require('../utils/adminLockdown');
|
||||||
// - /config/:key, which decides per key — some keys stay editable, and that
|
// - /config/:key, which decides per key — some keys stay editable, and that
|
||||||
// route applies lockdown.isLocked() itself.
|
// route applies lockdown.isLocked() itself.
|
||||||
// Everything else — model policy, SMTP, prompts, resets — is configuration.
|
// 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) {
|
router.use(function(req, res, next) {
|
||||||
if (!lockdown.enabled() || req.method === 'GET' || req.method === 'HEAD') return 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
|
// 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
|
// a compatible voice: being told "this model refuses that voice" is the
|
||||||
// answer the test exists to give.
|
// 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) {
|
router.post('/config/tts/test', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500);
|
var text = ((req.body.text || 'Hello, this is a TTS test for Pediatric AI Scribe.')).substring(0, 500);
|
||||||
|
|
|
||||||
|
|
@ -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.doesNotMatch(src, /db\.run\(\s*'INSERT INTO user_resource_shares/);
|
||||||
assert.match(src, /db\.query\('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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue