Translation
- Stop scrubbing markdown before sending it to LibreTranslate. The scrub
deleted ordered-list numbering ("1. Give amoxicillin" -> "Give amoxicillin"),
flattened tables into ambiguous whitespace and ate underscores inside
identifiers. Raw markdown now goes to the translator unchanged.
- Render the translation through the same markdown pipeline as the original
bubble, with the message's own sources, so [n] markers come back as the usual
clickable .assistant-cite chips instead of escaped literal text. Headings,
lists and tables survive with them.
- When the translator drops citation markers, surface the affected sources in a
recovery block rather than letting the evidence disappear.
- Image cards are live nodes: they are now re-attached on every path out of a
translation (success, failure and Show original), so a failed translation no
longer silently removes a generating image from the message.
Patient take home
- Add a language selector to the take-home modal, reusing the existing
/translate endpoint and offering only what the local LibreTranslate reports.
- Copy, Export and Email carry what the caregiver is actually reading; the
original stays canonical behind "Original".
Conversation budget
- The admin field no longer prefills with the environment value, which turned
the next Save into an accidental override and made the documented "leave
empty to use the environment" path unreachable. The effective limit is shown
as a placeholder instead.
- Report source 'default' honestly instead of naming an unset env var.
- The load-failure notice now lands on the <p> instead of an <input>'s
textContent, where it rendered nothing.
- One validator for the budget everywhere: conversationLimit() replaces a
parseInt that accepted "120000abc".
Other
- /assistant is addressed by its URL, not by ped_last_tab, so "/" no longer
reopens the assistant; the URL follows tab changes and Back leaves it.
- Remove the dead DeepL path (it referenced an undefined DEEPL_BASES) and stop
offering admins a provider the server silently ignores.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
183 lines
12 KiB
JavaScript
183 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/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');
|
|
});
|
|
});
|