Compare commits

..

No commits in common. "main" and "pre-citation-fixes-20260828" have entirely different histories.

7 changed files with 20 additions and 152 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.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]')));
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text));
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
@ -22,10 +22,7 @@ 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: '' };
// 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 === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(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>';
});
@ -96,10 +93,12 @@ function formatCitationCluster(nums) {
export function normalizeMarkdownText(text) {
return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '')
.replace(/\r\n/g, '\n')
.replace(/([^\n])\n+\s*(\[(?:\d+\s*,\s*)*\d+\])\s*(?:\n+\s*([.,;:]))?(?=\s*(?:\n|$))/g, '$1 $2$3')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
.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

@ -432,17 +432,13 @@ 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 = mermaidSource(el); return; }
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, mermaidSource(el))
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = mermaidSource(el); });
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {

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]. 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.';
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.';
}
function buildUserPrompt(question, context, history, searchQuery) {

View file

@ -15,23 +15,6 @@ 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', {
@ -89,9 +72,7 @@ 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);
@ -109,12 +90,8 @@ 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

@ -31,14 +31,6 @@ 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' });
@ -113,32 +105,21 @@ test('normalizes old saved assistant answer formatting', async () => {
assert.match(html, /Key Differences/);
});
test('does not turn citation-delimited prose into a list', async () => {
test('repairs smashed admission bullet list after citations', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
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>/);
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, /\]-/);
});
test('joins a citation-only paragraph back to its claim', async () => {
test('repairs smashed head injury red flag bullets after citation clusters', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
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>/);
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, /\]-/);
});
test('preserves code block contents without creating citation links inside code', async () => {
@ -271,15 +252,3 @@ 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,12 +52,6 @@ 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

@ -1,67 +0,0 @@
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;
});