fix: the assistant asks for context around each excerpt again — read from the stored chunks, 1400 characters by default
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 17:41:01 +02:00
parent 3e10b6faa7
commit b277ce4b10
9 changed files with 37 additions and 18 deletions

View file

@ -53,7 +53,7 @@ clamped on read so a bad value cannot break a search.
| Feature | Keys | Default | Clamp | | Feature | Keys | Default | Clamp |
|---|---|---|---| |---|---|---|---|
| Clinical Assistant | `clinical_assistant.search_limit` | 8 | 320 | | Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 8, 1400 | 320, 04000 (0 = excerpt only) |
| My Resources | `learning.search_limit`, `learning.context_chars` | 30, 2500 | 360, 3008000 | | My Resources | `learning.search_limit`, `learning.context_chars` | 30, 2500 | 360, 3008000 |
The `learning.*` names are historical: they were the Learning Hub's, and My The `learning.*` names are historical: they were the Learning Hub's, and My
@ -61,7 +61,7 @@ Resources inherited the retrieval code when that was removed. Renaming the keys
would orphan whatever an administrator has already set, so they keep the old would orphan whatever an administrator has already set, so they keep the old
names. names.
`search_limit` is how many excerpts to request. (There used to be a `context_chars` setting that made the search service download and re-extract the source document per hit to widen the excerpt; it is gone — the stored excerpt already carries the page, its tables and figure captions.) `learning.context_chars` is how much text `search_limit` is how many excerpts to request; `context_chars` is how much text around each excerpt to add — read from the neighbouring chunks already stored in Milvus, never by re-reading the source document; 0 sends the excerpt alone. It is how much text
to pull around each one. to pull around each one.
See [my-resources.md](my-resources.md) for the rest of that feature — its See [my-resources.md](my-resources.md) for the rest of that feature — its

View file

@ -407,6 +407,10 @@
<label for="assistant-search-limit" class="admin-row-label">Retrieval result limit</label> <label for="assistant-search-limit" class="admin-row-label">Retrieval result limit</label>
<input id="assistant-search-limit" type="number" min="3" max="20" value="8" class="admin-control"> <input id="assistant-search-limit" type="number" min="3" max="20" value="8" class="admin-control">
</div> </div>
<div class="admin-row">
<label for="assistant-context-chars" class="admin-row-label">Context per excerpt (characters)</label>
<input id="assistant-context-chars" type="number" min="0" max="4000" value="1400" class="admin-control" title="Text around each excerpt, read from the stored chunks; 0 = the excerpt only">
</div>
</div> </div>
<div class="admin-row" id="assistant-library-index"> <div class="admin-row" id="assistant-library-index">
<strong class="admin-row-label">Library index</strong> <strong class="admin-row-label">Library index</strong>

View file

@ -226,6 +226,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
setValue('assistant-indexer-url', cfg['clinical_assistant.indexer_url'] || ''); setValue('assistant-indexer-url', cfg['clinical_assistant.indexer_url'] || '');
setValue('assistant-indexer-token', ''); // never echoed back; blank means keep setValue('assistant-indexer-token', ''); // never echoed back; blank means keep
loadLibraryIndexStatus(); loadLibraryIndexStatus();
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] == null || cfg['clinical_assistant.context_chars'] === '' ? '1400' : cfg['clinical_assistant.context_chars']);
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');
if (sourcesBox) { if (sourcesBox) {
@ -435,6 +436,7 @@ 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.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',
(document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true') (document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true')

View file

@ -91,7 +91,8 @@ router.get('/clinical-assistant/status', async function(req, res) {
var chatModel = choices.chatConfigured; var chatModel = choices.chatConfigured;
var imageModel = choices.imageConfigured; var imageModel = choices.imageConfigured;
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8); var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var translateProvider = String(await getSetting('clinical_assistant.translate_provider', '') || 'libretranslate').toLowerCase(); var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 0, 4000, 1400);
var translateProvider = String(await getSetting('clinical_assistant.translate_provider', '') || 'libretranslate').toLowerCase();
if (!clinicalTranslation.TRANSLATE_PROVIDERS.includes(translateProvider)) translateProvider = 'libretranslate'; if (!clinicalTranslation.TRANSLATE_PROVIDERS.includes(translateProvider)) translateProvider = 'libretranslate';
var budget = conversationBudget(process.env); var budget = conversationBudget(process.env);
var mcpHealth = await getMcpHealth(); var mcpHealth = await getMcpHealth();
@ -102,6 +103,7 @@ router.get('/clinical-assistant/status', async function(req, res) {
allowedChatModels: choices.allowedChatModels, allowedChatModels: choices.allowedChatModels,
allowedImageModels: choices.allowedImageModels, allowedImageModels: choices.allowedImageModels,
searchLimit: searchLimit, searchLimit: searchLimit,
contextChars: contextChars,
conversationChars: budget.limit, conversationChars: budget.limit,
conversationUnit: budget.unit, conversationUnit: budget.unit,
conversationEnv: budget.env, conversationEnv: budget.env,
@ -572,7 +574,12 @@ async function prepareAssistantChat(body) {
// The model that gets shown an attachment when the chat model cannot be. // The model that gets shown an attachment when the chat model cannot be.
var visionModel = String(await getSetting('clinical_assistant.vision_model', '') || ''); var visionModel = String(await getSetting('clinical_assistant.vision_model', '') || '');
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8); var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 0, 4000, 1400);
var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR; var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
// The excerpt plus the paragraphs around it. The search service reads those
// neighbours from the chunks already stored in Milvus (it no longer
// downloads and re-extracts the source document); 0 means the excerpt only.
var includeContext = body.includeContext !== false && contextChars > 0;
var showSources = await showSourcesEnabled(); var showSources = await showSourcesEnabled();
// Phase timings, numbers only, so "is it slow?" can be answered from the // Phase timings, numbers only, so "is it slow?" can be answered from the
@ -585,10 +592,11 @@ async function prepareAssistantChat(body) {
return message; return message;
}); });
timing.rewriteMs = Date.now() - phase; phase = Date.now(); timing.rewriteMs = Date.now() - phase; phase = Date.now();
// The stored excerpt is what the model sees: it already carries the page, var searchResponse = await semanticSearch(searchQuery, {
// its tables and figure captions. The search service no longer re-reads limit: searchLimit,
// the source document at query time. includeContext: includeContext,
var searchResponse = await semanticSearch(searchQuery, { limit: searchLimit }); contextChars: contextChars
});
timing.searchMs = Date.now() - phase; timing.searchMs = Date.now() - phase;
// Retrieval is text-only. The multimodal path called nc_multimodal_search // Retrieval is text-only. The multimodal path called nc_multimodal_search
// against a second hardcoded collection with an embedding service that was // against a second hardcoded collection with an embedding service that was

View file

@ -89,6 +89,8 @@ async function semanticSearch(query, opts) {
doc_types: ['file'], doc_types: ['file'],
score_threshold: 0, score_threshold: 0,
fusion: 'rrf', fusion: 'rrf',
include_context: opts.includeContext,
context_chars: opts.contextChars
}); });
} }

View file

@ -80,15 +80,15 @@ test('native admin initializer preserves lazy navigation, assistant actions and
const save = document.getElementById('btn-save-assistant-config'); const save = document.getElementById('btn-save-assistant-config');
save.click(); await tick(); save.click(); await tick();
// Each card saves exactly what it shows. Save & Close on the Clinical // Each card saves exactly what it shows. Save & Close on the Clinical
// Assistant card writes the four retrieval/citation/translation/budget keys; // Assistant card writes the five retrieval/citation/translation/budget keys;
// the chat model and the two allowed lists belong to the Availability card // the chat model and the two allowed lists belong to the Availability card
// and are written by its own Save below. The signed-out preview is a feature // and are written by its own Save below. The signed-out preview is a feature
// flag saved by the Feature Flags card, not by either. // flag saved by the Feature Flags card, not by either.
assert.equal(writes().length, 4); assert.equal(writes().length, 5);
assert.equal(writes().filter(c => /preview/.test(c.url)).length, 0, assert.equal(writes().filter(c => /preview/.test(c.url)).length, 0,
'this button no longer writes the preview flag'); 'this button no longer writes the preview flag');
assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [ assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [
'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider' 'clinical_assistant.context_chars', 'clinical_assistant.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.show_sources', 'clinical_assistant.translate_provider'
]); ]);
assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success')); assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success'));
assert.equal(save.closest('details').open, false, 'Save & Close folds the card once saved'); assert.equal(save.closest('details').open, false, 'Save & Close folds the card once saved');

View file

@ -297,15 +297,14 @@ test('silent SSO: prompt=none on request, a refusal is not an error, the page tr
}); });
test('the assistant sends the stored excerpt only: no context expansion request, no setting, no field', () => { test('the assistant asks for context around each excerpt, and 0 means the excerpt only', () => {
const src = read('src/routes/clinicalAssistant.js'); const src = read('src/routes/clinicalAssistant.js');
assert.doesNotMatch(src, /includeContext|contextChars|context_chars/); assert.equal((src.match(/getSetting\('clinical_assistant\.context_chars', '1400'\), 0, 4000, 1400\)/g) || []).length, 2);
assert.doesNotMatch(read('src/utils/clinicalMcpClient.js'), /include_context|context_chars/); assert.match(src, /includeContext = body\.includeContext !== false && contextChars > 0;/);
assert.doesNotMatch(read('public/components/admin.html'), /assistant-context-chars/); assert.match(read('src/utils/clinicalMcpClient.js'), /include_context: opts\.includeContext/);
assert.doesNotMatch(read('public/js/admin/clinicalAssistant.js'), /context_chars/); assert.match(read('public/components/admin.html'), /id="assistant-context-chars" type="number" min="0"/);
}); });
test('sign-out ends the provider session: the server hands back the end-session address and the page goes there', () => { test('sign-out ends the provider session: the server hands back the end-session address and the page goes there', () => {
const auth = read('src/routes/auth.js'); const auth = read('src/routes/auth.js');
assert.match(auth, /res\.json\(\{ success: true, endSessionUrl: endSessionUrl \}\)/); assert.match(auth, /res\.json\(\{ success: true, endSessionUrl: endSessionUrl \}\)/);

View file

@ -89,7 +89,7 @@ test('native admin and assistant modules retain budget, table/source identity an
// Five: Save & Close writes the Clinical Assistant card's own settings. The // Five: Save & Close writes the Clinical Assistant card's own settings. The
// chat model and allowed lists are saved by the Availability card, and the // chat model and allowed lists are saved by the Availability card, and the
// signed-out preview by the Feature Flags card. // signed-out preview by the Feature Flags card.
assert.equal(calls.filter(call => call.options.method === 'PUT').length, 4, 'one native admin initializer; prompts are not generic setting saves'); assert.equal(calls.filter(call => call.options.method === 'PUT').length, 5, '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'); 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' } })); document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick(); await tick(); await tick(); await tick(); await tick(); await tick();

View file

@ -268,6 +268,7 @@ const assistantConfig = () => ({ success: true, config: [
{ key: 'clinical_assistant.chat_model', value: 'saved-chat' }, { key: 'clinical_assistant.chat_model', value: 'saved-chat' },
{ key: 'clinical_assistant.image_model', value: 'saved-image' }, { key: 'clinical_assistant.image_model', value: 'saved-image' },
{ key: 'clinical_assistant.search_limit', value: '17' }, { key: 'clinical_assistant.search_limit', value: '17' },
{ key: 'clinical_assistant.context_chars', value: '2300' }
], conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' } }); ], conversationBudget: { limit: 240000, unit: 'characters', measure: 'UTF-16 code units', env: 'CLINICAL_ASSISTANT_CONVERSATION_CHARS', source: 'environment' } });
const adminVisit = ui => ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); const adminVisit = ui => ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
const setting = (ui, name) => ui.document.getElementById('assistant-' + name); const setting = (ui, name) => ui.document.getElementById('assistant-' + name);
@ -302,12 +303,14 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
assert.equal(ui.document.getElementById('workflow-image-settings').children.length, 0, 'image settings load only after config success'); assert.equal(ui.document.getElementById('workflow-image-settings').children.length, 0, 'image settings load only after config success');
assert.equal(ui.calls.some(c => c.url.endsWith('/image-models/discover')), false); assert.equal(ui.calls.some(c => c.url.endsWith('/image-models/discover')), false);
setting(ui, 'search-limit').value = '21'; setting(ui, 'search-limit').value = '21';
setting(ui, 'context-chars').value = '3100';
setting(ui, 'chat-model').appendChild(new ui.window.Option('Draft chat', 'draft-chat')); setting(ui, 'chat-model').appendChild(new ui.window.Option('Draft chat', 'draft-chat'));
const retry = ui.document.getElementById('btn-retry-assistant-config'); const retry = ui.document.getElementById('btn-retry-assistant-config');
assert.equal(retry.hidden, false); assert.equal(retry.hidden, false);
assert.equal(retry.type, 'button'); assert.equal(retry.type, 'button');
retry.click(); await tick(); retry.click(); await tick();
assert.equal(setting(ui, 'search-limit').value, '21'); assert.equal(setting(ui, 'search-limit').value, '21');
assert.equal(setting(ui, 'context-chars').value, '3100');
assert.equal(setting(ui, 'chat-model').value, 'draft-chat'); assert.equal(setting(ui, 'chat-model').value, 'draft-chat');
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 2); assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 2);
@ -315,6 +318,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
adminVisit(ui); await tick(); adminVisit(ui); await tick();
assert.equal(setting(ui, 'chat-model').value, 'saved-chat'); assert.equal(setting(ui, 'chat-model').value, 'saved-chat');
assert.equal(setting(ui, 'search-limit').value, '17'); assert.equal(setting(ui, 'search-limit').value, '17');
assert.equal(setting(ui, 'context-chars').value, '2300');
// Empty means "no saved override" — see admin-clinical-assistant-wiring. // Empty means "no saved override" — see admin-clinical-assistant-wiring.
// The environment value is the placeholder so Save cannot promote it silently. // The environment value is the placeholder so Save cannot promote it silently.
assert.equal(setting(ui, 'conversation-budget').value, '', 'no saved override, so the field stays empty'); assert.equal(setting(ui, 'conversation-budget').value, '', 'no saved override, so the field stays empty');
@ -335,7 +339,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
// Saving an untouched form must NOT turn the environment value into a // Saving an untouched form must NOT turn the environment value into a
// stored override — empty is the "use the environment" signal. // stored override — empty is the "use the environment" signal.
['clinical_assistant.conversation_chars', ''], ['clinical_assistant.conversation_chars', ''],
['clinical_assistant.search_limit', '19'], ['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
['clinical_assistant.translate_provider', 'libretranslate'], ['clinical_assistant.translate_provider', 'libretranslate'],
['clinical_assistant.show_sources', 'true'] ['clinical_assistant.show_sources', 'true']
]); ]);