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
285 lines
17 KiB
JavaScript
285 lines
17 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 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() {} };
|
||
|
||
async function translate(options) {
|
||
const { translateMessage } = require('../src/utils/clinicalTranslation');
|
||
return translateMessage(options);
|
||
}
|
||
|
||
function baseOptions(overrides = {}) {
|
||
return {
|
||
message: 'Chest pain in a four year old.',
|
||
target: 'es',
|
||
userId: 7,
|
||
env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000', DEEPL_API_KEY: 'synthetic-key', DEEPL_API_BASE: 'https://api.deepl.com/v2' },
|
||
getSetting: async () => null,
|
||
cache: new Map(),
|
||
...overrides
|
||
};
|
||
}
|
||
|
||
function axiosStub(behavior) {
|
||
const calls = [];
|
||
return {
|
||
calls,
|
||
axios: { post: async (url, payload, opts) => { calls.push({ url: String(url), payload, opts }); return behavior({ url: String(url), payload, opts }, calls.length); } }
|
||
};
|
||
}
|
||
|
||
test('translation validates message, target and provider before any provider contact', async () => {
|
||
for (const target of ['xx', '', 'xy', 'javascript']) {
|
||
const stub = axiosStub(() => { throw new Error('must not be called'); });
|
||
await assert.rejects(translate(baseOptions({ target, axios: stub.axios })), e => e.statusCode === 400);
|
||
assert.equal(stub.calls.length, 0);
|
||
}
|
||
await assert.rejects(translate(baseOptions({ message: '' })), e => e.statusCode === 400);
|
||
await assert.rejects(translate(baseOptions({ message: 'x'.repeat(20001) })), e => e.statusCode === 400);
|
||
const stub = axiosStub(() => { throw new Error('must not be called'); });
|
||
await assert.rejects(translate(baseOptions({ provider: 'google', axios: stub.axios })), e => e.statusCode === 400);
|
||
assert.equal(stub.calls.length, 0, 'unknown providers are refused before contact');
|
||
// html format passes through to the local provider unchanged
|
||
const htmlStub = axiosStub(() => ({ data: { translatedText: '<p>Ok</p>' } }));
|
||
const html = await translate(baseOptions({ format: 'html', axios: htmlStub.axios }));
|
||
assert.equal(html.translated, '<p>Ok</p>');
|
||
assert.equal(htmlStub.calls[0].payload.format, 'html');
|
||
});
|
||
|
||
test('libretranslate is the local-first default and caches per provider+message+lang', async () => {
|
||
const stub = axiosStub(() => ({ data: { translatedText: 'Synthetic Spanish.' } }));
|
||
const cache = new Map();
|
||
const opts = baseOptions({ axios: stub.axios, cache });
|
||
const first = await translate(opts);
|
||
assert.equal(first.translated, 'Synthetic Spanish.');
|
||
assert.equal(first.provider, 'libretranslate');
|
||
assert.equal(stub.calls.length, 1);
|
||
assert.match(stub.calls[0].url, /^http:\/\/libretranslate:5000\/translate$/);
|
||
assert.deepEqual(stub.calls[0].payload, { q: 'Chest pain in a four year old.', source: 'auto', target: 'es', format: 'text' });
|
||
await translate(opts);
|
||
assert.equal(stub.calls.length, 1, 'cache hit');
|
||
// a different language or provider is a separate cache entry
|
||
await translate(baseOptions({ axios: stub.axios, cache, target: 'fr' }));
|
||
assert.equal(stub.calls.length, 2);
|
||
});
|
||
|
||
|
||
|
||
|
||
|
||
test('translate route is owner-bound, validated and cached; admin default provider is honored', async () => {
|
||
const module = { exports: {} };
|
||
const calls = [];
|
||
const db = { async getSetting(key) { return null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } };
|
||
const mocks = {
|
||
express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translatedText: 'Traducción sintética.' } }; } }, crypto: require('node:crypto'),
|
||
'../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) },
|
||
'../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {}, warn() {} },
|
||
'../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') },
|
||
'../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
|
||
'../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return {}; }, async enqueue() { return {}; } }) },
|
||
'../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() { return {}; }, async getMcpHealth() { return {}; } },
|
||
'../utils/clinicalRetrieval': { cleanSourceExcerpt: require('../src/utils/clinicalRetrieval').cleanSourceExcerpt, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
|
||
'../utils/clinicalAnswer': answer, '../utils/clinicalConversation': require('../src/utils/clinicalConversation'),
|
||
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'),
|
||
'../utils/patientTakehome': require('../src/utils/patientTakehome')
|
||
};
|
||
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
|
||
module, exports: module.exports, console: quiet, Buffer, Map, TextEncoder,
|
||
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', DEEPL_API_KEY: 'synthetic-key', DEEPL_API_BASE: 'https://api.deepl.com/v2' } }, setTimeout() {},
|
||
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
|
||
});
|
||
async function request(body) {
|
||
const handler = module.exports.stack.find(layer => layer.route && layer.route.path === '/clinical-assistant/translate' && layer.route.methods.post).route.stack.find(layer => layer.method === 'post').handle;
|
||
const res = { statusCode: 200, headers: {}, body: null, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, setHeader() {}, write() {}, end() {}, flushHeaders() {} };
|
||
await handler({ body, user: { id: 7 } }, res);
|
||
return res;
|
||
}
|
||
const ok = await request({ message: 'Chest pain.', target: 'es' });
|
||
assert.equal(ok.statusCode, 200);
|
||
assert.equal(ok.body.translated, 'Traducción sintética.');
|
||
assert.equal(ok.body.provider, 'libretranslate', 'local provider is the only provider');
|
||
assert.match(calls[0], /libretranslate:5000\/translate$/);
|
||
const cached = await request({ message: 'Chest pain.', target: 'es' });
|
||
assert.equal(calls.length, 1, 'route cache hit');
|
||
const bad = await request({ message: 'Chest pain.', target: 'nope' });
|
||
assert.equal(bad.statusCode, 400);
|
||
assert.equal(calls.length, 1, 'validation never contacts a provider');
|
||
});
|
||
|
||
function client(t, options = {}) {
|
||
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 calls = [];
|
||
const toasts = [];
|
||
const apiFetch = async (url, options) => {
|
||
calls.push({ url, options });
|
||
if (url === '/api/clinical-assistant/translate') {
|
||
const body = JSON.parse(options.body);
|
||
if (options.failEs || body.target === 'bad') return new Response(JSON.stringify({ error: 'Unsupported translation language.' }), { status: 400 });
|
||
// Stands in for a translator that returns the same structure it was given.
|
||
if (options.stripCitations) return new Response(JSON.stringify({ success: true, translated: String(body.message).replace(/\[\d+\]/g, ''), provider: body.provider }));
|
||
if (options.echo) return new Response(JSON.stringify({ success: true, translated: body.message, provider: body.provider }));
|
||
return new Response(JSON.stringify({ success: true, translated: 'Traducción de "' + body.message + '"', provider: body.provider }));
|
||
}
|
||
return new Response(JSON.stringify({ success: true, chats: [] }));
|
||
};
|
||
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
|
||
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
|
||
getAuthHeaders: () => ({ 'Content-Type': 'application/json' }),
|
||
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
|
||
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
||
saveAssistantChat: async () => ({ success: true }),
|
||
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
|
||
translateAssistantMessage: (message, target, provider, format) => apiFetch('/api/clinical-assistant/translate', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider, format: format || 'text' }),
|
||
failEs: options.failEs, stripCitations: options.stripCitations, echo: options.echo
|
||
}).then(function(r) { return r.json(); }),
|
||
fetch: apiFetch };
|
||
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);
|
||
}
|
||
context.bindEvents();
|
||
t.after(() => window.close());
|
||
return { context, document: window.document, window, calls, toasts };
|
||
}
|
||
|
||
test('per-message translate picker offers a provider choice and leaves the raw transcript untouched', async t => {
|
||
const app = client(t);
|
||
const c = app.context;
|
||
c.appendMessage('assistant', 'Chest pain in a four year old.', []);
|
||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||
const translateBtn = row.querySelector('[data-assistant-msg-translate]');
|
||
translateBtn.click();
|
||
const pop = app.document.querySelector('[data-assistant-translate-pop]');
|
||
assert.ok(pop, 'language picker opens');
|
||
assert.equal(pop.querySelector('[data-assistant-translate-provider]'), null, 'no provider choice — users pick a language only');
|
||
const es = pop.querySelector('[data-assistant-translate-lang="es"]');
|
||
assert.ok(es);
|
||
es.click();
|
||
await new Promise(r => setImmediate(r));
|
||
const translateCalls = app.calls.filter(c => c.url === '/api/clinical-assistant/translate');
|
||
assert.equal(translateCalls.length, 1);
|
||
assert.equal(translateCalls[0].url, '/api/clinical-assistant/translate');
|
||
const sent = JSON.parse(translateCalls[0].options.body);
|
||
assert.equal(sent.target, 'es');
|
||
assert.equal(sent.provider, 'libretranslate');
|
||
assert.equal(sent.format, 'html', 'html mode is what preserves tables and emphasis');
|
||
assert.equal(sent.message, '<p>Chest pain in a four year old.</p>\n');
|
||
const bubble = row.querySelector('.assistant-bubble');
|
||
assert.match(bubble.textContent, /Traducción de "Chest pain in a four year old/);
|
||
assert.equal(c.messages[0].content, 'Chest pain in a four year old.', 'canonical transcript unchanged');
|
||
const showOriginal = bubble.querySelector('[data-assistant-msg-show-original]');
|
||
assert.ok(showOriginal);
|
||
showOriginal.click();
|
||
assert.match(bubble.innerHTML, /Chest pain in a four year old\./);
|
||
assert.equal(bubble.querySelector('[data-assistant-msg-show-original]'), null, 'back to the original view');
|
||
});
|
||
|
||
test('translation failures toast once and keep the original rendering', async t => {
|
||
const app = client(t, { failEs: true });
|
||
const c = app.context;
|
||
c.appendMessage('assistant', 'Original content.', []);
|
||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||
row.querySelector('[data-assistant-msg-translate]').click();
|
||
const pop = app.document.querySelector('[data-assistant-translate-pop]');
|
||
pop.querySelector('[data-assistant-translate-lang="es"]').click();
|
||
await new Promise(r => setImmediate(r));
|
||
assert.equal(app.toasts.length, 1);
|
||
assert.match(app.toasts[0][0], /Unsupported translation language/);
|
||
assert.match(row.querySelector('.assistant-bubble').innerHTML, /Original content\./);
|
||
assert.equal(c.messages[0].content, 'Original content.');
|
||
});
|
||
|
||
test('a translated answer keeps its citation chips, headings, lists and tables', async t => {
|
||
// echo: a translator that returns the same structure it was handed, which is
|
||
// what LibreTranslate does in text mode for markdown it does not understand.
|
||
const app = client(t, { echo: true });
|
||
const c = app.context;
|
||
const raw = [
|
||
'## Management [1]',
|
||
'',
|
||
'| Drug | Dose |',
|
||
'| --- | --- |',
|
||
'| Amoxicillin | 90 mg/kg/day |',
|
||
'',
|
||
'1. Give amoxicillin **90 mg/kg/day** [1]',
|
||
'2. Reassess in 48 h [2]'
|
||
].join('\n');
|
||
const sources = [{ title: 'AAP otitis media', page: 4 }, { title: 'Local protocol' }];
|
||
c.appendMessage('assistant', raw, sources);
|
||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||
row.querySelector('[data-assistant-msg-translate]').click();
|
||
app.document.querySelector('[data-assistant-translate-lang="es"]').click();
|
||
await new Promise(r => setImmediate(r));
|
||
|
||
const sent = JSON.parse(app.calls.filter(x => x.url === '/api/clinical-assistant/translate')[0].options.body);
|
||
// Rendered HTML, not markdown: LibreTranslate's text mode turns table pipes
|
||
// into "←" and "**bold**" into "** bold**"; html mode leaves tags intact.
|
||
assert.equal(sent.format, 'html');
|
||
assert.match(sent.message, /<table>/, 'the table crosses the wire as a real table');
|
||
assert.match(sent.message, /<ol>/, 'ordered steps cross as an ordered list');
|
||
assert.match(sent.message, /<strong>90 mg\/kg\/day<\/strong>/, 'emphasis crosses as a tag');
|
||
assert.match(sent.message, /\[1\]/, 'citation markers cross as bare text so they can be re-linked');
|
||
assert.doesNotMatch(sent.message, /assistant-cite/, 'they are NOT pre-linked, which the translator would mangle');
|
||
|
||
const bubble = row.querySelector('.assistant-bubble');
|
||
const chips = bubble.querySelectorAll('.assistant-cite');
|
||
assert.equal(chips.length, 3, 'every [n] came back as a clickable source chip');
|
||
assert.equal(chips[0].getAttribute('data-source-number'), '1');
|
||
assert.match(chips[0].getAttribute('title'), /AAP otitis media/, 'chips stay bound to this message’s sources');
|
||
assert.ok(bubble.querySelector('table'), 'the table is still a table');
|
||
assert.ok(bubble.querySelector('ol'), 'the numbered steps are still numbered');
|
||
assert.ok(bubble.querySelector('h2'), 'headings survive');
|
||
assert.equal(bubble.querySelector('.assistant-translated-sources'), null, 'nothing was lost, so no recovery block');
|
||
});
|
||
|
||
test('citations the translator drops are still reachable instead of vanishing', async t => {
|
||
const app = client(t, { stripCitations: true });
|
||
const c = app.context;
|
||
c.appendMessage('assistant', 'Amoxicillin first line [1][2].', [{ title: 'AAP otitis media' }, { title: 'Local protocol' }]);
|
||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||
row.querySelector('[data-assistant-msg-translate]').click();
|
||
app.document.querySelector('[data-assistant-translate-lang="es"]').click();
|
||
await new Promise(r => setImmediate(r));
|
||
const recovery = row.querySelector('.assistant-bubble .assistant-translated-sources');
|
||
assert.ok(recovery, 'dropped markers surface a recovery block rather than disappearing');
|
||
assert.equal(recovery.querySelectorAll('.assistant-cite').length, 2, 'both lost sources stay clickable');
|
||
});
|
||
|
||
test('a failed translation puts the live image card back', async t => {
|
||
const app = client(t, { failEs: true });
|
||
const c = app.context;
|
||
c.appendMessage('assistant', 'Here is the diagram.', []);
|
||
const bubble = app.document.querySelector('.assistant-msg.assistant .assistant-bubble');
|
||
const card = app.document.createElement('div');
|
||
card.className = 'assistant-image-card';
|
||
card.setAttribute('data-job-id', 'job-1');
|
||
bubble.appendChild(card);
|
||
const row = app.document.querySelector('.assistant-msg.assistant');
|
||
row.querySelector('[data-assistant-msg-translate]').click();
|
||
app.document.querySelector('[data-assistant-translate-lang="es"]').click();
|
||
await new Promise(r => setImmediate(r));
|
||
const stillThere = bubble.querySelector('.assistant-image-card');
|
||
assert.ok(stillThere, 'the image card is not lost when translation fails');
|
||
assert.equal(stillThere, card, 'it is the same live node, so its status polling continues');
|
||
});
|