feat: admin controls the conversation budget — saved override wins over CLINICAL_ASSISTANT_CONVERSATION_CHARS, validated 1000-1000000, empty falls back to env
This commit is contained in:
parent
598731e170
commit
9a42b96ec9
8 changed files with 42 additions and 28 deletions
|
|
@ -337,8 +337,8 @@
|
|||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Conversation input budget</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<input id="assistant-conversation-budget" type="number" min="1000" max="1000000" class="admin-control" placeholder="e.g. 120000" aria-label="Conversation input budget (UTF-16 code units)" readonly style="background:var(--g50);color:var(--g600);">
|
||||
<p id="assistant-conversation-budget-meta" role="status" style="margin:0;font-size:12px;color:var(--g500);">Controlled by CLINICAL_ASSISTANT_CONVERSATION_CHARS — saved settings cannot change it.</p>
|
||||
<input id="assistant-conversation-budget" type="number" min="1000" max="1000000" class="admin-control" placeholder="e.g. 120000" aria-label="Conversation input budget (UTF-16 code units)">
|
||||
<p id="assistant-conversation-budget-meta" role="status" style="margin:0;font-size:12px;color:var(--g500);">UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.</p>
|
||||
</div>
|
||||
</div>
|
||||
<section aria-labelledby="clinical-prompts-heading">
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', '']
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue