diff --git a/public/components/admin.html b/public/components/admin.html
index 68c3dae8..e8356bf6 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -299,11 +299,20 @@
+ Discovery only lists what the gateway advertises. Add any id your gateway routes — it is checked on first use, and stays in the list once saved.
diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js
index 59070dbd..33deafa1 100644
--- a/public/js/admin/clinicalAssistant.js
+++ b/public/js/admin/clinicalAssistant.js
@@ -51,6 +51,38 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) {
container.appendChild(row);
});
}
+ // Discovery only returns what the gateway advertises, so an admin could never
+ // offer a model it does not list. Typed ids join the same checkbox list and
+ // persist through the normal allowed_models save.
+ function addAssistantModel(containerId, id) {
+ var container = document.getElementById(containerId);
+ id = String(id || '').trim();
+ if (!container || !id) return false;
+ var existing = Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]'), function(b) { return b.value; });
+ if (existing.indexOf(id) !== -1) {
+ var already = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
+ if (already) already.checked = true;
+ return true;
+ }
+ var roster = containerId === 'assistant-allowed-chat-models' ? chatRoster : imageRoster;
+ roster.push(id);
+ renderAssistantCheckboxList(containerId, roster, existing.filter(function(v) {
+ var box = container.querySelector('input[value="' + (window.CSS && CSS.escape ? CSS.escape(v) : v) + '"]');
+ return box && box.checked;
+ }).concat([id]));
+ return true;
+ }
+
+ document.addEventListener('click', function(event) {
+ var button = event.target.closest && event.target.closest('[data-assistant-add-model]');
+ if (!button) return;
+ var containerId = button.getAttribute('data-assistant-add-model');
+ var field = document.getElementById(containerId === 'assistant-allowed-chat-models' ? 'assistant-add-chat-model' : 'assistant-add-image-model');
+ if (!field) return;
+ if (addAssistantModel(containerId, field.value)) field.value = '';
+ else if (typeof window.showToast === 'function') window.showToast('Enter a model id first', 'error');
+ });
+
function checkedAssistantModels(containerId) {
var container = document.getElementById(containerId);
if (!container) return [];
diff --git a/public/js/assistant/sources.js b/public/js/assistant/sources.js
index 4e9df303..801a5327 100644
--- a/public/js/assistant/sources.js
+++ b/public/js/assistant/sources.js
@@ -7,7 +7,6 @@ export function renderSourcesList(sources) {
var page = s.page || s.page_number || s.pageNumber;
var meta = [];
if (page) meta.push('page ' + page);
- if (s.source_type === 'multimodal_page') meta.push('visual PDF page');
if (s.visual_caption_source) meta.push('caption: ' + s.visual_caption_source);
if (s.visual_kind || s.source_priority) meta.push(s.visual_kind || s.source_priority);
if (s.category) meta.push(s.category);
diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js
index 982ac9b4..c55d345d 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -20,7 +20,6 @@ var redisCache = require('../utils/redis');
var { createClinicalPromptPool } = require('../utils/clinicalPromptPool');
var {
semanticSearch,
- multimodalSearch,
indexedTopicSuggestions,
getMcpHealth,
warmMcpSession
@@ -28,11 +27,7 @@ var {
var {
cleanSourceExcerpt,
normalizeMcpSearchResponse,
- normalizeMcpMultimodalResponse,
dedupeSources,
- isVisualSourceQuery,
- buildMultimodalSearchQuery,
- classifyAndRerankMultimodalResults
} = require('../utils/clinicalRetrieval');
var {
buildSystemPrompt,
@@ -552,28 +547,14 @@ async function prepareAssistantChat(body) {
includeContext: includeContext,
contextChars: contextChars
});
- var visualQuery = isVisualSourceQuery(message) || isVisualSourceQuery(searchQuery);
- var multimodalResponse = visualQuery ? await multimodalSearch(buildMultimodalSearchQuery(searchQuery), { limit: 8 }).catch(function(e) {
- console.warn('[clinical-assistant] multimodal search skipped:', e.message);
- return null;
- }) : null;
- var rawTextResults = normalizeMcpSearchResponse(searchResponse);
- var rawMultimodalResults = await classifyAndRerankMultimodalResults(
- message + ' ' + searchQuery,
- normalizeMcpMultimodalResponse(multimodalResponse)
- );
- var rawResults = rawTextResults.concat(rawMultimodalResults);
- console.info('[clinical-assistant] retrieval counts:', {
- text: rawTextResults.length,
- multimodal: rawMultimodalResults.length
- });
- var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
- var textSlots = searchLimit - visualSlots;
- var sources = dedupeSources(
- dedupeSources(rawTextResults).slice(0, textSlots).concat(
- dedupeSources(rawMultimodalResults).slice(0, visualSlots)
- )
- ).slice(0, searchLimit);
+ // Retrieval is text-only. The multimodal path called nc_multimodal_search
+ // against a second hardcoded collection with an embedding service that was
+ // never deployed, so it only ever logged "multimodal search skipped".
+ var rawResults = normalizeMcpSearchResponse(searchResponse);
+ console.info('[clinical-assistant] retrieval count:', rawResults.length);
+ // No visual/text slot split any more: every result is text, so the whole
+ // search limit goes to it.
+ var sources = dedupeSources(rawResults).slice(0, searchLimit);
if (sources.length === 0) {
return { direct: {
@@ -601,7 +582,6 @@ async function prepareAssistantChat(body) {
],
search: {
totalFound: rawResults.length,
- multimodalFound: rawMultimodalResults.length,
query: searchQuery,
rewritten: searchQuery !== message,
verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0,
@@ -646,8 +626,7 @@ function needsContextualRewrite(message) {
function formatSourcesForPrompt(sources) {
return sources.map(function(s) {
- var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
- return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + label + '\n' + cleanSourceExcerpt(s.excerpt);
+ return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + cleanSourceExcerpt(s.excerpt);
}).join('\n\n---\n\n');
}
diff --git a/src/utils/clinicalMcpClient.js b/src/utils/clinicalMcpClient.js
index 8555eaba..3fae5326 100644
--- a/src/utils/clinicalMcpClient.js
+++ b/src/utils/clinicalMcpClient.js
@@ -84,14 +84,6 @@ async function semanticSearch(query, opts) {
});
}
-async function multimodalSearch(query, opts) {
- opts = opts || {};
- return callMcpTool('nc_multimodal_search', {
- query: query,
- limit: opts.limit
- });
-}
-
async function indexedTopicSuggestions(limit) {
return callMcpTool('nc_indexed_topic_suggestions', {
limit: limit || 12,
@@ -342,7 +334,6 @@ function parseMcpResponse(body) {
module.exports = {
semanticSearch: semanticSearch,
- multimodalSearch: multimodalSearch,
indexedTopicSuggestions: indexedTopicSuggestions,
getMcpHealth: getMcpHealth,
warmMcpSession: warmMcpSession,
diff --git a/src/utils/clinicalRetrieval.js b/src/utils/clinicalRetrieval.js
index 89a480ab..5699d408 100644
--- a/src/utils/clinicalRetrieval.js
+++ b/src/utils/clinicalRetrieval.js
@@ -1,5 +1,4 @@
var sourceMarkdown = require('markdown-it')({ html: false });
-var MULTIMODAL_CANDIDATE_LIMIT = 6;
var INTERNAL_TITLE_FALLBACKS = {
'1586022': 'Respiratory Disease',
'FABK010-fm[i-xiv].qxd': 'Respiratory Disease'
@@ -42,54 +41,6 @@ function normalizeMcpSearchResponse(result) {
}).filter(function(r) { return r.excerpt; });
}
-function normalizeMcpMultimodalResponse(result) {
- var data = result && (result.structuredContent || result.data || result);
- if ((!data || !Array.isArray(data.results)) && result && Array.isArray(result.content)) {
- for (var i = 0; i < result.content.length; i++) {
- var c = result.content[i];
- if (c && c.type === 'text' && c.text) {
- try {
- var parsed = JSON.parse(c.text);
- if (parsed && Array.isArray(parsed.results)) data = parsed;
- } catch (e) {}
- }
- }
- }
- if (!data || !Array.isArray(data.results)) return [];
- return data.results.map(function(r, idx) {
- var nearby = clip(cleanSourceExcerpt(r.nearby_text || ''), 1400);
- var caption = clip(cleanSourceExcerpt(r.visual_caption || ''), 900);
- var labels = Array.isArray(r.visual_labels) ? r.visual_labels.filter(Boolean).slice(0, 20) : [];
- var excerptParts = [];
- if (caption) excerptParts.push('[Visual caption] ' + caption);
- if (labels.length) excerptParts.push('[Visual labels] ' + labels.join(', '));
- if (nearby) excerptParts.push('[Page text] ' + nearby);
- return {
- number: idx + 1,
- id: r.id,
- doc_type: r.doc_type || 'file',
- source_type: 'multimodal_page',
- title: displayTitleForSource(r, 'PDF page image'),
- file_path: r.file_path || '',
- category: r.category || '',
- subcategory: r.subcategory || '',
- category_path: r.category_path || '',
- page: r.page_number || r.pageNumber || null,
- page_count: r.page_count || r.pageCount || null,
- visual_caption: caption,
- visual_labels: labels,
- visual_caption_source: r.visual_caption_source || '',
- is_cover_page: Boolean(r.is_cover_page || r.isCoverPage),
- is_title_page: Boolean(r.is_title_page || r.isTitlePage),
- reject_for_visual_search: Boolean(r.reject_for_visual_search || r.rejectForVisualSearch),
- excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[Page-image match] Rendered PDF page matched the visual/text query.',
- score: r.score,
- chunk_index: 'page-image-' + (r.page_number || idx + 1),
- total_chunks: r.page_count || null
- };
- }).filter(function(r) { return r.page; });
-}
-
function dedupeSources(results) {
var seen = new Map();
var out = [];
@@ -125,100 +76,6 @@ function mergeDuplicateSource(existing, duplicate) {
return merged;
}
-function isVisualSourceQuery(query) {
- return visualIntent(query).wanted.length > 0;
-}
-
-function isRadiologyQuery(query) {
- return visualIntent(query).wanted.indexOf('radiology') !== -1;
-}
-
-function buildMultimodalSearchQuery(query) {
- query = String(query || '').trim();
- var intent = visualIntent(query);
- if (intent.wanted.indexOf('radiology') !== -1) return query + ' radiograph x-ray chest imaging radiology';
- if (intent.wanted.indexOf('diagram') !== -1) return query + ' figure diagram pathway flowchart algorithm illustration';
- if (intent.wanted.indexOf('photo') !== -1) return query + ' clinical photograph image skin lesion pathology microscopy';
- return query;
-}
-
-async function classifyAndRerankMultimodalResults(query, results) {
- results = Array.isArray(results) ? results : [];
- if (!results.length) return [];
- var candidates = results.slice(0, MULTIMODAL_CANDIDATE_LIMIT);
- return selectMultimodalResults(query, candidates);
-}
-
-function selectMultimodalResults(query, results) {
- results = Array.isArray(results) ? results : [];
- if (!results.length) return [];
- results = results.filter(function(r) { return !shouldRejectVisualSource(query, r); });
- if (!results.length) return [];
- if (!isRadiologyQuery(query)) return results.filter(function(r) { return !looksLikeTextOnlyPage(r); });
- var radiologyTerms = /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film|pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i;
- return results.filter(function(r) {
- return radiologyTerms.test([r.title, r.excerpt, r.file_path, r.category, r.subcategory, r.category_path].filter(Boolean).join(' '));
- });
-}
-
-function visualIntent(query) {
- var text = String(query || '').toLowerCase();
- var wanted = [];
- if (/\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film)\b/i.test(text)) {
- wanted.push('radiology');
- }
- if (/\b(algorithm|flow\s*chart|flowchart|pathway|diagram|graph|chart|figure|figures|illustration|infographic|schema|schematic|table)\b/i.test(text)) {
- wanted.push('diagram');
- }
- if (/\b(photo|photograph|picture|skin|rash|lesion|wound|gross|microscopy|histology|pathology image|clinical image)\b/i.test(text)) {
- wanted.push('photo');
- }
- if (!wanted.length && /\b(visual|image|images|show|find|display|preview)\b/i.test(text)) {
- wanted.push('diagram', 'photo');
- }
- return { wanted: Array.from(new Set(wanted)) };
-}
-
-function visualMetadataScore(query, source) {
- var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path, source.category, source.subcategory, source.category_path].filter(Boolean).join(' ');
- var score = 0;
- var intent = visualIntent(query);
- if (isRadiologyQuery(query) && /\b(radiology|radiograph|x-?ray|cxr|imaging|ct|mri|ultrasound|film)\b/i.test(haystack)) score += 0.12;
- if (intent.wanted.indexOf('diagram') !== -1 && /\b(figure|diagram|algorithm|flowchart|pathway|chart|illustration|infographic)\b/i.test(haystack)) score += 0.12;
- if (intent.wanted.indexOf('photo') !== -1 && /\b(photo|photograph|skin|rash|lesion|wound|gross|microscopy|histology)\b/i.test(haystack)) score += 0.12;
- if (/\b(pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i.test(haystack)) score += 0.06;
- if (looksLikeFrontMatterPage(source)) score -= 0.5;
- if (looksLikeTextOnlyPage(source)) score -= 0.18;
- return score;
-}
-
-function shouldRejectVisualSource(query, source) {
- if (!source) return false;
- if (allowsFrontMatterQuery(query)) return false;
- return Boolean(source.reject_for_visual_search || source.is_cover_page || source.is_title_page || looksLikeFrontMatterPage(source));
-}
-
-function allowsFrontMatterQuery(query) {
- return /\b(cover|front cover|title page|copyright|table of contents|contents|preface|foreword|publisher|isbn)\b/i.test(String(query || ''));
-}
-
-function looksLikeFrontMatterPage(source) {
- var page = Number(source && source.page) || 0;
- var pageCount = Number(source && source.page_count) || 0;
- var earlyLimit = Math.min(8, Math.max(2, pageCount || 8));
- if (!page || page > earlyLimit) return false;
- var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path].filter(Boolean).join(' ');
- var hasVisualSignal = /\b(figure|fig\.?|radiograph|x-?ray|cxr|ultrasound|ct|mri|photograph|photo|image|diagram|flowchart|algorithm|histology|microscopy|gross pathology)\b/i.test(haystack);
- if (hasVisualSignal) return false;
- var frontMatterSignal = /\b(cover|front cover|title page|copyright|all rights reserved|isbn|library of congress|table of contents|contents|preface|foreword|dedication|contributors?|textbook of|manual of|review of|board review|edition|authors?|editor|publisher)\b/i.test(haystack);
- return frontMatterSignal || page <= 2;
-}
-
-function looksLikeTextOnlyPage(source) {
- var text = String(source && source.excerpt || '');
- return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
-}
-
function clip(s, n) { return String(s || '').replace(/\s+/g, ' ').trim().substring(0, n); }
function displayTitleForSource(source, fallback) {
@@ -349,10 +206,5 @@ module.exports = {
cleanSourceExcerpt: cleanSourceExcerpt,
clipSourceExcerpt: clipSourceExcerpt,
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
- normalizeMcpMultimodalResponse: normalizeMcpMultimodalResponse,
- dedupeSources: dedupeSources,
- isVisualSourceQuery: isVisualSourceQuery,
- buildMultimodalSearchQuery: buildMultimodalSearchQuery,
- classifyAndRerankMultimodalResults: classifyAndRerankMultimodalResults,
- visualMetadataScore: visualMetadataScore
+ dedupeSources: dedupeSources
};
diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js
index e4e4ee67..b5577762 100644
--- a/test/admin-clinical-assistant-wiring.test.js
+++ b/test/admin-clinical-assistant-wiring.test.js
@@ -125,3 +125,37 @@ test('real extracted initializer displays the server default only when returned
assert.match(document.getElementById('assistant-conversation-budget-meta').textContent, /built-in default/,
"source 'default' must not be reported as coming from the environment variable");
});
+
+test('an admin can add a model id discovery never returned', async t => {
+ const dom = new JSDOM(read('public/components/admin.html'));
+ browserGlobals(t, dom, async () => ({ ok: true, json: async () => ({
+ success: true, config: [], models: [{ id: 'discovered-model', name: 'Discovered' }],
+ conversationBudget: { limit: 120000, source: 'default' }
+ }) }), []);
+ const { initClinicalAssistantAdmin } = await import(pathToFileURL(path.join(root, 'public/js/admin/clinicalAssistant.js')).href);
+ initClinicalAssistantAdmin(value => value);
+ document.dispatchEvent(new dom.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
+ await tick();
+
+ const list = document.getElementById('assistant-allowed-chat-models');
+ const field = document.getElementById('assistant-add-chat-model');
+ assert.ok(field, 'there is somewhere to type a model id');
+ const before = list.querySelectorAll('input[type="checkbox"]').length;
+
+ field.value = 'openrouter-deepseek-v4-pro';
+ document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click();
+ await tick();
+
+ const values = Array.from(list.querySelectorAll('input[type="checkbox"]')).map(b => b.value);
+ assert.ok(values.includes('openrouter-deepseek-v4-pro'), 'the typed id joins the list');
+ assert.equal(values.length, before + 1);
+ const added = list.querySelector('input[value="openrouter-deepseek-v4-pro"]');
+ assert.equal(added.checked, true, 'and is enabled, since that is why it was added');
+ assert.equal(field.value, '', 'the field clears for the next one');
+
+ // Adding the same id twice must not duplicate the row.
+ field.value = 'openrouter-deepseek-v4-pro';
+ document.querySelector('[data-assistant-add-model="assistant-allowed-chat-models"]').click();
+ await tick();
+ assert.equal(list.querySelectorAll('input[value="openrouter-deepseek-v4-pro"]').length, 1);
+});
diff --git a/test/clinical-mcp-session-lifecycle.test.js b/test/clinical-mcp-session-lifecycle.test.js
index 6dabf91e..d0bbb5d2 100644
--- a/test/clinical-mcp-session-lifecycle.test.js
+++ b/test/clinical-mcp-session-lifecycle.test.js
@@ -57,12 +57,12 @@ test('live session reuse, real initialized notification and serialized tool call
} });
const first = client.semanticSearch('one', { limit: 4 });
await started.promise;
- const second = client.multimodalSearch('two', { limit: 8 });
+ const second = client.indexedTopicSuggestions(8); // any second tool call proves serialization
await turn();
assert.equal(tools, 1);
held.resolve();
await Promise.all([first, second]);
- await client.indexedTopicSuggestions(12);
+ await client.semanticSearch('three', { limit: 4 });
assert.deepEqual(calls.filter(c => c.method === 'POST').map(method), ['initialize', 'notifications/initialized', 'tools/call', 'tools/call', 'tools/call']);
const notice = calls[1];
assert.equal(notice.payload.jsonrpc, '2.0');
diff --git a/test/clinical-retrieval-title.test.js b/test/clinical-retrieval-title.test.js
index a90f36a5..30cd0b1c 100644
--- a/test/clinical-retrieval-title.test.js
+++ b/test/clinical-retrieval-title.test.js
@@ -1,7 +1,7 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
-const { normalizeMcpSearchResponse, normalizeMcpMultimodalResponse } = require('../src/utils/clinicalRetrieval');
+const { normalizeMcpSearchResponse } = require('../src/utils/clinicalRetrieval');
test('clinical retrieval replaces known internal PDF title with document title', () => {
const results = normalizeMcpSearchResponse({
@@ -19,13 +19,15 @@ test('clinical retrieval replaces known internal PDF title with document title',
});
test('clinical retrieval prefers file basename for internal PDF production titles', () => {
- const results = normalizeMcpMultimodalResponse({
+ // Retrieval is text-only now; this used to run through the multimodal
+ // normalizer, but the title cleanup it checks belongs to the shared path.
+ const results = normalizeMcpSearchResponse({
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',
+ excerpt: 'Airway anatomy and management in the critically ill child.',
page_number: 12
}]
});