perf: context expansion is off by default — the stored excerpt already carries the page, its tables and figure captions
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
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:16:28 +02:00
parent cd2ee9192a
commit a6c2cb1080
5 changed files with 20 additions and 8 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`, `clinical_assistant.context_chars` | 8, 1400 | 320, 3004000 | | Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 8, 0 | 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; `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. 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

@ -409,7 +409,7 @@
</div> </div>
<div class="admin-row"> <div class="admin-row">
<label for="assistant-context-chars" class="admin-row-label">Context per excerpt (characters)</label> <label for="assistant-context-chars" class="admin-row-label">Context per excerpt (characters)</label>
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control"> <input id="assistant-context-chars" type="number" min="0" max="4000" value="0" class="admin-control" title="0 = the stored excerpt only (no re-extraction of the source at query time)">
</div> </div>
</div> </div>
<div class="admin-row" id="assistant-library-index"> <div class="admin-row" id="assistant-library-index">

View file

@ -226,7 +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'] || '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 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) {
@ -436,7 +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.context_chars', getValue('assistant-context-chars') || '0'),
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,7 @@ 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 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(); 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);
@ -574,9 +574,13 @@ 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'), 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 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(); 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

View file

@ -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(js, /urlParams\.get\('sso'\) === 'none'/);
assert.match(read('docs/authentication.md'), /## Signed in at PedsHub means signed in here/); 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"/);
});