diff --git a/public/components/admin.html b/public/components/admin.html
index 8a24174f..68c3dae8 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -327,6 +327,16 @@
+
+
Citations
+
+
+
+ Show numbered citations and the Sources panel
+
+
Retrieval and grounding are unchanged when this is off — answers are still built only from retrieved sources, but the [1] markers and the Sources panel are hidden.
+
+
Translation provider
diff --git a/public/css/assistant.css b/public/css/assistant.css
index 16d532df..22fff9c5 100644
--- a/public/css/assistant.css
+++ b/public/css/assistant.css
@@ -302,6 +302,10 @@ body.assistant-workspace .assistant-layout { height: calc(100vh - 64px); min-hei
/* Open WebUI-style rail: the column collapses to zero and the chat takes the
space, animated so the change reads as a fold rather than a jump. */
.assistant-layout { transition:grid-template-columns .18s ease; }
+/* Citations off: the Sources column is gone, so its track goes with it rather
+ than leaving a 330px blank. Both collapse states are spelled out. */
+body.assistant-no-citations .assistant-layout { grid-template-columns:260px minmax(0,1fr); }
+body.assistant-no-citations .assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr); }
.assistant-layout.history-collapsed { grid-template-columns:0 minmax(0,1fr) 330px; }
/* NOT display:none — that removes the rail from grid flow, so the chat inherits
the 0 column and the sources column takes its place. Keep it as a zero-width
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index f04c84e4..59070dbd 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -150,6 +150,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
+ var citationsBox = document.getElementById('assistant-citations-enabled');
+ if (citationsBox) citationsBox.checked = String(cfg['clinical_assistant.citations_enabled']) !== 'false';
var budgetInput = document.getElementById('assistant-conversation-budget');
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
if (budgetInput) {
@@ -380,6 +382,8 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
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.citations_enabled',
+ (document.getElementById('assistant-citations-enabled') || {}).checked === false ? 'false' : 'true'),
putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')),
putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(','))
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index ef0dccb0..2015a57b 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -279,6 +279,7 @@ import {
}
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
if (data.success && data.translateProvider === 'libretranslate') translateProvider = data.translateProvider;
+ if (data.success) applyCitationMode(data.citationsEnabled !== false);
if (data.success) {
statusChoices = {
allowedChatModels: data.allowedChatModels || [],
@@ -744,6 +745,18 @@ import {
return null;
}
+ // Admin switch: with citations off the server sends no sources and strips the
+ // [n] markers, so the panel would only ever show its empty state. The layout
+ // gives the column back to the chat instead of leaving a blank rail.
+ var citationsOn = true;
+
+ function applyCitationMode(enabled) {
+ citationsOn = enabled !== false;
+ document.body.classList.toggle('assistant-no-citations', !citationsOn);
+ var side = document.querySelector('.assistant-side');
+ if (side) side.hidden = !citationsOn;
+ }
+
function renderSources(sources) {
var wrap = document.getElementById('assistant-sources');
if (!wrap) return;
diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js
index 41a13fe2..afe42557 100644
--- a/src/routes/adminConfig.js
+++ b/src/routes/adminConfig.js
@@ -860,6 +860,9 @@ router.put('/config/:key(*)', async function(req, res) {
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 === 'clinical_assistant.citations_enabled' && !['true', 'false'].includes(String(value))) {
+ return res.status(400).json({ error: 'Citation mode must be true or false' });
+ }
if (key.startsWith('prompt.') || promptCatalog.find(key)) {
if (!promptCatalog.find(key)) return res.status(400).json({ error: 'Unknown prompt key' });
return changePrompt(req, res, 'save', key);
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index 85fefc20..982ac9b4 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -36,6 +36,7 @@ var {
} = require('../utils/clinicalRetrieval');
var {
buildSystemPrompt,
+ stripCitationMarkers,
buildUserPrompt,
assistantGenerationOptions,
finalizeAssistantAnswer
@@ -109,6 +110,7 @@ router.get('/clinical-assistant/status', async function(req, res) {
conversationSource: budget.source,
conversationMeasure: budget.measure,
translateProvider: translateProvider,
+ citationsEnabled: await citationsEnabled(),
mcp: mcpHealth
});
} catch (e) {
@@ -336,9 +338,10 @@ router.post('/clinical-assistant/chat', async function(req, res) {
res.json({
success: true,
- answer: answer,
+ answer: prepared.citations ? answer : stripCitationMarkers(answer),
imageJobs: ai.imageJobs || [],
- sources: sanitizeSourcesForClient(prepared.sources),
+ citations: prepared.citations,
+ sources: prepared.citations ? sanitizeSourcesForClient(prepared.sources) : [],
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
@@ -408,9 +411,10 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
});
sendEvent('done', {
success: true,
- answer: answer,
+ answer: prepared.citations ? answer : stripCitationMarkers(answer),
imageJobs: ai.imageJobs || [],
- sources: safeSources,
+ citations: prepared.citations,
+ sources: prepared.citations ? safeSources : [],
model: ai.model || prepared.chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
@@ -537,6 +541,7 @@ async function prepareAssistantChat(body) {
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
var includeContext = body.includeContext !== false;
+ var citations = await citationsEnabled();
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
@@ -583,6 +588,7 @@ async function prepareAssistantChat(body) {
var context = formatSourcesForPrompt(sources);
return {
message: message,
+ citations: citations,
images: images,
imageContext: generatedImages.imageContext(message, history),
history: history,
@@ -590,7 +596,7 @@ async function prepareAssistantChat(body) {
imageModel: imageModel,
sources: sources,
messages: [
- { role: 'system', content: buildSystemPrompt(behavior) },
+ { role: 'system', content: buildSystemPrompt(behavior, { citations: citations }) },
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
],
search: {
@@ -761,6 +767,12 @@ function isUsefulIndexedTopicExample(item) {
return true;
}
+// Admin switch. Retrieval, prompts and grounding are unchanged when this is off;
+// only the visible citation markers and the sources panel go away.
+async function citationsEnabled() {
+ return String(await getSetting('clinical_assistant.citations_enabled', 'true')) !== 'false';
+}
+
async function getConversationLimit() {
// Admin-set value wins over the environment so the administrator can test
// the warning/refusal behavior with a lower limit. Same validator the admin
diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js
index 26da7394..53aaaaee 100644
--- a/src/utils/clinicalAnswer.js
+++ b/src/utils/clinicalAnswer.js
@@ -1,5 +1,24 @@
-function buildSystemPrompt(behavior) {
- return behavior + '\n\nIf the user asks for an image, call the generate_image tool with a self-contained prompt and briefly say you are preparing the image; the image appears automatically. Do not merely write an image prompt.\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3]. Never escape citation brackets: write [1], not \\[1\\]; reserved LaTeX delimiters are not citations.\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- If a table has a Source, Source(s), Citation, or Citation(s) column, every cell in that column must use bracketed citation tokens like [1] or [1, 3], never bare numbers like 1 or 1, 3.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
+// The citation rules are the only difference between the two modes. Everything
+// else — grounding, scope, table formatting, tone — is identical, so turning
+// citations off changes what the answer shows, not how it is produced.
+var CITED_RULES = '\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3]. Never escape citation brackets: write [1], not \\[1\\]; reserved LaTeX delimiters are not citations.\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- If a table has a Source, Source(s), Citation, or Citation(s) column, every cell in that column must use bracketed citation tokens like [1] or [1, 3], never bare numbers like 1 or 1, 3.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- Do not cite a source number that is not provided.';
+var UNCITED_RULES = '\n- Do not include citations, source numbers, bracketed reference markers like [1], footnote markers, or a Source/Citation column in tables.\n- Every factual claim must still come from the retrieved sources; the requirement is unchanged, only the visible marker is dropped.';
+
+function buildSystemPrompt(behavior, options) {
+ var citations = !options || options.citations !== false;
+ return behavior + '\n\nIf the user asks for an image, call the generate_image tool with a self-contained prompt and briefly say you are preparing the image; the image appears automatically. Do not merely write an image prompt.\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.' +
+ (citations ? CITED_RULES : UNCITED_RULES) +
+ '\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section' + (citations ? '; the UI displays all retrieved sources separately.' : '.') + '\n- Do not add generic disclaimers about clinician judgment.';
+}
+
+// A model can still emit [1] against instructions. With citations off there is
+// no sources panel to link them to, so a stray marker would render as literal
+// text — strip them from the stored answer, not just the view.
+function stripCitationMarkers(answer) {
+ return String(answer || '')
+ .replace(/(?:\s*\[(?:\d+\s*,\s*)*\d+\])+/g, '')
+ .replace(/[ \t]+([.,;:])/g, '$1')
+ .replace(/[ \t]{2,}/g, ' ');
}
function buildUserPrompt(question, context, history, searchQuery) {
@@ -66,6 +85,7 @@ function shouldRegenerateTruncatedAnswer(answer, finishReason) {
module.exports = {
buildSystemPrompt: buildSystemPrompt,
+ stripCitationMarkers: stripCitationMarkers,
buildUserPrompt: buildUserPrompt,
assistantGenerationOptions: assistantGenerationOptions,
finalizeAssistantAnswer: finalizeAssistantAnswer,
diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js
index 0dafe3dd..e4e4ee67 100644
--- a/test/admin-clinical-assistant-wiring.test.js
+++ b/test/admin-clinical-assistant-wiring.test.js
@@ -70,9 +70,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, 7);
+ assert.equal(writes().length, 8);
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.conversation_chars', 'clinical_assistant.search_limit', 'clinical_assistant.translate_provider'
+ 'clinical_assistant.allowed_image_models', 'clinical_assistant.allowed_models', 'clinical_assistant.chat_model', 'clinical_assistant.citations_enabled', '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'));
diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js
index 88e2b225..a7400543 100644
--- a/test/assistant-citations.test.js
+++ b/test/assistant-citations.test.js
@@ -292,3 +292,58 @@ test('mermaid source survives the sanitiser and round-trips', async () => {
assert.ok(!attr[1].includes('-->'), 'the stored value must not contain a raw arrow');
assert.equal(decodeURIComponent(attr[1].replace(/&/g, '&')).trim(), flowchart);
});
+
+// ── Admin citation mode ────────────────────────────────────────────────────
+test('citation mode changes only the citation rules, never the grounding', () => {
+ const { buildSystemPrompt } = require('../src/utils/clinicalAnswer');
+ const on = buildSystemPrompt('BEHAVIOR');
+ const off = buildSystemPrompt('BEHAVIOR', { citations: false });
+
+ assert.match(on, /Cite factual claims immediately with numbered citations/);
+ assert.doesNotMatch(off, /Cite factual claims immediately/, 'the [n] rules are dropped');
+ assert.match(off, /Do not include citations, source numbers, bracketed reference markers/);
+ assert.match(off, /the requirement is unchanged, only the visible marker is dropped/);
+
+ // Everything that makes the answer trustworthy must be identical in both.
+ for (const shared of [
+ /Answer only the user question/,
+ /not directly supported by retrieved sources/,
+ /do not claim that another source is from the named source/,
+ /valid GitHub-flavored markdown table/,
+ /Do not add generic disclaimers/
+ ]) {
+ assert.match(on, shared);
+ assert.match(off, shared, 'grounding and formatting rules are the same in both modes');
+ }
+ assert.equal(buildSystemPrompt('BEHAVIOR', {}), on, 'citations default to on');
+ assert.equal(buildSystemPrompt('BEHAVIOR', { citations: true }), on);
+});
+
+test('stray citation markers are stripped from the stored answer, not just the view', () => {
+ const { stripCitationMarkers } = require('../src/utils/clinicalAnswer');
+ assert.equal(stripCitationMarkers('Amoxicillin 90 mg/kg/day [1][2]. Reassess in 48 h [3].'),
+ 'Amoxicillin 90 mg/kg/day. Reassess in 48 h.');
+ assert.equal(stripCitationMarkers('Give fluids [1, 3] and rest [2].'), 'Give fluids and rest.');
+ assert.equal(stripCitationMarkers('No citations here.'), 'No citations here.');
+ // A model that obeys the prompt costs nothing; one that does not must not
+ // leave literal "[1]" text with no sources panel to click through to.
+ assert.doesNotMatch(stripCitationMarkers('Dose [1] is 90 mg/kg [2]'), /\[\d/);
+});
+
+test('the admin toggle is a real boolean setting and the server refuses anything else', () => {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const root = path.join(__dirname, '..');
+ const admin = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
+ assert.match(admin, /clinical_assistant\.citations_enabled' && !\['true', 'false'\]/, 'validated as a boolean');
+ const route = fs.readFileSync(path.join(root, 'src/routes/clinicalAssistant.js'), 'utf8');
+ assert.match(route, /citations_enabled', 'true'\)\) !== 'false'/, 'defaults to on');
+ assert.match(route, /citationsEnabled: await citationsEnabled\(\)/, 'exposed on the status endpoint');
+ assert.match(route, /sources: prepared\.citations \? sanitizeSourcesForClient\(prepared\.sources\) : \[\]/,
+ 'no sources are sent to the client when citations are off');
+ const html = fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8');
+ assert.match(html, /id="assistant-citations-enabled"/, 'the admin has a control for it');
+ const ui = fs.readFileSync(path.join(root, 'public/js/clinicalAssistant.js'), 'utf8');
+ assert.match(ui, /applyCitationMode\(data\.citationsEnabled !== false\)/, 'the UI follows the server');
+ assert.match(ui, /side\.hidden = !citationsOn/, 'and hides the Sources panel');
+});
diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js
index 3d40ba16..576212a0 100644
--- a/test/clinical-release-integration.test.js
+++ b/test/clinical-release-integration.test.js
@@ -85,7 +85,7 @@ test('native admin and assistant modules retain budget, table/source identity an
document.getElementById('btn-save-assistant-config').click();
await tick();
assert.equal(limit, 2000);
- assert.equal(calls.filter(call => call.options.method === 'PUT').length, 7, 'one native admin initializer; prompts are not generic setting saves');
+ assert.equal(calls.filter(call => call.options.method === 'PUT').length, 8, '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 0836f1fe..db9bcdc6 100644
--- a/test/frontend-prompt-env.test.js
+++ b/test/frontend-prompt-env.test.js
@@ -328,6 +328,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves
['clinical_assistant.conversation_chars', ''],
['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'],
['clinical_assistant.translate_provider', 'libretranslate'],
+ ['clinical_assistant.citations_enabled', 'true'],
['clinical_assistant.allowed_models', ''], ['clinical_assistant.allowed_image_models', '']
]);
});