pediatric-ai-scribe-v3/test/admin-clinical-assistant-wiring.test.js
Daniel 9788b167f2 refactor: retrieval is text-only; admins can add model ids discovery never returns
Multimodal removal
The multimodal path called nc_multimodal_search against a second hardcoded
collection whose embedding service (multimodal-embeddings:7999) was never
deployed and ENABLE_MULTIMODAL_RAG has always been false, so it only ever logged
"multimodal search skipped". Removed rather than left as dead weight:

- clinicalRetrieval: normalizeMcpMultimodalResponse, isVisualSourceQuery,
  isRadiologyQuery, buildMultimodalSearchQuery, classifyAndRerankMultimodalResults,
  selectMultimodalResults, visualIntent, visualMetadataScore,
  shouldRejectVisualSource, allowsFrontMatterQuery, looksLikeFrontMatterPage,
  looksLikeTextOnlyPage and MULTIMODAL_CANDIDATE_LIMIT (~140 lines).
- clinicalMcpClient: multimodalSearch.
- The route's visual/text slot split is gone; the whole search limit is text.
- The "[visual PDF page match]" prompt label and the "visual PDF page" source
  badge are gone with it.

Adding models
Model availability could only be ticked from what the gateway advertised, so an
admin could never offer a model discovery did not list. Each list now has a text
field: a typed id joins the same checkbox list, is enabled by default, is
de-duplicated, and persists through the normal allowed_models save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 23:35:06 +02:00

161 lines
11 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { JSDOM } = require('jsdom');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
function browserGlobals(t, dom, fetch, toasts) {
const values = { window: dom.window, document: dom.window.document, fetch, getAuthHeaders: () => ({ 'X-Test': 'synthetic' }), showToast: (...args) => toasts.push(args) };
const originals = Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(global, key)]);
Object.assign(global, values);
Object.assign(dom.window, { fetch, getAuthHeaders: values.getAuthHeaders });
t.after(() => {
for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(global, key, descriptor); else delete global[key]; }
dom.window.close();
});
}
test('native admin initializer preserves lazy navigation, assistant actions and read-only ENV budget metadata', async t => {
const dom = new JSDOM('<button class="tab-btn active" data-tab="home">Home</button><button class="tab-btn" data-tab="admin">Admin</button><div id="home-tab" class="tab-content" data-component="home" data-loaded="1"></div><div id="admin-tab" class="tab-content" data-component="admin"></div>', { runScripts: 'outside-only', url: 'https://app.example' });
const calls = []; const toasts = [];
const fetch = async (url, options = {}) => {
calls.push({ url, options });
let data = {};
if (url.startsWith('/components/admin.html?')) return { ok: true, text: async () => read('public/components/admin.html') };
if (url === '/api/models') data = { models: [{ id: 'chat', name: 'Chat' }], defaultModel: 'chat' };
if (url === '/api/admin/config') {
assert.ok(document.getElementById('assistant-chat-model'), 'tabChanged fires after lazy markup exists');
data = { success: true, conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' }, config: [{ key: 'clinical_assistant.conversation_chars', value: '999999' }, { key: 'clinical_assistant.image_model', value: 'saved-image' }] };
}
if (url === '/api/admin/config/image-models/discover') data = { models: [{ id: 'image', name: 'Image' }] };
if (url === '/api/admin/clinical-assistant/prompt-pool') data = { success: true, meta: { count: 3 }, snapshots: [{ id: 7, count: 3 }] };
if (options.method) data = { success: true, duration: 1, response: 'Synthetic', meta: { count: 4 }, snapshots: [{ id: 7, count: 4 }] };
return { ok: true, json: async () => data };
};
browserGlobals(t, dom, fetch, toasts);
await import(pathToFileURL(path.join(root, 'public/js/admin.js')).href);
assert.equal(calls.length, 0, 'registration must not eagerly load assistant settings');
// JSDOM does not execute native script tags. Import the unmodified ESM above,
// then execute the real classic app entrypoint and its component loader.
await tick();
dom.window.eval(read('public/js/app.js'));
document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
await tick();
assert.equal(document.getElementById('assistant-chat-model'), null);
document.querySelector('[data-tab="admin"]').click();
await tick(); await tick();
assert.equal(document.getElementById('admin-tab').dataset.loaded, '1');
assert.equal(document.getElementById('assistant-chat-model').options[0].textContent, 'Use global default (chat)');
assert.equal(document.getElementById('assistant-image-model'), null, 'single image-model dropdown lives in workflow-image-settings');
assert.match(document.getElementById('assistant-prompt-pool-status').textContent, /3 prompts/);
const budget = document.getElementById('assistant-conversation-budget');
assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0);
assert.equal(budget.type, 'number', 'the conversation budget is an editable admin input');
assert.equal(budget.readOnly, false, 'the budget is admin-editable');
assert.equal(budget.value, '999999', 'prefilled with the saved override');
const budgetMeta = document.getElementById('assistant-conversation-budget-meta').textContent;
assert.match(budgetMeta, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/);
assert.match(budgetMeta, /Clear this field/, 'the admin is told how to get back to the environment value');
assert.match(budgetMeta, /240,000/, 'and what that environment value currently is');
const initialConfigLoads = calls.filter(c => c.url === '/api/admin/config').length;
document.querySelector('[data-tab="home"]').click(); await tick();
document.querySelector('[data-tab="admin"]').click(); await tick();
assert.equal(calls.filter(c => c.url.startsWith('/components/admin.html?')).length, 1);
assert.equal(calls.filter(c => c.url === '/api/admin/config').length, initialConfigLoads, 'initializer loads once across revisits');
const writes = () => calls.filter(c => c.options.method === 'PUT');
const save = document.getElementById('btn-save-assistant-config');
save.click(); await tick();
assert.equal(writes().length, 8);
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.citations_enabled', 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
]);
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
document.getElementById('btn-test-assistant-chat-model').click(); await tick();
assert.deepEqual(JSON.parse(calls.find(c => c.url === '/api/admin/config/models/test').options.body), { modelId: 'chat' });
document.getElementById('btn-regenerate-assistant-prompt-pool').click(); await tick();
assert.equal(document.getElementById('btn-regenerate-assistant-prompt-pool').disabled, false);
document.getElementById('assistant-prompt-pool-snapshots').value = '7';
document.getElementById('btn-restore-assistant-prompt-pool').click(); await tick();
assert.deepEqual(JSON.parse(calls.find(c => c.url.endsWith('/prompt-pool/restore')).options.body), { id: 7 });
assert.equal(document.getElementById('assistant-custom-image-model'), null);
assert.equal(document.getElementById('btn-test-assistant-image-model'), null);
assert.equal(document.getElementById('btn-use-custom-assistant-image-model'), null);
await tick();
});
test('real extracted initializer never invents a cap when metadata is missing, invalid or returns 503', async t => {
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
for (const data of [{}, { success: false, error: 'Invalid environment' }, { success: true, conversationBudget: { limit: 1000001 } }]) {
await t.test(JSON.stringify(data), async t => {
const dom = new JSDOM(read('public/components/admin.html'));
browserGlobals(t, dom, async () => ({ status: 503, json: async () => data }), []);
initClinicalAssistantAdmin(value => value);
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
assert.equal(document.getElementById('assistant-conversation-budget').value, '', 'failed load leaves the budget input empty');
// The failure notice must land on the <p>; writing it to the <input>
// rendered nothing, so admins saw a stale message on a broken load.
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /Conversation budget unavailable/);
assert.doesNotMatch(document.getElementById('assistant-conversation-budget-meta').textContent, /Leave empty/, 'no misleading guidance when the load failed');
});
}
});
test('real extracted initializer displays the server default only when returned as metadata', async t => {
const dom = new JSDOM(read('public/components/admin.html'));
browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({ success: true, config: [], models: [], conversationBudget: {
limit: 120000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'default'
} }) }), []);
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
initClinicalAssistantAdmin(value => value);
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
// No saved override: the field stays EMPTY so Save cannot silently promote the
// environment value into a stored override. The number is shown as a placeholder.
const noOverride = document.getElementById('assistant-conversation-budget');
assert.equal(noOverride.value, '', 'no override means an empty field, not a prefilled one');
assert.equal(noOverride.placeholder, '120000', 'the effective limit is shown as a placeholder');
assert.equal(noOverride.readOnly, false);
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /built-in default/,
"source 'default' must not be reported as coming from the environment variable");
});
test('an admin can add a model id discovery never returned', async t => {
const dom = new JSDOM(read('public/components/admin.html'));
browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({
success: true, config: [], models: [{ id: 'discovered-model', name: 'Discovered' }],
conversationBudget: { limit: 120000, source: 'default' }
}) }), []);
const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
initClinicalAssistantAdmin(value => value);
document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
const list = document.getElementById('assistant-allowed-chat-models');
const field = document.getElementById('assistant-add-chat-model');
assert.ok(field, 'there is somewhere to type a model id');
const before = list.querySelectorAll('input[type="checkbox"]').length;
field.value = 'openrouter-deepseek-v4-pro';
document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click();
await tick();
const values = Array.from(list.querySelectorAll('input[type="checkbox"]')).map(b => b.value);
assert.ok(values.includes('openrouter-deepseek-v4-pro'), 'the typed id joins the list');
assert.equal(values.length, before + 1);
const added = list.querySelector('input[value="openrouter-deepseek-v4-pro"]');
assert.equal(added.checked, true, 'and is enabled, since that is why it was added');
assert.equal(field.value, '', 'the field clears for the next one');
// Adding the same id twice must not duplicate the row.
field.value = 'openrouter-deepseek-v4-pro';
document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click();
await tick();
assert.equal(list.querySelectorAll('input[value="openrouter-deepseek-v4-pro"]').length, 1);
});