harden clinical assistant source handling
This commit is contained in:
parent
8d69fe57a5
commit
795ad9ffae
17 changed files with 181 additions and 23 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ' ')
|
||||
|
|
|
|||
|
|
@ -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) {} };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, ' ')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, ' ')
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
20
test/assistant-image-intent.test.js
Normal file
20
test/assistant-image-intent.test.js
Normal 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);
|
||||
});
|
||||
16
test/assistant-sources-cleaning.test.js
Normal file
16
test/assistant-sources-cleaning.test.js
Normal 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: ' AGE STREAMS OF DEVELOPMENT' }]);
|
||||
|
||||
assert.match(html, /AGE STREAMS OF DEVELOPMENT/);
|
||||
assert.doesNotMatch(html, /tmp\/pdf-images|!\[\]/);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
62
test/clinical-retrieval-title.test.js
Normal file
62
test/clinical-retrieval-title.test.js
Normal 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\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|!\[\]/);
|
||||
});
|
||||
Loading…
Reference in a new issue