Compare commits

..

16 commits

Author SHA1 Message Date
Daniel
2942d4f6b3 Revert "Sanitize citations from clinical image prompts"
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m43s
This reverts commit 7833018695.
2026-09-02 01:50:44 +02:00
Daniel
7833018695 Sanitize citations from clinical image prompts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m47s
2026-09-02 01:47:32 +02:00
Daniel
54d49dd28a Forbid LaTeX escaped clinical citations
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m55s
2026-09-01 19:29:40 +02:00
Daniel
491f5e02b7 Render escaped clinical citations as links
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m49s
2026-09-01 19:19:11 +02:00
Daniel
e4dacbaf09 Rejoin detached clinical citations
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
2026-09-01 19:11:18 +02:00
Daniel
34eef6ec6b Keep prose citations inline
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
2026-09-01 18:51:39 +02:00
Daniel
e71a7e22e1 Keep clinical citations inline
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m54s
2026-09-01 18:45:44 +02:00
Daniel
c1ba6fa798 Close MCP sessions when they are replaced
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m48s
The MCP server builds a Nextcloud client per session and closes it only
when the session ends. The clinical assistant replaced its cached session
every time the ten-minute TTL lapsed but never ended the old one, so each
abandoned client held its Nextcloud connections open. The production MCP
container was holding 708 sockets in CLOSE-WAIT against a 1024 descriptor
ceiling, roughly 300 from the point where every clinical search fails.

Expired and server-rejected sessions are now deleted. Session reuse is
unchanged: a live session is still shared across calls, since closing one
still in use would force a re-initialize on every search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:45:26 +02:00
Daniel
3d4a95fea4 Revert the clinical assistant markdown changes and fix mermaid rendering only
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
Reverts 126d7928 and 2d292d12 in full. The markdown normaliser and the trailing
emphasis and dollar handling return to exactly the state tagged
pre-citation-fixes-20260828, which has been rendering acceptably in use.

The one change kept is mermaid. DOMPurify 3.1.6 strips an attribute whose value
contains "-->", and every mermaid flowchart contains one, so the sanitiser
removed data-mermaid and querySelectorAll('[data-mermaid]') never matched: the
diagram sat on "Rendering graph..." forever. Reproduced against the exact pinned
build from cdnjs; sequence diagrams using "->>" were unaffected, which is why
only flowcharts failed.

The diagram source is now percent-encoded into the attribute and decoded when
read, so no arrow ever appears in an attribute value. Nothing about the
sanitiser configuration changes and no markup is newly allowed. The reader
tolerates an unencoded value, so anything already in flight still renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:55:03 +02:00
Daniel
2d292d12af fix: make the markdown repairs line aware so tables and ranges survive
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
The earlier fix corrected two of the repair rules and left the rest with the same
flaw, and the punctuation boundary it introduced let the table case back in. A
review found the remainder.

Every repair rule inserted newlines with no idea whether it was inside a markdown
table row. A dose row such as "| Dexamethasone | 0.6 mg/kg PO (max 16 mg) - single
dose | 1 |" was split mid-row, which drops that row's citation and every row
below it out of the table. A "#" column destroyed the table outright. The rules
now skip table rows, dividers and headers entirely.

Ranges were still split in three other places: in a heading, so "### Dexamethasone
0.15 - 0.6 mg/kg" rendered as a heading reading "Dexamethasone 0.15"; after a
citation; and beside a hash, where "Room # 4" became a heading. A hyphen now
starts a list item only when a number does not follow, and a run-together heading
is recognised by the capital letter after the hashes rather than by position.

Adjacent citation merging could also cross a paragraph break, turning "[1]\n\n[2]
Vancomycin only if MRSA" into "[1, 2]Vancomycin only if MRSA" -- joining two
paragraphs and moving a citation onto a claim it never supported. Merging is now
limited to citations on the same line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:49:01 +02:00
Daniel
126d7928a2 fix: stop the markdown normaliser mangling doses, bold and dollar amounts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m52s
Three rendering defects in the clinical assistant, all confirmed by running the
code rather than reading it.

A spaced hyphen anywhere in a sentence was rewritten as a list item, so every
numeric range written that way was split in two:

    "Give dexamethasone 0.15 - 0.6 mg/kg orally."
      -> "Give dexamethasone 0.15\n- 0.6 mg/kg orally."

which renders as a truncated sentence followed by a bullet, and a dose range
therefore reads as a different dose. The same applied to SpO2 targets, pH ranges
and age ranges. A hyphen now starts a list item only at the beginning of a line
or after sentence punctuation, which still catches the case the rule was written
for.

An answer ending in a bold phrase lost its closing marker, because trailing
emphasis was stripped unconditionally and the strip ran twice. Only an unpaired
marker is removed now.

Inline maths swallowed dollar amounts: "Costs $5 to $10 per dose" rendered the
text between the signs as an equation and dropped both signs. A $...$ span is
now treated as maths only when it contains something mathematical, so subscripts
and fractions still render.

Citation handling is unchanged and covered by the same tests: clusters still
merge and sort, unknown source numbers stay literal, links still resolve by index,
and bare numbers in a table's Source column still become bracketed tokens.

The state before these fixes is tagged pre-citation-fixes-20260828.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:20:39 +02:00
Daniel
c88cc6a547 Prevent citations in clinical image prompts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
2026-08-28 03:23:18 +02:00
Daniel
604f6abb49 Remove clinical assistant source lock
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m54s
2026-08-27 21:33:58 +02:00
Daniel
018913a845 Lock clinical assistant retrieval to configured source
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m46s
2026-08-27 21:30:29 +02:00
Daniel
2c02e6eca7 Send reasoning options only to supported model
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
2026-08-27 21:25:38 +02:00
Daniel
e710b1c7bd Configure clinical assistant reasoning profile
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
2026-08-27 20:47:59 +02:00
12 changed files with 240 additions and 46 deletions

View file

@ -6,7 +6,7 @@ export function renderAssistantMarkdown(md, sources, options) {
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text));
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]')));
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
@ -22,7 +22,10 @@ export function renderAssistantMarkdown(md, sources, options) {
html = renderCitationLinks(html, sources || [], opts);
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
// Percent-encoded: DOMPurify strips an attribute whose value contains "-->",
// which every mermaid flowchart has, leaving the diagram stuck on its
// placeholder. Encoding keeps the value intact; the reader decodes it.
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(encodeURIComponent(block.code)) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
@ -93,12 +96,10 @@ function formatCitationCluster(nums) {
export function normalizeMarkdownText(text) {
return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '')
.replace(/\r\n/g, '\n')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
.replace(/([^\n])\n+\s*(\[(?:\d+\s*,\s*)*\d+\])\s*(?:\n+\s*([.,;:]))?(?=\s*(?:\n|$))/g, '$1 $2$3')
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n')
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')

View file

@ -282,13 +282,11 @@ async function imageSourceToBlob(src) {
return response.blob();
}
export function buildContextualImagePrompt(request, lastAnswer, lastSources) {
export function buildContextualImagePrompt(request, lastAnswer) {
var prompt = String(request || '').trim();
if (!lastAnswer) return prompt;
var sourceText = (lastSources || []).slice(0, 6).map(function (s, idx) {
return '[' + (s.number || idx + 1) + '] ' + (s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
}).join('\n');
return prompt + '\n\nUse this clinical answer as the required context. Do not switch topics or introduce unrelated scenes such as gardening. Create a medical teaching visual faithful to the answer.\n\nAnswer:\n' + String(lastAnswer || '').slice(0, 3000) + '\n\nSources:\n' + sourceText;
var answer = String(lastAnswer || '').replace(/\[\d+(?:\s*,\s*\d+)*\]/g, '').slice(0, 3000);
return prompt + '\n\nUse this clinical answer as the required context. Do not switch topics or introduce unrelated scenes such as gardening. Create a medical teaching visual faithful to the answer. Do not include citations, reference numbers, footnotes, source lists, or named organizations; do not invent references.\n\nAnswer:\n' + answer;
}
export function isImageRequest(text) {

View file

@ -432,13 +432,17 @@ import {
}
function renderEmbeddedBlocks(root) {
function mermaidSource(el) {
var raw = el.getAttribute('data-mermaid') || '';
try { return decodeURIComponent(raw); } catch (e) { return raw; }
}
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
if (!window.mermaid) { el.textContent = mermaidSource(el); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
window.mermaid.render(id, mermaidSource(el))
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
.catch(function () { el.textContent = mermaidSource(el); });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {
@ -570,7 +574,7 @@ import {
function prepareSidebarImagePrompt(request) {
var promptEl = document.getElementById('assistant-image-prompt');
var prompt = buildContextualImagePrompt(request, lastAnswer, lastSources);
var prompt = buildContextualImagePrompt(request, lastAnswer);
if (promptEl) {
promptEl.value = prompt;
promptEl.focus();

View file

@ -36,6 +36,7 @@ var {
var {
buildSystemPrompt,
buildUserPrompt,
assistantGenerationOptions,
finalizeAssistantAnswer
} = require('../utils/clinicalAnswer');
@ -182,12 +183,17 @@ router.post('/clinical-assistant/chat', async function(req, res) {
var prepared = await prepareAssistantChat(req.body);
if (prepared.direct) return res.json(prepared.direct);
var ai = await callAI(prepared.messages, {
var ai = await callAI(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
maxTokens: 2600
}));
var finalized = await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
chatModel: prepared.chatModel,
callAI: callAI,
generationOptions: assistantGenerationOptions({ temperature: 0.15 })
});
var finalized = await finalizeAssistantAnswer(ai, { messages: prepared.messages, chatModel: prepared.chatModel, callAI: callAI });
var answer = finalized.answer;
ai = finalized.ai;
@ -237,11 +243,11 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
sendEvent('sources', { sources: safeSources, search: prepared.search });
sendEvent('status', { message: 'Generating answer...' });
var ai = await callAIStream(prepared.messages, {
var ai = await callAIStream(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
maxTokens: 2600
}, function(delta) {
}), function(delta) {
sendEvent('token', { token: delta });
});
@ -249,6 +255,7 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
messages: prepared.messages,
chatModel: prepared.chatModel,
callAI: callAI,
generationOptions: assistantGenerationOptions({ temperature: 0.15 }),
streamed: true,
onRegenerating: function() { sendEvent('status', { message: 'Completing answer...' }); }
});
@ -467,11 +474,11 @@ async function rewriteSearchQuery(message, history, chatModel) {
role: 'user',
content: 'Conversation:\n' + hist + '\n\nLatest user question:\n' + message + '\n\nStandalone search query:'
}
], {
], assistantGenerationOptions({
model: chatModel || undefined,
temperature: 0,
maxTokens: 80
});
}));
var rewritten = String(ai.content || '').replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
if (!rewritten || rewritten.length < 6 || rewritten.length > 300) return message;
if (/^(yes|no|maybe|i don'?t know)$/i.test(rewritten)) return message;

View file

@ -7,6 +7,7 @@
const { OpenAI } = require('openai');
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models');
const logger = require('./logger');
const { resolveGenerationOptions, addReasoningOptions } = require('./generationOptions');
var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter');
@ -367,15 +368,15 @@ async function callVertex(messages, model, temperature, maxTokens) {
// ============================================================
// CALL LITELLM (OpenAI-compatible proxy)
// ============================================================
async function callLiteLLM(messages, model, temperature, maxTokens) {
async function callLiteLLM(messages, model, temperature, maxTokens, generation) {
if (!litellmClient) throw new Error('LiteLLM not configured. Set LITELLM_API_BASE in .env');
var completion = await litellmClient.chat.completions.create({
var completion = await litellmClient.chat.completions.create(addReasoningOptions({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
}, generation || {}));
return {
success: true,
@ -421,8 +422,9 @@ async function callAIStream(messages, options, onToken) {
options = options || {};
var requestedModel = options.model;
var model = await resolveModel(requestedModel);
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
var generation = resolveGenerationOptions(options);
var temperature = generation.temperature;
var maxTokens = generation.maxTokens;
var startTime = Date.now();
await assertModelAllowed(model, options);
@ -443,13 +445,13 @@ async function callAIStream(messages, options, onToken) {
var content = '';
var finishReason = null;
var stream = await client.chat.completions.create({
var stream = await client.chat.completions.create(addReasoningOptions({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens,
stream: true
});
}, generation));
for await (var part of stream) {
var choice = part && part.choices && part.choices[0] ? part.choices[0] : null;
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
@ -470,8 +472,9 @@ async function callAI(messages, options) {
options = options || {};
var requestedModel = options.model;
var model = await resolveModel(requestedModel);
var temperature = options.temperature || 0.3;
var maxTokens = options.maxTokens || 4000;
var generation = resolveGenerationOptions(options);
var temperature = generation.temperature;
var maxTokens = generation.maxTokens;
var startTime = Date.now();
// Server-side whitelist: reject any model the operator hasn't enabled.
@ -492,7 +495,7 @@ async function callAI(messages, options) {
} else if (activeProvider === 'vertex' && vertexClient) {
result = await callVertex(messages, model, temperature, maxTokens);
} else if (activeProvider === 'litellm' && litellmClient) {
result = await callLiteLLM(messages, model, temperature, maxTokens);
result = await callLiteLLM(messages, model, temperature, maxTokens, generation);
} else if (openrouter) {
result = await callOpenRouter(messages, model, temperature, maxTokens);
} else {
@ -552,7 +555,7 @@ async function callAI(messages, options) {
if (activeProvider === 'litellm' && model !== FALLBACK_MODEL && litellmClient) {
logger.warn('Trying fallback model on LiteLLM: ' + FALLBACK_MODEL);
try {
var litellmFallback = await callLiteLLM(messages, FALLBACK_MODEL, temperature, maxTokens);
var litellmFallback = await callLiteLLM(messages, FALLBACK_MODEL, temperature, maxTokens, generation);
litellmFallback.fallback = true;
litellmFallback.duration = Date.now() - startTime;
logger.info('LiteLLM fallback success', { model: FALLBACK_MODEL });

View file

@ -1,5 +1,5 @@
function buildSystemPrompt(behavior) {
return behavior + '\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].\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.';
return behavior + '\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.';
}
function buildUserPrompt(question, context, history, searchQuery) {
@ -9,17 +9,23 @@ function buildUserPrompt(question, context, history, searchQuery) {
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
}
function assistantGenerationOptions(overrides) {
return Object.assign({
reasoningEffort: 'low',
reasoningFormat: 'hidden'
}, overrides || {});
}
async function finalizeAssistantAnswer(ai, options) {
options = options || {};
var answer = stripModelSourcesSection(String(ai && ai.content || '').trim());
if (shouldRegenerateTruncatedAnswer(answer, ai && ai.finishReason) && typeof options.callAI === 'function') {
console.warn('[clinical-assistant] answer looked truncated; regenerating final answer', { finishReason: ai && ai.finishReason, chars: answer.length, streamed: Boolean(options.streamed) });
if (typeof options.onRegenerating === 'function') options.onRegenerating();
var completed = await options.callAI(options.messages, {
var completed = await options.callAI(options.messages, Object.assign({}, options.generationOptions || {}, {
model: options.chatModel || undefined,
temperature: 0.15,
maxTokens: 5000
});
}));
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
ai.model = completed.model || ai.model;
ai.provider = completed.provider || ai.provider;
@ -60,6 +66,7 @@ function shouldRegenerateTruncatedAnswer(answer, finishReason) {
module.exports = {
buildSystemPrompt: buildSystemPrompt,
buildUserPrompt: buildUserPrompt,
assistantGenerationOptions: assistantGenerationOptions,
finalizeAssistantAnswer: finalizeAssistantAnswer,
stripModelSourcesSection: stripModelSourcesSection,
shouldRegenerateTruncatedAnswer: shouldRegenerateTruncatedAnswer

View file

@ -15,6 +15,23 @@ function positiveInt(value, fallback) {
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
// The MCP server builds a Nextcloud client per session and closes it only when the
// session ends. A session is replaced every time the TTL expires, so without this
// the abandoned clients hold their Nextcloud connections open in CLOSE-WAIT until
// the server runs out of file descriptors and every search fails.
// Best effort by design: the session being discarded is already unusable, so a
// failed teardown must never surface to the caller.
async function endMcpSession(session) {
if (!session || !session.sessionId) return;
try {
await axios.delete(session.mcpUrl || _lastGoodMcpUrl, {
headers: { 'Accept': 'application/json, text/event-stream', 'mcp-session-id': session.sessionId },
timeout: MCP_INITIALIZE_TIMEOUT_MS,
validateStatus: function() { return true; }
});
} catch (e) { /* the server reaps abandoned sessions on its own schedule */ }
}
async function semanticSearch(query, opts) {
opts = opts || {};
return callMcpTool('nc_semantic_search', {
@ -72,7 +89,9 @@ async function callMcpToolUnlocked(name, args) {
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
} catch (e) {
if (!isInvalidMcpSessionError(e)) throw e;
var rejected = _mcpSession;
_mcpSession = null;
if (rejected) endMcpSession(rejected);
session = await getMcpSession();
payload.id = nextMcpRequestId();
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
@ -90,8 +109,12 @@ async function getMcpSession() {
var now = Date.now();
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
if (_mcpSessionPromise) return _mcpSessionPromise;
// Captured before the replacement lands so the expired session can be closed.
var stale = _mcpSession;
_mcpSession = null;
_mcpSessionPromise = initializeMcpSession().then(function(session) {
_mcpSession = session;
if (stale) endMcpSession(stale);
return session;
}).finally(function() {
_mcpSessionPromise = null;

View file

@ -0,0 +1,20 @@
function resolveGenerationOptions(options) {
options = options || {};
return {
temperature: options.temperature ?? 0.3,
maxTokens: options.maxTokens ?? 4000,
reasoningEffort: options.reasoningEffort,
reasoningFormat: options.reasoningFormat
};
}
function addReasoningOptions(request, generation) {
// These OpenAI-compatible fields are rejected by ordinary chat models such as GPT-4.1.
// Keep the Clinical Assistant profile, but only send it to the Groq Qwen model that supports both fields.
if (request.model !== 'groq-qwen3.8-27b') return request;
if (generation.reasoningEffort != null) request.reasoning_effort = generation.reasoningEffort;
if (generation.reasoningFormat != null) request.reasoning_format = generation.reasoningFormat;
return request;
}
module.exports = { resolveGenerationOptions, addReasoningOptions };

View file

@ -31,6 +31,14 @@ test('renders citation clusters as links to matching source cards', async () =>
assert.match(html, /<a class="assistant-cite"[^>]*>src<\/a> <a class="assistant-cite"[^>]*>src<\/a>/);
});
test('renders escaped citation tokens as source links, not display math', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const katex = { renderToString: function () { throw new Error('citation sent to KaTeX'); } };
const html = renderAssistantMarkdown('Acquired hypothyroidism is uncommon. \\[1, 2\\].', sources, { katex });
assert.match(html, /<a class="assistant-cite"[^>]*>src<\/a> <a class="assistant-cite"[^>]*>src<\/a>/);
assert.doesNotMatch(html, /katex-display/);
});
test('can render citation labels as numbers for PDF export', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources, { citationLabel: 'number' });
@ -105,21 +113,32 @@ test('normalizes old saved assistant answer formatting', async () => {
assert.match(html, /Key Differences/);
});
test('repairs smashed admission bullet list after citations', async () => {
test('does not turn citation-delimited prose into a list', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Admission is indicated for: Apnoea [1, 2]- **Persistent oxygen saturation <90%** [1, 2]- **Severe respiratory distress** [1]', sources);
assert.match(html, /<li><strong>Persistent oxygen saturation &lt;90%<\/strong>/);
assert.match(html, /<li><strong>Severe respiratory distress<\/strong>/);
assert.doesNotMatch(html, /\]-/);
const html = renderAssistantMarkdown('Admission is indicated for: Apnoea [1, 2]- **Persistent oxygen saturation <90%** [1, 2].', sources);
assert.match(html, /<\/a>- <strong>Persistent oxygen saturation &lt;90%<\/strong>/);
assert.doesNotMatch(html, /<ul>/);
});
test('repairs smashed head injury red flag bullets after citation clusters', async () => {
test('joins a citation-only paragraph back to its claim', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1]- Signs of skull fracture [2]- Recurrent vomiting [3]', sources);
assert.match(html, /<li>Focal neurologic deficits/);
assert.match(html, /<li>Signs of skull fracture/);
assert.match(html, /<li>Recurrent vomiting/);
assert.doesNotMatch(html, /\]-/);
const html = renderAssistantMarkdown('Acquired hypothyroidism is uncommon.\n\n[1, 2]\n\n.\n\n- Next point', sources);
assert.match(html, /uncommon\. <a class="assistant-cite"[^>]*>src<\/a> <a class="assistant-cite"[^>]*>src<\/a>\./);
assert.doesNotMatch(html, /<p>\s*<a class="assistant-cite"/);
});
test('keeps citations inline before clinical terms', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('Assess for lethargy [1] and dehydration [2].', sources);
assert.match(html, /lethargy <a class="assistant-cite"[^>]*>src<\/a> and dehydration <a class="assistant-cite"[^>]*>src<\/a>/);
assert.doesNotMatch(html, /<\/a><br>\s*and dehydration/);
});
test('keeps citation-delimited clinical prose inline', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1].', sources);
assert.match(html, /<\/a>- Focal neurologic deficits/);
assert.doesNotMatch(html, /<br>|<ul>/);
});
test('preserves code block contents without creating citation links inside code', async () => {
@ -252,3 +271,15 @@ test('clinical assistant streams long table answers as lightweight text before f
assert.match(source, /assistant-streaming-text/);
assert.match(source, /pipeRows >= 8/);
});
test('mermaid source survives the sanitiser and round-trips', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const flowchart = 'graph TD; A[Start]-->B[Give O2];';
const html = renderAssistantMarkdown('```mermaid\n' + flowchart + '\n```', []);
const attr = html.match(/data-mermaid="([^"]*)"/);
assert.ok(attr, 'the placeholder must carry the diagram source');
// Encoded, because DOMPurify strips an attribute whose value contains "-->"
// and every flowchart has one, which left diagrams stuck on their placeholder.
assert.ok(!attr[1].includes('-->'), 'the stored value must not contain a raw arrow');
assert.equal(decodeURIComponent(attr[1].replace(/&amp;/g, '&')).trim(), flowchart);
});

View file

@ -52,6 +52,12 @@ test('clinical assistant prompt forbids relabeling other sources as named source
assert.match(answer, /If the named source is absent from the retrieved sources, say that explicitly/);
});
test('clinical assistant prompt forbids LaTeX-escaped citations', () => {
const answer = read('src/utils/clinicalAnswer.js');
assert.match(answer, /Never escape citation brackets/);
assert.ok(answer.includes(String.raw`write [1], not \\[1\\]`));
});
test('clinical assistant prompt requires bracketed citations in table source columns', () => {
const answer = read('src/utils/clinicalAnswer.js');
assert.match(answer, /Source, Source\(s\), Citation, or Citation\(s\) column/);

View file

@ -0,0 +1,27 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { assistantGenerationOptions } = require('../src/utils/clinicalAnswer');
const { resolveGenerationOptions, addReasoningOptions } = require('../src/utils/generationOptions');
test('clinical assistant profile requests low reasoning without exposing it', () => {
assert.deepEqual(assistantGenerationOptions({ temperature: 0, maxTokens: 80 }), {
reasoningEffort: 'low',
reasoningFormat: 'hidden',
temperature: 0,
maxTokens: 80
});
});
test('generation defaults preserve an explicit zero temperature', () => {
assert.equal(resolveGenerationOptions({ temperature: 0 }).temperature, 0);
assert.equal(resolveGenerationOptions({}).temperature, 0.3);
});
test('reasoning fields are sent only to the confirmed Groq Qwen model', () => {
const profile = { reasoningEffort: 'low', reasoningFormat: 'hidden' };
assert.deepEqual(addReasoningOptions({ model: 'openai-gpt-4.1' }, profile), { model: 'openai-gpt-4.1' });
assert.deepEqual(addReasoningOptions({ model: 'groq-qwen3.8-27b' }, profile), {
model: 'groq-qwen3.8-27b', reasoning_effort: 'low', reasoning_format: 'hidden'
});
});

View file

@ -0,0 +1,67 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const Module = require('node:module');
// The MCP server closes its Nextcloud client only when a session ends. These tests
// pin the teardown, because without it expired sessions leak sockets until the
// server exhausts its file descriptors and every clinical search fails.
function loadClientWithFakeAxios(calls) {
const axiosStub = {
post: async function(url, payload, config) {
calls.push({ method: 'POST', url, payload, headers: (config || {}).headers || {} });
return {
status: 200,
config: { url },
headers: { 'mcp-session-id': 'session-' + calls.filter(c => c.payload && c.payload.method === 'initialize').length },
data: JSON.stringify({ jsonrpc: '2.0', result: { content: [] } })
};
},
delete: async function(url, config) {
calls.push({ method: 'DELETE', url, headers: (config || {}).headers || {} });
return { status: 204 };
},
get: async function(url) { calls.push({ method: 'GET', url }); return { status: 200, data: {} }; }
};
const target = require.resolve('../src/utils/clinicalMcpClient');
delete require.cache[target];
const original = Module._load;
Module._load = function(request, parent, isMain) {
if (request === 'axios') return axiosStub;
return original.apply(this, arguments);
};
try { return require(target); } finally { Module._load = original; }
}
test('an expired session is closed when it is replaced, not abandoned', async () => {
const calls = [];
process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS = '1';
const client = loadClientWithFakeAxios(calls);
await client.semanticSearch('bronchiolitis', { limit: 4 });
await new Promise(resolve => setTimeout(resolve, 5)); // let the 1ms TTL lapse
await client.semanticSearch('croup', { limit: 4 });
const deletes = calls.filter(c => c.method === 'DELETE');
assert.equal(deletes.length, 1, 'the expired session should be deleted exactly once');
assert.equal(deletes[0].headers['mcp-session-id'], 'session-1');
const initializes = calls.filter(c => c.payload && c.payload.method === 'initialize');
assert.equal(initializes.length, 2, 'a lapsed TTL should open a fresh session');
delete process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS;
});
test('a live session is reused and never closed between calls', async () => {
const calls = [];
process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS = String(10 * 60 * 1000);
const client = loadClientWithFakeAxios(calls);
await client.semanticSearch('asthma', { limit: 4 });
await client.semanticSearch('sepsis', { limit: 4 });
assert.equal(calls.filter(c => c.method === 'DELETE').length, 0,
'deleting a session still in use would force a re-initialize on every search');
assert.equal(calls.filter(c => c.payload && c.payload.method === 'initialize').length, 1);
delete process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS;
});