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
434 lines
27 KiB
JavaScript
434 lines
27 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, requestInterceptor } = require('jsdom');
|
|
const { pathToFileURL } = require('node:url');
|
|
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 png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=';
|
|
const quiet = { log() {}, warn() {}, error() {}, info() {} };
|
|
|
|
function server(options = {}) {
|
|
const calls = { ai: [], search: [], writes: [], images: [], health: [] };
|
|
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.image_behavior') return options.imageBehavior ?? null;
|
|
return options.model || null;
|
|
},
|
|
async get(sql, params) {
|
|
if (sql.includes('COUNT')) return { cnt: 0 };
|
|
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 }; }
|
|
};
|
|
const ai = async (messages, settings) => {
|
|
calls.ai.push({ messages, settings });
|
|
return { content: options.emptyAI ? '' : 'Complete supported answer. [1]', finishReason: Object.hasOwn(options, 'finishReason') ? options.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': options.images || { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue(owner, workflow, input) {
|
|
calls.images.push({ prompt: require('../src/utils/clinicalPrompts').imagePromptForCanvas(input.prompt, options.imageBehavior) + '\nRequested layout: auto.' });
|
|
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.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,
|
|
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('conversation budget validates exact boundaries, Unicode, malformed roles and configuration', () => {
|
|
assert.equal(policy.conversationLimit(null), 120000);
|
|
for (const value of ['oops', '999', '1000001', '1.5', NaN, false, [1000], {}]) assert.throws(() => policy.conversationLimit(value));
|
|
const history = [{ role: 'user', content: '😀\n'.repeat(333) }];
|
|
assert.equal(policy.checkConversation(history, 'x', 1000).budget.used, 1000);
|
|
assert.throws(() => policy.checkConversation(history, 'xx', 1000), error => error.statusCode === 413);
|
|
for (const history of [null, {}, [{ role: 'system', content: 'x' }], [{ role: 'user', content: 123 }]]) {
|
|
assert.throws(() => policy.checkConversation(history, 'x', 1000), error => error.statusCode === 400);
|
|
}
|
|
});
|
|
|
|
test('real chat and stream endpoints reject before all paid/retrieval calls; invalid ENV closes access', async () => {
|
|
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
|
|
const app = server({ limit: '1000' });
|
|
const result = await app.request('post', endpoint, { message: 'hello', history: [{ role: 'user', content: 'x'.repeat(1001) }] });
|
|
assert.equal(result.statusCode, 413);
|
|
assert.equal(result.body.code, 'CONVERSATION_LIMIT');
|
|
assert.equal(result.events, '');
|
|
assert.equal(app.calls.ai.length + app.calls.search.length, 0);
|
|
}
|
|
for (const options of [{ limit: '999' }, { limit: 'broken' }, { limit: ' ' }]) {
|
|
const app = server(options);
|
|
const result = await app.request('post', '/clinical-assistant/chat/stream', { message: 'Question', history: [] });
|
|
assert.equal(result.statusCode, 503);
|
|
assert.equal(app.calls.ai.length + app.calls.search.length, 0);
|
|
assert.doesNotMatch(JSON.stringify(result.body), /private diagnostic/);
|
|
}
|
|
});
|
|
|
|
test('real accepted route retains early turns and late corrections in rewrite and answer, with fresh retrieval', async () => {
|
|
const app = server();
|
|
const history = Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + '\n' }));
|
|
history[0].content = 'Original age: 3 years.\n' + 'detail '.repeat(250) + '\nCORRECTION: age 3 months, not years.\n| Dose | Unit |\n|---|---|\n| 0.25 | mg/kg |';
|
|
const result = await app.request('post', '/clinical-assistant/chat', { message: 'What about monitoring?', history });
|
|
assert.equal(result.statusCode, 200);
|
|
assert.equal(app.calls.search.length, 1);
|
|
assert.equal(app.calls.ai.length, 2);
|
|
for (const call of app.calls.ai) {
|
|
assert.ok(call.messages[1].content.includes(history[0].content));
|
|
assert.ok(call.messages[1].content.includes('Turn 11'));
|
|
}
|
|
assert.match(app.calls.ai[1].messages[1].content, /prior AI output is not evidence/);
|
|
const second = await app.request('post', '/clinical-assistant/chat', { message: 'Explain the differential diagnosis and diagnostic workup in detail for this presentation.', history });
|
|
assert.equal(second.statusCode, 200);
|
|
assert.equal(app.calls.search.length, 2);
|
|
const long = await server({ noSources: true }).request('post', '/clinical-assistant/chat', { message: 'x'.repeat(4001), history: [] });
|
|
assert.equal(long.statusCode, 200, 'The old independent 4,000-character clipping boundary is gone');
|
|
});
|
|
|
|
test('real save/reopen keeps 101 turns, Markdown, Unicode and full source metadata; the sidebar image persists', async () => {
|
|
const app = server();
|
|
const content = ' Preserve indent\n\n| Item | Unit |\n|---|---|\n| 0.25 | mg/kg |\n' + '保留'.repeat(6500);
|
|
const sources = Array.from({ length: 31 }, (_, index) => ({ number: index + 1, title: 'Title ' + index, excerpt: content, resource: 'source-' + index, page: index + 1 }));
|
|
const messages = Array.from({ length: 101 }, (_, index) => ({ role: index % 2 ? 'assistant' : 'user', content: index === 0 ? content : 'Turn ' + index, sources: index === 1 ? sources : [] }));
|
|
const body = { messages, sources, lastAnswer: content, generatedImage: png };
|
|
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)), messages);
|
|
assert.equal(reopened.body.chat.payload.lastAnswer, content);
|
|
assert.equal(reopened.body.chat.payload.generatedImage, png, 'The sidebar image persists with the chat');
|
|
assert.equal(reopened.body.chat.payload.sources.length, 31);
|
|
assert.equal((await app.request('get', '/clinical-assistant/chats/:id', {}, 8)).statusCode, 404);
|
|
const oversized = await app.request('post', '/clinical-assistant/chats', { messages: [{ role: 'user', content: 'x'.repeat(policy.MAX_SAVED_CHAT_BYTES) }] });
|
|
assert.equal(oversized.statusCode, 413);
|
|
const invalidImage = await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage: 'data:image/svg+xml;base64,PHN2Zz4=' });
|
|
assert.equal(invalidImage.statusCode, 400);
|
|
for (const generatedImage of ['data:image/png;base64,SGVsbG8=', png.replace('image/png', 'image/jpeg')]) {
|
|
assert.equal((await app.request('post', '/clinical-assistant/chats', { messages: [], generatedImage })).statusCode, 400);
|
|
}
|
|
assert.equal(app.calls.writes.length, 1, 'Rejected saves must not insert truncated records');
|
|
});
|
|
|
|
test('handoff route is gone; save/download wording covers the budget limit', async () => {
|
|
const app = server();
|
|
await assert.rejects(app.request('post', '/clinical-assistant/handoff', { history: [{ role: 'user', content: 'Known facts.' }] }), /route/, 'route layer no longer exists');
|
|
assert.throws(() => policy.checkConversation([{ role: 'user', content: 'x' }], 'y'.repeat(1000), 1000), error => error.statusCode === 413 && !/handoff/i.test(error.message));
|
|
});
|
|
|
|
function browserUI(options = {}) {
|
|
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', {
|
|
url: 'https://example.test/',
|
|
resources: { interceptors: [requestInterceptor(request => {
|
|
assert.equal(new URL(request.url).pathname, '/css/assistant.css');
|
|
return new Response(read('public/css/assistant.css'), { headers: { 'Content-Type': 'text/css' } });
|
|
})] }
|
|
});
|
|
const calls = { stream: [], save: [] };
|
|
const escapeHtml = text => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const context = {
|
|
window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
|
|
console: quiet, AbortController, TextDecoder, TextEncoder, URL, Blob, crypto: require("node:crypto").webcrypto,
|
|
setTimeout() {}, showToast() {}, escapeHtml, escapeAttr: escapeHtml,
|
|
renderAssistantMarkdown: text => escapeHtml(text), renderSourcesList: () => '', ...options.renderers, EMPTY_PROMPT_SETS: [[]],
|
|
createAssistantExporter: () => ({ invalidate() {}, exportAnswerPdf() {} }),
|
|
createAssistantImageStore: () => ({ renderGeneratedImage: src => '<img src="' + src + '">', clear() {} }),
|
|
isImageRequest: () => false,
|
|
fetchAssistantStatus: async () => ({ success: true, conversationChars: options.limit || 120000 }),
|
|
fetchAssistantExamples: async () => ({}), fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
|
|
openAssistantStream: async payload => {
|
|
calls.stream.push(payload);
|
|
if (options.stream) return options.stream(payload);
|
|
return new Response('event: done\ndata: ' + JSON.stringify({ success: true, answer: 'Complete response.', sources: [] }) + '\n\n');
|
|
},
|
|
saveAssistantChat: async payload => { calls.save.push(payload); return { success: true }; }
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(read('public/js/clinicalAssistant.js').replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, ''), context);
|
|
context.bindEvents();
|
|
context.conversationChars = options.limit || 120000;
|
|
return { context, document: dom.window.document, calls, dom };
|
|
}
|
|
|
|
test('actual UI blocks over-budget input without clearing draft/history', async () => {
|
|
const ui = browserUI({ limit: 1000 });
|
|
ui.context.restoreSavedChat({ messages: [{ role: 'user', content: 'x'.repeat(999) }] });
|
|
ui.document.getElementById('assistant-input').value = 'xx';
|
|
await ui.context.onAsk();
|
|
assert.equal(ui.calls.stream.length, 0);
|
|
assert.equal(ui.context.messages.length, 1);
|
|
assert.equal(ui.document.getElementById('assistant-input').value, 'xx');
|
|
assert.equal(ui.document.getElementById('assistant-context-warning').hidden, false);
|
|
ui.dom.window.close();
|
|
});
|
|
|
|
test('actual UI sends complete prior history exactly once and saves the sidebar image', async () => {
|
|
const ui = browserUI();
|
|
const messages = Array.from({ length: 20 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: 'Turn ' + i + (i === 0 ? 'x'.repeat(1500) : ''), sources: [] }));
|
|
ui.context.restoreSavedChat({ messages, lastAnswer: 'Previous answer.', generatedImage: png });
|
|
ui.document.getElementById('assistant-input').value = 'New question';
|
|
await ui.context.onAsk();
|
|
assert.equal(ui.calls.stream[0].history.length, 20);
|
|
assert.equal(ui.calls.stream[0].history[0].content, messages[0].content);
|
|
assert.equal(ui.context.messages.length, 22);
|
|
assert.equal(ui.context.messages[20].content, 'New question');
|
|
assert.equal(ui.document.getElementById('assistant-input').value, '');
|
|
ui.context.performAutosave();
|
|
assert.equal(ui.calls.save[0].generatedImage, png);
|
|
assert.equal(ui.calls.save[0].messages.length, 22);
|
|
assert.equal(ui.context.messages.length, 22);
|
|
ui.dom.window.close();
|
|
});
|
|
|
|
test('actual UI preserves draft on authoritative server rejection and ignores a late cancelled response', async () => {
|
|
const ui = browserUI({ stream: () => new Response(JSON.stringify({ error: 'Limit reached', code: 'CONVERSATION_LIMIT', budget: { limit: 1000 } }), { status: 413 }) });
|
|
ui.document.getElementById('assistant-input').value = 'Draft must survive';
|
|
await ui.context.onAsk();
|
|
assert.equal(ui.context.messages.length, 0);
|
|
assert.equal(ui.document.getElementById('assistant-input').value, 'Draft must survive');
|
|
assert.equal(ui.context.conversationChars, 1000);
|
|
ui.dom.window.close();
|
|
let release;
|
|
const delayed = browserUI({ stream: () => new Promise(resolve => { release = resolve; }) });
|
|
delayed.document.getElementById('assistant-input').value = 'Pending question';
|
|
const request = delayed.context.onAsk();
|
|
delayed.context.clearConversation();
|
|
release(new Response('event: done\ndata: {"success":true,"answer":"Old answer"}\n\n'));
|
|
await request;
|
|
assert.equal(delayed.context.messages.length, 0);
|
|
assert.doesNotMatch(delayed.document.getElementById('assistant-messages').textContent, /Old answer/);
|
|
delayed.dom.window.close();
|
|
});
|
|
|
|
test('dose follow-up rewrites from the drug/diagnosis AND a separate late age correction', async () => {
|
|
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
|
|
const app = server();
|
|
const history = [
|
|
{ role: 'user', content: 'Discuss acyclovir for suspected neonatal herpes in a three-month-old.' },
|
|
{ role: 'assistant', content: 'Confirm the age before deciding on therapy.' },
|
|
{ role: 'user', content: 'Correction: three weeks, not three months.' }
|
|
];
|
|
const response = await app.request('post', endpoint, { history, message: 'dose?' });
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(app.calls.ai.length, 2, 'Rewrite must not shortcut to only the latest turn');
|
|
for (const turn of history) assert.ok(app.calls.ai[0].messages[1].content.includes(turn.content));
|
|
assert.ok(app.calls.ai[0].messages[1].content.includes('dose?'));
|
|
assert.equal(app.calls.search[0], 'Complete supported answer. [1]', 'Retrieval uses the full-history rewrite result');
|
|
}
|
|
});
|
|
|
|
test('real save/reopen and UI renderer contain malicious legacy numbers without changing citation identity', async () => {
|
|
const renderers = {
|
|
...await import(pathToFileURL(path.join(root, 'public/js/assistant/citations.js')).href),
|
|
...await import(pathToFileURL(path.join(root, 'public/js/assistant/sources.js')).href)
|
|
};
|
|
const ui = browserUI({ renderers });
|
|
ui.dom.window.DOMPurify = require('dompurify')(ui.dom.window);
|
|
const app = server();
|
|
const malicious = '1"><img src=x onerror=alert(1)><span data-injected="yes';
|
|
const sources = [{ number: '1', title: 'Valid legacy string' }, { number: malicious, title: 'Legacy markup' }];
|
|
const body = { messages: [{ role: 'assistant', content: 'Retained citation [1].', sources }], sources };
|
|
assert.equal((await app.request('post', '/clinical-assistant/chats', body)).statusCode, 200);
|
|
const reopened = await app.request('get', '/clinical-assistant/chats/:id', {});
|
|
assert.equal(reopened.statusCode, 200);
|
|
const payload = reopened.body.chat.payload;
|
|
assert.equal(payload.sources[0].number, '1');
|
|
assert.equal(payload.sources[1].number, malicious);
|
|
assert.equal(payload.messages[0].sources[1].number, malicious);
|
|
ui.context.restoreSavedChat(payload);
|
|
const cards = ui.document.querySelectorAll('.assistant-source');
|
|
assert.equal(cards.length, 2);
|
|
assert.equal(cards[1].id, 'assistant-source-' + malicious);
|
|
assert.equal(cards[1].querySelector('strong').textContent, '[' + malicious + '] Legacy markup');
|
|
assert.equal(ui.document.querySelector('img, script, [onerror], [data-injected]'), null);
|
|
assert.equal(ui.document.querySelector('.assistant-cite').getAttribute('href'), '#assistant-source-1');
|
|
assert.equal(ui.document.getElementById('assistant-source-1'), cards[0]);
|
|
await new Promise(resolve => ui.dom.window.addEventListener('load', resolve, { once: true }));
|
|
assert.equal(ui.dom.window.getComputedStyle(ui.document.getElementById('assistant-messages')).maxHeight, '65vh');
|
|
ui.dom.window.close();
|
|
});
|
|
|
|
function directAdapter(provider, mode, reason) {
|
|
const responses = [];
|
|
const sdkCalls = [];
|
|
class InvokeModelCommand { constructor(input) { this.input = input; } }
|
|
class ConverseCommand { constructor(input) { this.input = input; } }
|
|
const mocks = {
|
|
openai: { OpenAI: class { constructor() { throw Error('Unexpected OpenAI client'); } } },
|
|
'./models': { ...require('../src/utils/models'), async getAllowedModelIds() { return new Set(['anthropic.claude-synthetic', 'amazon/nova-lite', 'google/gemini-2.5-flash']); } },
|
|
'./generationOptions': require('../src/utils/generationOptions'),
|
|
'./logger': { apiCall() {}, error() {} },
|
|
'../db/database': { async getSetting() { return null; } },
|
|
'@aws-sdk/client-bedrock-runtime': {
|
|
InvokeModelCommand, ConverseCommand,
|
|
BedrockRuntimeClient: class {
|
|
async send(command) {
|
|
sdkCalls.push(command);
|
|
if (mode === 'invoke') {
|
|
assert.ok(command instanceof InvokeModelCommand);
|
|
return { body: Buffer.from(JSON.stringify({ content: [{ type: 'text', text: 'Synthetic handoff.' }], stop_reason: reason })) };
|
|
}
|
|
assert.ok(command instanceof ConverseCommand);
|
|
return { output: { message: { content: [{ text: 'Synthetic handoff.' }] } }, stopReason: reason };
|
|
}
|
|
}
|
|
},
|
|
'@google-cloud/vertexai': {
|
|
VertexAI: class {
|
|
getGenerativeModel() {
|
|
return { async generateContent(request) {
|
|
sdkCalls.push(request);
|
|
return { response: { candidates: [{ content: { parts: [{ text: 'Synthetic handoff.' }] }, finishReason: reason }] } };
|
|
} };
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const module = { exports: {} };
|
|
vm.runInNewContext(read('src/utils/ai.js'), {
|
|
module, exports: module.exports, console: quiet, TextDecoder,
|
|
process: { env: provider === 'bedrock' ? { AI_PROVIDER: provider, AWS_BEDROCK_REGION: 'synthetic' } : { AI_PROVIDER: provider, GOOGLE_VERTEX_PROJECT: 'synthetic' } },
|
|
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected adapter import: ' + name); return mocks[name]; }
|
|
});
|
|
return {
|
|
responses, sdkCalls,
|
|
async callAI(...args) { const result = await module.exports.callAI(...args); responses.push(result); return result; }
|
|
};
|
|
}
|
|
|
|
test('actual direct AI adapters propagate completion status without any route (fake SDK only)', async () => {
|
|
for (const [provider, mode, model] of [
|
|
['bedrock', 'invoke', 'anthropic.claude-synthetic'],
|
|
['bedrock', 'converse', 'amazon/nova-lite'],
|
|
['vertex', 'vertex', 'google/gemini-2.5-flash']
|
|
]) {
|
|
for (const [reason, normalized] of [
|
|
['max_tokens', 'length'], ['MAX_TOKENS', 'length'],
|
|
['end_turn', 'stop'], ['stop_sequence', 'stop'], ['stop', 'stop'], ['STOP', 'stop'],
|
|
['content_filtered', 'content_filtered'], ['SAFETY', 'SAFETY'], ['tool_use', 'tool_use'],
|
|
['future_status', 'future_status'], ['', ''], [null, null], [undefined, null]
|
|
]) {
|
|
const adapter = directAdapter(provider, mode, reason);
|
|
await adapter.callAI([{ role: 'user', content: 'Synthetic facts.' }], { model });
|
|
assert.equal(adapter.sdkCalls.length, 1, provider + '/' + mode + '/' + reason);
|
|
assert.equal(adapter.responses[0].finishReason, normalized);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('ENV/default metadata and exact UTF16 boundary ignore legacy DB budget and DB failures', async () => {
|
|
for (const [limit, source, expected] of [[undefined, 'default', 120000], ['', 'default', 120000], ['1000', 'environment', 1000], ['1000000', 'environment', 1000000]]) {
|
|
const app = server({ limit, legacyLimit: '999999', dbError: true });
|
|
const status = await app.request('get', '/clinical-assistant/status');
|
|
assert.equal(status.statusCode, 200);
|
|
assert.equal(status.body.conversationChars, expected);
|
|
assert.equal(status.body.conversationSource, source);
|
|
assert.equal(status.body.conversationEnv, 'CLINICAL_ASSISTANT_CONVERSATION_CHARS');
|
|
assert.equal(status.body.conversationMeasure, 'UTF-16 code units');
|
|
assert.equal(status.body.conversationUnit, 'characters');
|
|
}
|
|
const invalid = server({ limit: 'invalid' });
|
|
assert.equal((await invalid.request('get', '/clinical-assistant/status')).statusCode, 503);
|
|
assert.equal(invalid.calls.health.length, 0);
|
|
for (const endpoint of ['/clinical-assistant/chat', '/clinical-assistant/chat/stream']) {
|
|
const app = server({ limit: '1000', legacyLimit: '1' });
|
|
const history = [{ role: 'user', content: '😀'.repeat(499) }];
|
|
assert.equal((await app.request('post', endpoint, { history, message: '😀' })).statusCode, 200);
|
|
assert.equal(app.calls.search.length, 1);
|
|
assert.equal((await app.request('post', endpoint, { history, message: '😀x' })).statusCode, 413);
|
|
assert.equal(app.calls.search.length, 1);
|
|
}
|
|
});
|
|
|
|
test('both actual image routes delegate to the shared pipeline with full input (poster snapshot covered in PG integration)', async () => {
|
|
const prompts = require('../src/utils/clinicalPrompts');
|
|
for (const imageBehavior of [undefined, ' Synthetic override.', 'Override without leading space.']) {
|
|
const app = server({ imageBehavior });
|
|
for (const endpoint of ['/clinical-assistant/image', '/clinical-assistant/image/jobs']) {
|
|
const response = await app.request('post', endpoint, { prompt: ' flowchart comparison ' });
|
|
assert.equal(response.statusCode, 200);
|
|
for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve));
|
|
const payload = app.calls.images.at(-1);
|
|
assert.equal(payload.prompt, prompts.imagePromptForCanvas(' flowchart comparison ', imageBehavior) + '\nRequested layout: auto.');
|
|
assert.match(payload.prompt, /tall portrait layout.*wide landscape layout/);
|
|
assert.ok(payload.prompt.startsWith(' flowchart comparison '));
|
|
if (imageBehavior) assert.doesNotMatch(payload.prompt, /single complete medical teaching poster/);
|
|
}
|
|
assert.equal(app.calls.images.length, 2);
|
|
}
|
|
});
|
|
|
|
test('both actual image routes use the editable poster instruction before unchanged layout suffixes', async () => {
|
|
const images = require('../src/utils/generatedImages');
|
|
const prompts = require('../src/utils/clinicalPrompts');
|
|
for (const behavior of [undefined, ' Synthetic override.', 'Override without leading space.']) {
|
|
const snapshots = [];
|
|
const real = images.createImageService({
|
|
db: { query: async () => ({ rows: [{ key: 'clinical_assistant.image_behavior', value: behavior, revision: 7 }] }) },
|
|
encryption: {}, generate: async () => { throw new Error('No provider allowed in this check'); }
|
|
});
|
|
const app = server({ images: { ...images, service: () => ({
|
|
async enqueue(owner, workflow, input) {
|
|
assert.equal(workflow, 'clinical_assistant');
|
|
snapshots.push(await real.snapshot(workflow, input));
|
|
return { success: true, jobId: 'synthetic', status: 'pending' };
|
|
},
|
|
async get() { return { success: true, status: 'done', jobId: 'synthetic' }; }
|
|
}) } });
|
|
for (const path of ['/clinical-assistant/image', '/clinical-assistant/image/jobs']) {
|
|
const response = await app.request('post', path, { prompt: ' flowchart comparison ' });
|
|
assert.equal(response.statusCode, 200);
|
|
const snapshot = snapshots.at(-1);
|
|
const prefix = prompts.imagePromptForCanvas('Original image request:\n flowchart comparison ', behavior) + '\nRequested layout: auto.';
|
|
assert.ok(snapshot.rendered.startsWith(prefix));
|
|
assert.match(snapshot.rendered, /tall portrait layout.*wide landscape layout/);
|
|
assert.ok(snapshot.rendered.endsWith(images.IMAGE_OUTPUT_RULE));
|
|
assert.equal(snapshot.revision, 7);
|
|
if (behavior) assert.doesNotMatch(snapshot.rendered, /single complete medical teaching poster/);
|
|
}
|
|
assert.equal(snapshots.length, 2);
|
|
}
|
|
});
|