From 9a42b96ec9cac5d9077184fb5fa77dec2ee16aea Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 17:49:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20controls=20the=20conversation?= =?UTF-8?q?=20budget=20=E2=80=94=20saved=20override=20wins=20over=20CLINIC?= =?UTF-8?q?AL=5FASSISTANT=5FCONVERSATION=5FCHARS,=20validated=201000-10000?= =?UTF-8?q?00,=20empty=20falls=20back=20to=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/components/admin.html | 4 ++-- public/js/admin/clinicalAssistant.js | 11 ++++++----- src/routes/adminConfig.js | 7 ++++++- src/routes/clinicalAssistant.js | 9 +++++++-- test/admin-clinical-assistant-wiring.test.js | 18 +++++++++--------- test/clinical-release-integration.test.js | 8 ++++---- test/frontend-prompt-env.test.js | 5 +++-- test/prompt-administration.test.js | 8 +++++--- 8 files changed, 42 insertions(+), 28 deletions(-) diff --git a/public/components/admin.html b/public/components/admin.html index 6864e11..99ef15d 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -337,8 +337,8 @@
Conversation input budget
- -

Controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS — saved settings cannot change it.

+ +

UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.

diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index 5b476c0..ff8280b 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -138,13 +138,13 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { setValue('assistant-translate-provider', ['libretranslate', 'deepl'].includes(cfg['clinical_assistant.translate_provider']) ? cfg['clinical_assistant.translate_provider'] : 'libretranslate'); var budgetInput = document.getElementById('assistant-conversation-budget'); if (budgetInput && budget && Number.isInteger(budget.limit)) { - budgetInput.value = String(budget.limit); - budgetInput.readOnly = true; + var cfgBudget = cfg['clinical_assistant.conversation_chars']; + budgetInput.value = cfgBudget ? String(cfgBudget) : String(budget.limit); } var budgetMeta = document.getElementById('assistant-conversation-budget-meta'); - if (budgetMeta) budgetMeta.textContent = budget && Number.isInteger(budget.limit) - ? 'Controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS (' + budget.limit.toLocaleString() + ' UTF-16 code units). Saved settings cannot change it.' - : 'Controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS — saved settings cannot change it.'; + if (budgetMeta) budgetMeta.textContent = budget && budget.source === 'environment' + ? 'Currently from CLINICAL_ASSISTANT_CONVERSATION_CHARS (' + budget.limit.toLocaleString() + '). Set a value here to override it; clear it to use the environment again.' + : 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.'; configState = 'ready'; updateAssistantLoadState(); loadAssistantImageModels(); @@ -360,6 +360,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { if (status) status.textContent = 'Saving...'; Promise.all([ putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')), + putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')), putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'), putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'), putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'), diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 3e6a529..724f033 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -852,7 +852,12 @@ router.put('/config/:key(*)', async function(req, res) { if (key.startsWith('models.')) return res.status(400).json({ error: 'Use the model configuration endpoints' }); if (key.startsWith('feature.') && !['true', 'false'].includes(String(value))) return res.status(400).json({ error: 'Feature value must be true or false' }); if (key === 'clinical_assistant.conversation_chars') { - return res.status(400).json({ error: 'Conversation budget is controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS, not saved settings' }); + if (value !== '' && value != null) { + var budgetParsed = parseInt(value, 10); + if (!Number.isInteger(budgetParsed) || budgetParsed < 1000 || budgetParsed > 1000000) { + return res.status(400).json({ error: 'Conversation budget must be an integer between 1000 and 1000000 UTF-16 code units, or empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS' }); + } + } } if (key.startsWith('prompt.') || promptCatalog.find(key)) { if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' }); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index d6ed7c7..894db98 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -757,8 +757,13 @@ function isUsefulIndexedTopicExample(item) { } async function getConversationLimit() { - // The conversation budget is controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS - // only. Saved settings never change it (adminConfig.js rejects the key). + // Admin-set value wins over the environment so the administrator can test + // the warning/refusal behavior with a lower limit. + var override = await getSetting('clinical_assistant.conversation_chars', ''); + if (override !== '') { + var parsed = parseInt(override, 10); + if (Number.isInteger(parsed) && parsed >= 1000 && parsed <= 1000000) return parsed; + } return conversationBudget(process.env).limit; } diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index 931108b..f516add 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -54,11 +54,11 @@ test('native admin initializer preserves lazy navigation, assistant actions and 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 admin input'); - assert.equal(budget.readOnly, true, 'the budget is read-only: the env var controls it'); - assert.equal(budget.value, '240000', 'prefilled with the environment limit'); + 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'); assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /CLINICAL_ASSISTANT_CONVERSATION_CHARS/); - assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /cannot change it/); + assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /override it/); 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(); @@ -68,9 +68,9 @@ test('native admin initializer preserves lazy navigation, assistant actions and 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, 6); + assert.equal(writes().length, 7); 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.context_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider' + 'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', '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')); @@ -97,7 +97,7 @@ test('real extracted initializer never invents a cap when metadata is missing, i 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'); - assert.equal(document.getElementById('assistant-conversation-budget-meta').textContent, 'Controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS — saved settings cannot change it.'); + assert.equal(document.getElementById('assistant-conversation-budget-meta').textContent, 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.'); }); } }); @@ -111,6 +111,6 @@ test('real extracted initializer displays the server default only when returned initClinicalAssistantAdmin(value => value); document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); await tick(); - assert.equal(document.getElementById('assistant-conversation-budget').value, '120000', 'read-only budget prefilled from the server metadata'); - assert.equal(document.getElementById('assistant-conversation-budget').readOnly, true); + assert.equal(document.getElementById('assistant-conversation-budget').value, '120000', 'editable budget prefilled from the server metadata'); + assert.equal(document.getElementById('assistant-conversation-budget').readOnly, false); }); diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js index 6f3cf02..3d40ba1 100644 --- a/test/clinical-release-integration.test.js +++ b/test/clinical-release-integration.test.js @@ -80,13 +80,13 @@ test('native admin and assistant modules retain budget, table/source identity an document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); await tick(); assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0); - assert.equal(document.getElementById('assistant-conversation-budget').value, '2000'); - assert.equal(document.getElementById('assistant-conversation-budget').readOnly, true, 'the budget is read-only: the env var controls it'); + assert.equal(document.getElementById('assistant-conversation-budget').value, '999999'); + assert.equal(document.getElementById('assistant-conversation-budget').readOnly, false, 'the budget is admin-editable'); document.getElementById('btn-save-assistant-config').click(); await tick(); assert.equal(limit, 2000); - assert.equal(calls.filter(call => call.options.method === 'PUT').length, 6, 'one native admin initializer; prompts and ENV budget are not generic setting saves'); - assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), false, 'the conversation budget is never saved as a setting'); + assert.equal(calls.filter(call => call.options.method === 'PUT').length, 7, 'one native admin initializer; prompts are not generic setting saves'); + assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), true, 'the conversation budget is an admin-settable override'); document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } })); await tick(); await tick(); await tick(); const input = document.getElementById('assistant-input'); diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index 7a2cb75..c331dae 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -310,8 +310,8 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves assert.equal(setting(ui, 'chat-model').value, 'saved-chat'); assert.equal(setting(ui, 'search-limit').value, '17'); assert.equal(setting(ui, 'context-chars').value, '2300'); - assert.equal(setting(ui, 'conversation-budget').value, '240000', 'read-only budget prefilled from environment metadata'); - assert.equal(setting(ui, 'conversation-budget').readOnly, true); + assert.equal(setting(ui, 'conversation-budget').value, '240000', 'editable budget prefilled from environment metadata'); + assert.equal(setting(ui, 'conversation-budget').readOnly, false); assert.match(setting(ui, 'admin-status').textContent, /ready/i); assert.equal(retry.hidden, true); adminVisit(ui); adminVisit(ui); await tick(); @@ -320,6 +320,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves ui.document.getElementById('btn-save-assistant-config').click(); await tick(); assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [ ['clinical_assistant.chat_model', 'saved-chat'], + ['clinical_assistant.conversation_chars', '240000'], ['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'], ['clinical_assistant.translate_provider', 'libretranslate'], ['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', ''] diff --git a/test/prompt-administration.test.js b/test/prompt-administration.test.js index 3d45506..ef7a36b 100644 --- a/test/prompt-administration.test.js +++ b/test/prompt-administration.test.js @@ -269,7 +269,7 @@ test('Scribe object stays shared across consumers; defaults/helpers resist overr assert.notEqual(svc.prompts.getDefaultPrompt('hpiEncounter'), 'Concurrent edit'); }); -test('history is newest first, bounded 20/100, and admin budget uses ENV only without any legacy write', async t => { +test('history is newest first, bounded 20/100, and admin budget overrides ENV with validated writes', async t => { const svc = services(); for (let i = 0; i < 105; i++) await svc.revisions.mutate(svc.db, 'clinical_assistant.image_behavior', { action: 'save', value: 'synthetic ' + i }); assert.equal((await svc.revisions.history(svc.db, 'clinical_assistant.image_behavior')).revisions.length, 20); @@ -279,8 +279,10 @@ test('history is newest first, bounded 20/100, and admin budget uses ENV only wi const app = await application(t, svc, { CLINICAL_ASSISTANT_CONVERSATION_CHARS: '1000' }); const config = await app.request('GET', '/config'); assert.deepEqual(config.body.conversationBudget, { limit: 1000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' }); - assert.equal((await app.request('PUT', '/config/clinical_assistant.conversation_chars', { value: '5000' })).status, 400); - assert.equal(svc.state.settings.get('clinical_assistant.conversation_chars'), '999999'); + assert.equal((await app.request('PUT', '/config/clinical_assistant.conversation_chars', { value: '5000' })).status, 200); + assert.equal(svc.state.settings.get('clinical_assistant.conversation_chars'), '5000'); + assert.equal((await app.request('PUT', '/config/clinical_assistant.conversation_chars', { value: '50' })).status, 400, 'below the 1000 floor is rejected'); + assert.equal((await app.request('PUT', '/config/clinical_assistant.conversation_chars', { value: '99999999' })).status, 400, 'above the 1000000 ceiling is rejected'); const invalid = await application(t, svc, { CLINICAL_ASSISTANT_CONVERSATION_CHARS: 'not-a-number' }); const unavailable = await invalid.request('GET', '/config'); assert.equal(unavailable.status, 503); assert.equal(unavailable.body.conversationBudget, undefined);