Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and enqueued an image from the answer text when the model had not called the tool. It was a compatibility path for models without tool calling and it did more harm than good: it decided in English only, it could not see the conversation, and "image summary" fell through it while reading as an obvious image request to the model itself — which was measured, not assumed. A second and worse decision-maker sitting behind the first. Whether a message deserves a picture is now the model's call, made from the tool description, which is the only place it ever belonged. **Lending eyes.** The same shape, for a different capability. When someone attaches a photograph and the chat model cannot accept image input, the attachment was either refused by the provider or silently dropped — an answer about a picture nobody had looked at, which is worse than a refusal. The chat model is now offered look_at_image beside the image tool and decides when to use it. The attachment goes to clinical_assistant.vision_model, whose description comes back as a tool result, and the chat model answers in its own voice with its own sources. Only the seeing is delegated; the clinical reasoning stays with the model an administrator chose. The seeing model is told to report and not to diagnose, because it has a picture and no context and an opinion from it would carry weight it has not earned. Delegation triggers only on an explicit supports_vision: false from the gateway. An unknown is left alone — most of a roster reports nothing, and treating silence as blindness would route good models through a detour. The capability lookup moved to its own module, is cached for five minutes because it runs on exactly the requests that are already slowest, and is never inferred from the model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the other gateway helpers live. The new setting is guarded like the slide reviewer: a model the gateway calls text-only cannot be saved as the one that looks at images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
185 lines
12 KiB
JavaScript
185 lines
12 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const express = require('express');
|
|
const { JSDOM } = require('jsdom');
|
|
const { marked } = require('marked');
|
|
const policy = require('../src/utils/clinicalConversation');
|
|
const answer = require('../src/utils/clinicalAnswer');
|
|
const root = path.join(__dirname, '..');
|
|
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
|
const quiet = { log() {}, warn() {}, error() {}, info() {} };
|
|
const canonical = (length, fill = 7) => Buffer.alloc(length, fill).toString('base64');
|
|
const png = { dataBase64: canonical(16), mimeType: 'image/png', name: 'chest.png' };
|
|
const jpeg = { dataBase64: canonical(32), mimeType: 'image/jpeg', name: 'knee.jpeg' };
|
|
const asset = '/api/generated-images/12345678-1234-1234-1234-123456789abc';
|
|
|
|
function server(options = {}) {
|
|
const calls = { ai: [], search: [], writes: [], images: [] };
|
|
let saved;
|
|
const db = {
|
|
async getSetting(key) {
|
|
if (options.dbError) throw Error('private diagnostic');
|
|
if (key.endsWith('conversation_chars')) return options.legacyLimit ?? 'broken';
|
|
if (key === 'clinical_assistant.translate_provider') return 'libretranslate';
|
|
return options.model || null;
|
|
},
|
|
async get(sql, params) {
|
|
if (sql.includes('COUNT')) return { cnt: 0 };
|
|
if (sql.includes('FROM clinical_assistant_chats') && params) return saved && params[0] === 1 && params[1] === 7 ? { id: params[0], title: saved[1], payload: saved[2] } : null;
|
|
return saved && params[1] === 7 ? { id: 1, title: saved[1], payload: saved[2] } : null;
|
|
},
|
|
async run(sql, params) { calls.writes.push(sql); saved = params; return { lastInsertRowid: 1 }; },
|
|
async query(sql, params) { return { rows: (params[0] || []).map(id => ({ id })) }; }
|
|
};
|
|
const ai = async (messages, settings) => {
|
|
calls.ai.push({ messages, settings });
|
|
return { content: 'Complete supported answer. [1]', finishReason: 'stop', model: 'synthetic' };
|
|
};
|
|
const source = { number: 1, title: 'Synthetic source', excerpt: 'Synthetic reference.', page: 9 };
|
|
const mocks = {
|
|
express, axios: { async post(url, payload) { if (String(url).includes('/translate')) return { data: { translatedText: 'Synthetic translation.' } }; calls.images.push(payload); return { data: { data: [{ b64_json: 'c3ludGhldGlj' }] } }; } }, crypto: require('node:crypto'), '../db/database': db,
|
|
'../middleware/auth': { authMiddleware() {} },
|
|
'./auth': { __sendEmail: async () => false }, '../utils/ai': options.ai || { callAI: ai, callAIStream: ai },
|
|
'../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
|
|
'../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') },
|
|
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
|
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue() { return { success: true, jobId: 'synthetic', status: 'pending' }; } }) },
|
|
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
|
|
'../utils/visionTool': require('../src/utils/visionTool'),
|
|
'../utils/modelVision': { supportsVision: async () => null },
|
|
'../utils/imageTool': require('../src/utils/imageTool'),
|
|
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
|
|
'../utils/clinicalMcpClient': {
|
|
async semanticSearch(query) { calls.search.push(query); return {}; }, async getMcpHealth() { calls.health = calls.health || []; calls.health.push('health'); return {}; }
|
|
},
|
|
'../utils/clinicalRetrieval': {
|
|
cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt,
|
|
normalizeMcpSearchResponse: () => options.noSources ? [] : [source],
|
|
normalizeMcpMultimodalResponse: () => [], dedupeSources: value => value,
|
|
isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => []
|
|
},
|
|
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': policy,
|
|
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'),
|
|
'../utils/patientTakehome': require('../src/utils/patientTakehome')
|
|
};
|
|
const module = { exports: {} };
|
|
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
|
|
module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder,
|
|
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', CLINICAL_ASSISTANT_CONVERSATION_CHARS: options.limit, LITELLM_API_BASE: 'http://synthetic.invalid' } }, setTimeout() {},
|
|
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
|
|
});
|
|
async function request(method, endpoint, body, userId = 7) {
|
|
const handler = module.exports.stack.find(layer => layer.route && layer.route.path === endpoint && layer.route.methods[method]).route.stack.find(layer => layer.method === method).handle;
|
|
const res = {
|
|
statusCode: 200, headers: {}, events: '', body: null,
|
|
status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; },
|
|
setHeader(k, v) { this.headers[k] = v; }, write(text) { this.events += text; }, end() {}, flushHeaders() {}
|
|
};
|
|
await handler({ body, params: { id: 1 }, user: { id: userId } }, res);
|
|
return res;
|
|
}
|
|
return { calls, request };
|
|
}
|
|
|
|
test('savedChatPayload persists bounded attachments and the generated image; invalid input is rejected before any write', () => {
|
|
const messages = [{ role: 'user', content: 'Question', attachments: [png, jpeg] }, { role: 'assistant', content: 'Answer [1]', sources: [{ number: 1, title: 'S' }] }];
|
|
const payload = policy.savedChatPayload({ messages, lastAnswer: 'Answer [1]', sources: [], generatedImage: asset });
|
|
assert.deepEqual(payload.messages[0].attachments, [png, jpeg], 'normalized roundtrip incl. name');
|
|
assert.equal(payload.generatedImage, asset, 'sidebar image now persists');
|
|
assert.equal(payload.messages[1].attachments, undefined, 'absent attachments stay absent');
|
|
|
|
const invalid = [
|
|
[{ dataBase64: png.dataBase64, mimeType: 'image/svg+xml' }],
|
|
[{ dataBase64: 'not base64!!!', mimeType: 'image/png' }],
|
|
[{ dataBase64: canonical(5 * 1024 * 1024 + 1), mimeType: 'image/png' }],
|
|
[png, jpeg, png, jpeg, png],
|
|
'nope'
|
|
];
|
|
for (const attachments of invalid) {
|
|
assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments }] }), error => error.statusCode === 400);
|
|
}
|
|
const longName = { dataBase64: png.dataBase64, mimeType: png.mimeType, name: 'n'.repeat(400) };
|
|
const trimmed = policy.savedChatPayload({ messages: [{ role: 'user', content: 'Q', attachments: [longName] }] });
|
|
assert.equal(trimmed.messages[0].attachments[0].name.length, 255, 'names clip at 255 characters');
|
|
|
|
for (const generatedImage of ['data:image/svg+xml;base64,PHN2Zz4=', 'javascript:alert(1)']) {
|
|
assert.throws(() => policy.savedChatPayload({ messages: [], generatedImage }), error => error.statusCode === 400);
|
|
}
|
|
assert.throws(() => policy.savedChatPayload({ messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] }), error => error.statusCode === 413);
|
|
});
|
|
|
|
test('POST /chats stores and reopens attachments; the same id updates in place instead of duplicating', async () => {
|
|
const app = server();
|
|
const body = { messages: [{ role: 'user', content: 'Question with image', attachments: [png] }, { role: 'assistant', content: 'Answer [1]' }], lastAnswer: 'Answer [1]', sources: [] };
|
|
const saved = await app.request('post', '/clinical-assistant/chats', body);
|
|
assert.equal(saved.statusCode, 200);
|
|
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
|
|
assert.deepEqual(JSON.parse(JSON.stringify(reopened.body.chat.payload.messages[0].attachments)), [png]);
|
|
const updated = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'Edited', attachments: [jpeg] }], lastAnswer: '' });
|
|
assert.equal(updated.statusCode, 200);
|
|
assert.equal(updated.body.id, 1);
|
|
assert.ok(app.calls.writes.some(sql => /UPDATE clinical_assistant_chats/.test(sql)), 'update path used');
|
|
const foreign = await app.request('post', '/clinical-assistant/chats', { id: 1, messages: [{ role: 'user', content: 'x' }] }, 8);
|
|
assert.equal(foreign.statusCode, 404, 'updates are owner-bound');
|
|
const missing = await app.request('post', '/clinical-assistant/chats', { id: 99, messages: [{ role: 'user', content: 'x' }] });
|
|
assert.equal(missing.statusCode, 404);
|
|
const badId = await app.request('post', '/clinical-assistant/chats', { id: 'not-a-number', messages: [{ role: 'user', content: 'x' }] });
|
|
assert.equal(badId.statusCode, 400);
|
|
});
|
|
|
|
test('client restores attachments as thumbnails and saves them plus the generated image', t => {
|
|
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
|
|
const window = dom.window;
|
|
window.eval(read('public/js/accountBoundary.js'));
|
|
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
|
window.marked = marked;
|
|
window.DOMPurify = require('dompurify')(window);
|
|
window.matchMedia = () => ({ matches: true });
|
|
const saves = [];
|
|
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
|
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: src => '<img src="' + src + '">' }),
|
|
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
fetchAssistantImageJobs: async () => ({ success: true, jobs: [{ jobId: 'g1', imageUrl: asset }] }),
|
|
saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(policy.savedChatPayload(body)))); return { success: true, id: 1 }; } };
|
|
vm.createContext(context);
|
|
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
|
|
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
|
|
}
|
|
t.after(() => window.close());
|
|
context.bindEvents();
|
|
context.appendMessage('user', 'Question with image', [], [], false, { attachments: [png] });
|
|
let thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
|
|
assert.ok(thumbs, 'thumbnail strip rendered');
|
|
assert.ok(thumbs.querySelector('img').getAttribute('src').startsWith('data:image/png;base64,'));
|
|
assert.match(thumbs.textContent, /chest\.png/);
|
|
context.appendMessage('assistant', 'Answer [1].', [{ number: 1, title: 'S', page: 2 }]);
|
|
context.lastAnswer = 'Answer [1].';
|
|
context.lastGeneratedImageSrc = asset;
|
|
context.scheduleAutosave();
|
|
return context.performAutosave().then(() => {
|
|
assert.deepEqual(saves[0].messages[0].attachments, [png]);
|
|
assert.equal(saves[0].generatedImage, asset);
|
|
context.restoreSavedChat(saves[0]);
|
|
thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
|
|
assert.ok(thumbs, 'attachments restored after reload');
|
|
// The user's image library lives inside the Create image popup.
|
|
window.document.getElementById('btn-assistant-create-image').click();
|
|
var waitFor = function(remaining) {
|
|
return new Promise(function(resolve) { setImmediate(resolve); }).then(function() {
|
|
if (window.document.querySelector('#create-image-history img')) return Promise.resolve();
|
|
if (remaining <= 0) return Promise.resolve();
|
|
return waitFor(remaining - 1);
|
|
});
|
|
};
|
|
return waitFor(12).then(function() {
|
|
const galleryWrap = window.document.querySelector('#create-image-history');
|
|
const galleryImg = window.document.querySelector('#create-image-history img');
|
|
assert.ok(galleryImg && galleryImg.getAttribute('src') === asset, 'generated image restored into the image history popup; popup=' + (window.document.getElementById('assistant-create-image-modal') ? 'open' : 'missing') + ' history=' + (galleryWrap ? galleryWrap.innerHTML.slice(0, 120) : 'missing'));
|
|
});
|
|
assert.equal(context.messages[0].content, 'Question with image', 'raw transcript canonical');
|
|
});
|
|
});
|