harden clinical assistant source handling

This commit is contained in:
Daniel 2026-05-09 19:59:04 +02:00
parent 8d69fe57a5
commit 795ad9ffae
17 changed files with 181 additions and 23 deletions

View file

@ -819,7 +819,7 @@ function adminFlashButtonBackground(btn, color) {
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Image test failed');
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + src + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
showToast('Image model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;

View file

@ -26,7 +26,15 @@ window.addEventListener('unhandledrejection', function(e) {
document.addEventListener('DOMContentLoaded', function() {
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
var COMPONENT_VERSION = '7.1.3';
function getComponentVersion() {
try {
var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]');
var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : '';
return version || 'dev';
} catch(e) { return 'dev'; }
}
var COMPONENT_VERSION = getComponentVersion();
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
var _componentCache = {};
var _componentLoading = {};
@ -1020,7 +1028,7 @@ function deduplicateFinal(newText, existingText) {
// PWA Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function() {});
navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {});
}
console.log('✅ App.js loaded');

View file

@ -52,8 +52,9 @@ export function buildContextualImagePrompt(request, lastAnswer, lastSources) {
export function isImageRequest(text) {
text = String(text || '').trim();
var visualNoun = /\b(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)\b/i;
return /^(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)$/i.test(text) ||
/\b(show|see|display|view|image|photo|picture|visual|illustration|diagram|figure)\b/i.test(text) && text.split(/\s+/).length <= 6 ||
(/\b(show|see|display|view)\b/i.test(text) && visualNoun.test(text) && text.split(/\s+/).length <= 8) ||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text);
}

View file

@ -38,6 +38,7 @@ function renderSourceBadges(source) {
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')

View file

@ -726,7 +726,7 @@ function setupShadessModule() {
if (e.results[i].isFinal) _wvTranscript += e.results[i][0].transcript + ' ';
else interim = e.results[i][0].transcript;
}
if (transcriptEl) transcriptEl.innerHTML = _wvTranscript + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
if (transcriptEl) transcriptEl.innerHTML = esc(_wvTranscript) + (interim ? '<span style="color:#9ca3af;">' + esc(interim) + '</span>' : '');
};
_wvRecognition.onend = function() { if (_wvRecording && !_wvPaused) try { _wvRecognition.start(); } catch(e) {} };
}

View file

@ -4,7 +4,9 @@
// API calls always fresh (critical for medical data accuracy)
// ============================================================
var CACHE_NAME = 'pedscribe-v12-notes7';
var CACHE_VERSION = 'dev';
try { CACHE_VERSION = new URL(self.location.href).searchParams.get('v') || CACHE_VERSION; } catch(e) {}
var CACHE_NAME = 'pedscribe-' + CACHE_VERSION;
var SHELL_ASSETS = [
'/',
'/index.html',

View file

@ -176,7 +176,7 @@ function getTemplatedIndex() {
if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html;
var raw = fs.readFileSync(INDEX_PATH, 'utf8');
_indexCached.html = raw.replace(
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g,
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(?:\?v=[^"']*)?(["'])/g,
'$1$2?v=' + BUILD_ID + '$3'
);
_indexCached.mtime = st.mtimeMs;

View file

@ -35,6 +35,15 @@ function requireAdmin(req, res, next) {
// at the repo root and __dirname here is src/routes, so go up two.
var DOCS_ROOT = path.resolve(__dirname, '..', '..', 'docs');
function sanitizeRenderedHtml(html) {
return String(html || '')
.replace(/<\s*(script|style|iframe|object|embed)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(iframe|object|embed)\b[^>]*\/?>/gi, '')
.replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/\s+(href|src)\s*=\s*(["'])\s*javascript:[\s\S]*?\2/gi, '')
.replace(/\s+(href|src)\s*=\s*javascript:[^\s>]+/gi, '');
}
// Resolve a user-supplied path safely. Returns null if the path tries
// to escape DOCS_ROOT or doesn't exist.
function safeResolve(relPath) {
@ -113,7 +122,7 @@ router.get('/file', requireAdmin, function (req, res) {
// marked: GitHub-flavoured rendering with automatic line breaks off
// (so authors can wrap prose without inserting <br> everywhere) and
// header IDs on so the client can deep-link via #anchor.
var html = marked.parse(src, { gfm: true, breaks: false, headerIds: true });
var html = sanitizeRenderedHtml(marked.parse(src, { gfm: true, breaks: false, headerIds: true }));
res.json({ success: true, html: html, path: raw, bytes: stat.size });
} catch (e) {
logger.error('[adminDocs] file failed', e.message);

View file

@ -12,6 +12,7 @@ var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI, callAIStream } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var { getLiteLLMHeaders } = require('../utils/litellm');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
var redisCache = require('../utils/redis');
@ -551,8 +552,7 @@ async function getIndexedTopicExamples() {
async function generateImage(prompt, model) {
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
var headers = getLiteLLMHeaders('application/json');
var renderedPrompt = imagePromptForCanvas(prompt);
var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto';
var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) {
@ -614,6 +614,7 @@ function cleanTitle(s) {
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')

View file

@ -1,14 +1,14 @@
// ============================================================
// AI.JS — Multi-provider AI client
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
// AI.JS — AI client
// Primary path: LiteLLM / OpenAI-compatible base URL via LITELLM_API_BASE.
// Legacy direct providers remain available only when explicitly selected.
// ============================================================
const { OpenAI } = require('openai');
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models');
const logger = require('./logger');
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter');
// ============================================================
// OPENROUTER CLIENT (default)
@ -99,7 +99,7 @@ if (process.env.LITELLM_API_BASE) {
try {
litellmClient = new OpenAI({
baseURL: process.env.LITELLM_API_BASE.replace(/\/+$/, ''),
apiKey: process.env.LITELLM_API_KEY || 'sk-litellm'
apiKey: process.env.LITELLM_API_KEY || process.env.LITELLM_MASTER_KEY || process.env.OPENAI_API_KEY || 'sk-litellm'
});
activeProvider = 'litellm';
console.log('✅ LiteLLM: configured (base: ' + process.env.LITELLM_API_BASE + ')');
@ -496,7 +496,7 @@ async function callAI(messages, options) {
} else if (openrouter) {
result = await callOpenRouter(messages, model, temperature, maxTokens);
} else {
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, AZURE_OPENAI_ENDPOINT, GOOGLE_VERTEX_PROJECT, or LITELLM_API_BASE in .env');
throw new Error('No AI provider configured. Set LITELLM_API_BASE plus an API key, or explicitly configure a legacy direct provider.');
}
var duration = Date.now() - startTime;

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- 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 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- 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

@ -1,4 +1,8 @@
var MULTIMODAL_CANDIDATE_LIMIT = 6;
var INTERNAL_TITLE_FALLBACKS = {
'1586022': 'Respiratory Disease',
'FABK010-fm[i-xiv].qxd': 'Respiratory Disease'
};
function normalizeMcpSearchResponse(result) {
var data = result && (result.structuredContent || result.data || result);
@ -20,15 +24,16 @@ function normalizeMcpSearchResponse(result) {
number: idx + 1,
id: r.id,
doc_type: r.doc_type,
title: cleanTitle(r.title || 'Untitled resource'),
title: displayTitleForSource(r, 'Untitled resource'),
page: r.page_number || r.pageNumber || null,
page_count: r.page_count || r.pageCount || null,
file_path: r.file_path || r.filePath || r.path || '',
folder_path: r.folder_path || r.folderPath || '',
category: r.category || '',
source_priority: r.source_priority || r.sourcePriority || '',
source_boost: r.source_boost || r.sourceBoost || null,
tags: Array.isArray(r.tags) ? r.tags : [],
excerpt: clip(text, 1800),
excerpt: clip(cleanSourceExcerpt(text), 1800),
score: r.score,
chunk_index: r.chunk_index,
total_chunks: r.total_chunks
@ -63,7 +68,7 @@ function normalizeMcpMultimodalResponse(result) {
id: r.id,
doc_type: r.doc_type || 'file',
source_type: 'multimodal_page',
title: cleanTitle(r.title || r.file_path || 'PDF page image'),
title: displayTitleForSource(r, 'PDF page image'),
file_path: r.file_path || '',
category: r.category || '',
subcategory: r.subcategory || '',
@ -215,6 +220,31 @@ function looksLikeTextOnlyPage(source) {
function clip(s, n) { return String(s || '').replace(/\s+/g, ' ').trim().substring(0, n); }
function displayTitleForSource(source, fallback) {
source = source || {};
var rawTitle = String(source.title || '').trim();
var filePath = source.file_path || source.filePath || source.path || '';
if (looksLikeInternalPdfTitle(rawTitle)) {
var fromPath = titleFromPath(filePath);
if (fromPath) return cleanTitle(fromPath);
var mapped = INTERNAL_TITLE_FALLBACKS[String(source.id || '')] || INTERNAL_TITLE_FALLBACKS[rawTitle];
if (mapped) return mapped;
}
return cleanTitle(rawTitle || filePath || fallback || 'Untitled resource');
}
function looksLikeInternalPdfTitle(title) {
return /\.qxd$/i.test(String(title || '').trim());
}
function titleFromPath(filePath) {
var text = String(filePath || '').trim();
if (!text) return '';
try { text = decodeURIComponent(text); } catch (e) {}
var parts = text.split(/[\\/]+/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : text;
}
function cleanTitle(s) {
var title = String(s || '').trim();
try { title = decodeURIComponent(title); } catch (e) {}
@ -228,6 +258,7 @@ function cleanTitle(s) {
function cleanSourceExcerpt(text) {
return String(text || '')
.replace(/^\[Page-image match\]\s*/i, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\*\*/g, '')
.replace(/\|\s*-{2,}\s*/g, ' ')

View file

@ -3,7 +3,7 @@
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
// ============================================================
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter');
var OPENROUTER_MODELS = [
{ id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' },
@ -133,7 +133,7 @@ function getDefaultModel() {
case 'bedrock': return 'amazon/nova-pro';
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
case 'vertex': return 'gemini-2.5-flash';
case 'litellm': return ''; // No default — set via admin panel or LITELLM_DEFAULT_MODEL env
case 'litellm': return process.env.LITELLM_DEFAULT_MODEL || process.env.OPENAI_MODEL || ''; // OpenAI-compatible base: set admin default or env.
case 'openrouter':
default: return 'google/gemini-2.5-flash';
}
@ -144,7 +144,7 @@ function getFallbackModel() {
case 'bedrock': return 'amazon/nova-lite';
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
case 'vertex': return 'gemini-2.0-flash';
case 'litellm': return ''; // No fallback — use whatever the user has configured
case 'litellm': return process.env.LITELLM_FALLBACK_MODEL || ''; // No implicit cross-provider fallback.
case 'openrouter':
default: return 'deepseek/deepseek-chat-v3-0324';
}

View file

@ -0,0 +1,20 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
async function imagesModule() {
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/assistant/images.js')).href);
}
test('assistant image intent does not intercept table retrieval requests', async () => {
const { isImageRequest } = await imagesModule();
assert.equal(isImageRequest('can you show me the table?'), false);
assert.equal(isImageRequest('show me Table 13.1'), false);
});
test('assistant image intent still handles explicit visual requests', async () => {
const { isImageRequest } = await imagesModule();
assert.equal(isImageRequest('show me the diagram'), true);
assert.equal(isImageRequest('create an infographic for asthma'), true);
});

View file

@ -0,0 +1,16 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
async function sourcesModule() {
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/assistant/sources.js')).href);
}
test('assistant source excerpts hide image markdown but keep OCR text', async () => {
const { renderSourcesList } = await sourcesModule();
const html = renderSourcesList([{ title: 'Nelson', excerpt: '![](/tmp/pdf-images/pdf-0132-02.png) AGE STREAMS OF DEVELOPMENT' }]);
assert.match(html, /AGE STREAMS OF DEVELOPMENT/);
assert.doesNotMatch(html, /tmp\/pdf-images|!\[\]/);
});

View file

@ -19,3 +19,10 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/);
assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/);
});
test('clinical assistant prompt forbids relabeling other sources as named sources', () => {
const answer = read('src/utils/clinicalAnswer.js');
assert.match(answer, /If the user names a specific source, textbook, guideline, or table/);
assert.match(answer, /do not claim that another source is from the named source/);
assert.match(answer, /If the named source is absent from the retrieved sources, say that explicitly/);
});

View file

@ -0,0 +1,62 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { normalizeMcpSearchResponse, normalizeMcpMultimodalResponse } = require('../src/utils/clinicalRetrieval');
test('clinical retrieval replaces known internal PDF title with document title', () => {
const results = normalizeMcpSearchResponse({
results: [{
id: 1586022,
doc_type: 'file',
title: 'FABK010-fm[i-xiv].qxd',
excerpt: 'Respiratory disease text',
page_number: 320
}]
});
assert.equal(results[0].title, 'Respiratory Disease');
assert.equal(results[0].page, 320);
});
test('clinical retrieval prefers file basename for internal PDF production titles', () => {
const results = normalizeMcpMultimodalResponse({
results: [{
id: 99,
doc_type: 'file',
title: 'chapter-layout.qxd',
file_path: 'Medical Library/Critical%20care%20%26%20Respiratory/Respiratory%20Disease.pdf',
visual_caption: 'Airway diagram',
page_number: 12
}]
});
assert.equal(results[0].title, 'Respiratory Disease');
});
test('clinical retrieval leaves normal source titles unchanged', () => {
const results = normalizeMcpSearchResponse({
results: [{
id: 123,
doc_type: 'file',
title: 'Nelson Textbook of Pediatrics.pdf',
excerpt: 'Clinical text'
}]
});
assert.equal(results[0].title, 'Nelson Textbook of Pediatrics');
});
test('clinical retrieval strips image markdown while preserving OCR table text', () => {
const results = normalizeMcpSearchResponse({
results: [{
id: 1585684,
doc_type: 'file',
title: 'Nelson Textbook of Pediatrics.pdf',
excerpt: '~~Table 13.1~~\n\n![](/tmp/pdf-images/pdf-0132-02.png)\n\nAGE STREAMS OF DEVELOPMENT<br>2 mo row'
}]
});
assert.match(results[0].excerpt, /Table 13\.1/);
assert.match(results[0].excerpt, /AGE STREAMS OF DEVELOPMENT/);
assert.doesNotMatch(results[0].excerpt, /pdf-images|!\[\]/);
});