feat: admin can turn citations off
New setting clinical_assistant.citations_enabled (default true, admin checkbox). With it off, retrieval, grounding and every other rule are unchanged — answers are still built only from retrieved sources — but: - buildSystemPrompt swaps only the citation block: the "cite factual claims with [1]" rules are replaced with "do not include citations, source numbers or bracketed markers", and the note that the sourcing requirement itself is unchanged. Grounding, scope, table formatting and tone rules are byte-identical between the two modes. - The server strips any stray [n] the model emits anyway, from the stored answer rather than only the view, so saved chats and exports match what was shown. - No sources are sent to the client at all, and the status endpoint reports the mode so the UI hides the Sources panel and gives its 330px column back to the chat instead of showing an empty rail. Validated as a boolean in adminConfig, like the feature.* keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
This commit is contained in:
parent
022869f01d
commit
b992c6600b
11 changed files with 132 additions and 10 deletions
|
|
@ -327,6 +327,16 @@
|
|||
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<strong class="admin-row-label">Citations</strong>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
|
||||
<input type="checkbox" id="assistant-citations-enabled" checked>
|
||||
Show numbered citations and the Sources panel
|
||||
</label>
|
||||
<p style="margin:0;font-size:12px;color:var(--g500);">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<label for="assistant-translate-provider" class="admin-row-label">Translation provider</label>
|
||||
<select id="assistant-translate-provider" class="admin-control">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(','))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', '']
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue