From a6c2cb1080d4c07d3da464875f49bf3549479545 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 17:16:28 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20context=20expansion=20is=20off=20by=20d?= =?UTF-8?q?efault=20=E2=80=94=20the=20stored=20excerpt=20already=20carries?= =?UTF-8?q?=20the=20page,=20its=20tables=20and=20figure=20captions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- docs/retrieval-tuning.md | 4 ++-- public/components/admin.html | 2 +- public/js/admin/clinicalAssistant.js | 4 ++-- src/routes/clinicalAssistant.js | 10 +++++++--- test/backend-hardening.test.js | 8 ++++++++ 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/retrieval-tuning.md b/docs/retrieval-tuning.md index b883d544..4a3a0615 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`, `clinical_assistant.context_chars` | 8, 1400 | 3–20, 300–4000 | +| Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 8, 0 | 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; `context_chars` is how much text +`search_limit` is how many excerpts to request; `context_chars` is how much extra text around each excerpt to fetch from the source at query time — 0, the default, sends the stored excerpt as it is, which already carries the page, its tables and figure captions; any other value makes the search service download and re-extract the source document per hit (about a second each, cached for 15 minutes). 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 d7bcb2d7..b1ee3358 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -409,7 +409,7 @@
- +
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index 55916e88..524cdbbf 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -226,7 +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'] || '1400'); + setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] == null || cfg['clinical_assistant.context_chars'] === '' ? '0' : 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) { @@ -436,7 +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.context_chars', getValue('assistant-context-chars') || '0'), 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 cac1e933..88a33e52 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -91,7 +91,7 @@ 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 contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400); + var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '0'), 0, 4000, 0); var translateProvider = String(await getSetting('clinical_assistant.translate_provider', '') || 'libretranslate').toLowerCase(); if (!clinicalTranslation.TRANSLATE_PROVIDERS.includes(translateProvider)) translateProvider = 'libretranslate'; var budget = conversationBudget(process.env); @@ -574,9 +574,13 @@ 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'), 300, 4000, 1400); + var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '0'), 0, 4000, 0); var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR; - var includeContext = body.includeContext !== false; + // Context expansion made the search service download each hit's whole PDF + // and extract it again at query time to widen the excerpt — a second per + // hit. The stored excerpt already carries the page, its tables and figure + // captions, so the default is off: 0 characters 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 diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index 9fcd4046..22b858e3 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -295,3 +295,11 @@ test('silent SSO: prompt=none on request, a refusal is not an error, the page tr assert.match(js, /urlParams\.get\('sso'\) === 'none'/); assert.match(read('docs/authentication.md'), /## Signed in at PedsHub means signed in here/); }); + + +test('context expansion is off unless an admin asks for it: 0 characters means the stored excerpt only', () => { + const src = read('src/routes/clinicalAssistant.js'); + assert.equal((src.match(/getSetting\('clinical_assistant\.context_chars', '0'\), 0, 4000, 0\)/g) || []).length, 2); + assert.match(src, /includeContext = body\.includeContext !== false && contextChars > 0;/); + assert.match(read('public/components/admin.html'), /id="assistant-context-chars" type="number" min="0"/); +});