Compare commits
16 commits
baseline-b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2942d4f6b3 | ||
|
|
7833018695 | ||
|
|
54d49dd28a | ||
|
|
491f5e02b7 | ||
|
|
e4dacbaf09 | ||
|
|
34eef6ec6b | ||
|
|
e71a7e22e1 | ||
|
|
c1ba6fa798 | ||
|
|
3d4a95fea4 | ||
|
|
2d292d12af | ||
|
|
126d7928a2 | ||
|
|
c88cc6a547 | ||
|
|
604f6abb49 | ||
|
|
018913a845 | ||
|
|
2c02e6eca7 | ||
|
|
e710b1c7bd |
12 changed files with 240 additions and 46 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
20
src/utils/generationOptions.js
Normal file
20
src/utils/generationOptions.js
Normal 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 };
|
||||
|
|
@ -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 <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 <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(/&/g, '&')).trim(), flowchart);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
27
test/clinical-generation-options.test.js
Normal file
27
test/clinical-generation-options.test.js
Normal 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'
|
||||
});
|
||||
});
|
||||
67
test/clinical-mcp-session-lifecycle.test.js
Normal file
67
test/clinical-mcp-session-lifecycle.test.js
Normal 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;
|
||||
});
|
||||
Loading…
Reference in a new issue