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_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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue