diff --git a/docs/retrieval-tuning.md b/docs/retrieval-tuning.md
index e75c0e3e..a7bd2113 100644
--- a/docs/retrieval-tuning.md
+++ b/docs/retrieval-tuning.md
@@ -53,7 +53,7 @@ clamped on read so a bad value cannot break a search.
| Feature | Keys | Default | Clamp |
|---|---|---|---|
-| Clinical Assistant | `clinical_assistant.search_limit` | 8 | 3–20 |
+| Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 8, 1400 | 3–20, 0–4000 (0 = excerpt only) |
| My Resources | `learning.search_limit`, `learning.context_chars` | 30, 2500 | 3–60, 300–8000 |
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
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.
See [my-resources.md](my-resources.md) for the rest of that feature — its
diff --git a/public/components/admin.html b/public/components/admin.html
index 1d49b0cc..d87360e6 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -407,6 +407,10 @@
+
+
+
+
Library index
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index fc305f3e..9d78cfc5 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -226,6 +226,7 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
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'] == null || cfg['clinical_assistant.context_chars'] === '' ? '1400' : cfg['clinical_assistant.context_chars']);
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
var sourcesBox = document.getElementById('assistant-show-sources');
if (sourcesBox) {
@@ -435,6 +436,7 @@ 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.context_chars', getValue('assistant-context-chars') || '1400'),
putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'),
putAssistantConfig('clinical_assistant.show_sources',
(document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true')
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index 70a7ffdd..1e2101a5 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -91,7 +91,8 @@ router.get('/clinical-assistant/status', async function(req, res) {
var chatModel = choices.chatConfigured;
var imageModel = choices.imageConfigured;
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';
var budget = conversationBudget(process.env);
var mcpHealth = await getMcpHealth();
@@ -102,6 +103,7 @@ router.get('/clinical-assistant/status', async function(req, res) {
allowedChatModels: choices.allowedChatModels,
allowedImageModels: choices.allowedImageModels,
searchLimit: searchLimit,
+ contextChars: contextChars,
conversationChars: budget.limit,
conversationUnit: budget.unit,
conversationEnv: budget.env,
@@ -572,7 +574,12 @@ async function prepareAssistantChat(body) {
// The model that gets shown an attachment when the chat model cannot be.
var visionModel = String(await getSetting('clinical_assistant.vision_model', '') || '');
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;
+ // 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();
// Phase timings, numbers only, so "is it slow?" can be answered from the
@@ -585,10 +592,11 @@ async function prepareAssistantChat(body) {
return message;
});
timing.rewriteMs = Date.now() - phase; phase = Date.now();
- // The stored excerpt is what the model sees: it already carries the page,
- // its tables and figure captions. The search service no longer re-reads
- // the source document at query time.
- var searchResponse = await semanticSearch(searchQuery, { limit: searchLimit });
+ var searchResponse = await semanticSearch(searchQuery, {
+ limit: searchLimit,
+ includeContext: includeContext,
+ contextChars: contextChars
+ });
timing.searchMs = Date.now() - phase;
// Retrieval is text-only. The multimodal path called nc_multimodal_search
// against a second hardcoded collection with an embedding service that was
diff --git a/src/utils/clinicalMcpClient.js b/src/utils/clinicalMcpClient.js
index 41c6e0bc..565610af 100644
--- a/src/utils/clinicalMcpClient.js
+++ b/src/utils/clinicalMcpClient.js
@@ -89,6 +89,8 @@ async function semanticSearch(query, opts) {
doc_types: ['file'],
score_threshold: 0,
fusion: 'rrf',
+ include_context: opts.includeContext,
+ context_chars: opts.contextChars
});
}
diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js
index 7c8a9086..ebb2010a 100644
--- a/test/admin-clinical-assistant-wiring.test.js
+++ b/test/admin-clinical-assistant-wiring.test.js
@@ -80,15 +80,15 @@ test('native admin initializer preserves lazy navigation, assistant actions and
const save = document.getElementById('btn-save-assistant-config');
save.click(); await tick();
// 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
// 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.
- assert.equal(writes().length, 4);
+ assert.equal(writes().length, 5);
assert.equal(writes().filter(c => /preview/.test(c.url)).length, 0,
'this button no longer writes the preview flag');
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.equal(save.closest('details').open, false, 'Save & Close folds the card once saved');
diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js
index ae7c2db3..64a604b1 100644
--- a/test/backend-hardening.test.js
+++ b/test/backend-hardening.test.js
@@ -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');
- assert.doesNotMatch(src, /includeContext|contextChars|context_chars/);
- assert.doesNotMatch(read('src/utils/clinicalMcpClient.js'), /include_context|context_chars/);
- assert.doesNotMatch(read('public/components/admin.html'), /assistant-context-chars/);
- assert.doesNotMatch(read('public/js/admin/clinicalAssistant.js'), /context_chars/);
+ assert.equal((src.match(/getSetting\('clinical_assistant\.context_chars', '1400'\), 0, 4000, 1400\)/g) || []).length, 2);
+ assert.match(src, /includeContext = body\.includeContext !== false && contextChars > 0;/);
+ assert.match(read('src/utils/clinicalMcpClient.js'), /include_context: opts\.includeContext/);
+ 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', () => {
const auth = read('src/routes/auth.js');
assert.match(auth, /res\.json\(\{ success: true, endSessionUrl: endSessionUrl \}\)/);
diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js
index 6d62b421..abbd0a1f 100644
--- a/test/clinical-release-integration.test.js
+++ b/test/clinical-release-integration.test.js
@@ -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
// chat model and allowed lists are saved by the Availability card, and the
// 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');
document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } }));
await tick(); await tick(); await tick();
diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js
index 5b35a9fe..2fd1a38a 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -268,6 +268,7 @@ const assistantConfig = () => ({ success: true, config: [
{ key: 'clinical_assistant.chat_model', value: 'saved-chat' },
{ key: 'clinical_assistant.image_model', value: 'saved-image' },
{ 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' } });
const adminVisit = ui => ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
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.calls.some(c => c.url.endsWith('/image-models/discover')), false);
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'));
const retry = ui.document.getElementById('btn-retry-assistant-config');
assert.equal(retry.hidden, false);
assert.equal(retry.type, 'button');
retry.click(); await tick();
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');
await forceAssistantSave(ui); assert.equal(writes(ui).length, 0);
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();
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');
// Empty means "no saved override" — see admin-clinical-assistant-wiring.
// 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');
@@ -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
// stored override — empty is the "use the environment" signal.
['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.show_sources', 'true']
]);