fix: translate as HTML so tables and emphasis survive; stop repeat image generation

Translation formatting
LibreTranslate's text mode destroys markdown syntax. Verified against the live
container: table pipes come back as "←", the |---| delimiter row is translated
as prose ("Silencio."), and "**bold**" returns as "** bold**" which no longer
renders. Its html mode leaves tags — and bare [n] markers — completely intact.
Messages and the patient take home are now rendered to HTML, simplified (maths
and UI chrome flattened to text), and translated as HTML. Citation chips are
re-linked from the returned markers afterwards, which is the step the original
html path was missing. A text-mode fallback remains for builds that reject html.

Repeat image generation
Typing "Окей" or "Nice" after an image turn produced another image every time:
the model saw its own "I'll generate an educational image…" in the history and
repeated it. Recognising acknowledgements in every language is not possible, so
the rule is inverted — a short follow-up (<=3 words) that mentions nothing about
a picture does not get the image tool offered at all when the previous assistant
turn produced an image. Terse repeat requests ("again", "ещё", "another one")
still work. The worst case is that a terse question is answered in text.

In-chat images
Generated images render as a 320x240 thumbnail instead of filling the bubble,
and the image itself opens the full-resolution preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
This commit is contained in:
Daniel 2026-09-09 18:49:58 +02:00
parent 4d92488f0c
commit d04a3fe53b
8 changed files with 209 additions and 54 deletions

View file

@ -121,8 +121,11 @@
/* Sources: a full-page right column; the panel fills the height and scrolls */
.assistant-side { display:flex; flex-direction:column; gap:12px; height:100%; min-height:0; }
.assistant-side .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.assistant-generated-image { display:grid; gap:8px; }
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-generated-image { display:grid; gap:8px; justify-items:start; }
/* Thumbnail in the transcript; Preview (or clicking it) opens full resolution. */
.assistant-generated-image img { width:auto; max-width:min(100%,320px); max-height:240px; object-fit:contain; border-radius:10px; border:1px solid var(--g200); background:white; cursor:zoom-in; }
.assistant-generated-image img:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }
@media (max-width:960px) { .assistant-generated-image img { max-width:100%; max-height:200px; } }
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-image-preview-open { overflow:hidden; }
.assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; }

View file

@ -82,7 +82,7 @@ export function renderAssistantMarkdown(md, sources, options) {
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
function wrapTables(html) {
export function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table$1>')
.replace(/<\/table>/g, '</table></div>');

View file

@ -10,7 +10,10 @@ export function createAssistantImageStore() {
function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = { src: src, downloadUrl: downloadUrl || (assetPath(src) ? src + '?download=1' : '') };
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
// The in-chat image is a thumbnail; full resolution is one click away, so a
// long answer is not pushed off the screen by the picture that illustrates it.
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') +
'" data-assistant-open-image="' + escapeAttr(id) + '" title="Click to view full resolution" tabindex="0" role="button">' +
'<div class="assistant-image-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-download-image="' + escapeAttr(id) + '"><i class="fas fa-download"></i> Download</button>' +

View file

@ -4,7 +4,7 @@
// server can call native MCP directly without routing through mcpo.
// ============================================================
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl } from './assistant/citations.js';
import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl, wrapTables } from './assistant/citations.js';
import { renderSourcesList } from './assistant/sources.js';
import { createAssistantExporter } from './assistant/export.js';
import { createAssistantImageStore } from './assistant/images.js';
@ -1506,13 +1506,24 @@ import {
// ── Patient take home: plain-language summary + copy/export/email ────
var takehomeBusy = false;
var takehomeText = ''; // canonical original, never overwritten
var takehomeTranslated = ''; // current translation, '' when showing the original
var takehomeText = ''; // canonical original markdown, never overwritten
var takehomeTranslatedHtml = ''; // translated HTML, '' when showing the original
var takehomeLang = '';
// Copy / Export / Email must hand over what the parent is actually reading.
// The take home is translated as HTML for the same reason chat messages are:
// LibreTranslate's text mode destroys tables and emphasis markers.
function takehomeVisibleHtml() {
return takehomeTranslatedHtml
? wrapTables(sanitize(takehomeTranslatedHtml))
: renderMarkdown(takehomeText, [], {});
}
// Copy / Export / Email must hand over what the caregiver is actually reading.
function takehomeVisibleText() {
return takehomeTranslated || takehomeText;
if (!takehomeTranslatedHtml) return takehomeText;
var holder = document.createElement('div');
holder.innerHTML = sanitize(takehomeTranslatedHtml);
return String(holder.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
}
function openPatientTakehome() {
@ -1524,7 +1535,7 @@ import {
closePatientTakehomeModal();
takehomeBusy = true;
takehomeText = '';
takehomeTranslated = '';
takehomeTranslatedHtml = '';
takehomeLang = '';
var modal = document.createElement('div');
modal.className = 'assistant-takehome-modal';
@ -1560,7 +1571,7 @@ import {
if (result) {
// Same universal markdown renderer as chat bubbles; raw text stays
// canonical for Copy/Export/email.
result.innerHTML = renderMarkdown(takehomeText, [], {});
result.innerHTML = takehomeVisibleHtml();
result.classList.remove('hidden');
}
if (actions) actions.classList.remove('hidden');
@ -1584,7 +1595,7 @@ import {
function renderTakehomeBody() {
var result = document.querySelector('#assistant-takehome-modal .assistant-takehome-result');
if (result) result.innerHTML = renderMarkdown(takehomeVisibleText(), [], {});
if (result) result.innerHTML = takehomeVisibleHtml();
}
function translateTakehome(target) {
@ -1592,16 +1603,16 @@ import {
if (!takehomeText) return;
if (!target) { // back to the original
takehomeLang = '';
takehomeTranslated = '';
takehomeTranslatedHtml = '';
renderTakehomeBody();
return;
}
if (select) select.disabled = true;
translateAssistantMessage(takehomeText, target, translateProvider, 'text')
translateAssistantMessage(simplifyHtmlForTranslation(renderMarkdown(takehomeText, [], {})), target, translateProvider, 'html')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
takehomeLang = target;
takehomeTranslated = String(data.translated || '');
takehomeTranslatedHtml = String(data.translated || '');
renderTakehomeBody();
})
.catch(function(err) {
@ -1621,7 +1632,7 @@ import {
return;
}
assertSharingOwner(owner);
var html = renderMarkdown(takehomeVisibleText(), [], {});
var html = takehomeVisibleHtml();
doc.document.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Patient Take Home</title>' +
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.6;margin:32px;max-width:720px}' +
'h1{color:#0f766e;font-size:24px;margin:0 0 4px;border-bottom:2px solid #0f766e;padding-bottom:8px}' +
@ -1635,7 +1646,7 @@ import {
function closePatientTakehomeModal() {
var modal = document.getElementById('assistant-takehome-modal');
if (modal) modal.remove();
takehomeTranslated = '';
takehomeTranslatedHtml = '';
takehomeLang = '';
}
@ -1709,9 +1720,26 @@ import {
cards.forEach(function(card) { bubble.appendChild(card); });
}
// LibreTranslate mangles markdown syntax in text mode: table pipes come back as
// "←", the |---| delimiter row is translated as prose, and "**bold**" returns as
// "** bold**", which no longer renders. Its html mode leaves tags — and bare
// [n] markers — completely intact, so rendered HTML is what gets translated.
function simplifyHtmlForTranslation(html) {
if (typeof DOMParser === 'undefined') return String(html || '');
var doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
// Rendered maths and UI chrome are not prose; flatten them to their text so
// the translator cannot rearrange markup it does not own.
doc.body.querySelectorAll('.katex, mjx-container, .assistant-code-copy, .assistant-table-actions, .assistant-msg-actions, .assistant-image-card, script, style')
.forEach(function(el) { el.replaceWith(doc.createTextNode(el.textContent || '')); });
doc.body.querySelectorAll('.assistant-table-scroll').forEach(function(el) {
var table = el.querySelector('table');
if (table) el.replaceWith(table); // re-wrapped after translation
});
return doc.body.innerHTML;
}
// Citation markers must survive the round trip so the translated answer keeps
// its clickable [n] chips. Markdown itself is sent unchanged: the old scrub
// deleted ordered-list numbers, flattened tables and ate underscores.
// its clickable [n] chips.
function citationNumbersIn(text) {
var found = [];
String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
@ -1736,35 +1764,48 @@ import {
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
var sources = Array.isArray(bubble.assistantSources) ? bubble.assistantSources : [];
var expected = citationNumbersIn(raw);
translateAssistantMessage(raw, target, provider, 'text')
function showTranslation(translated) {
// Sanitize what the service returned, THEN turn the surviving [n] markers
// into the usual chips bound to this message's sources, then restore the
// scroll wrapper the simplification removed.
var html = wrapTables(renderCitationLinks(sanitize(String(translated || '')), sources, {}));
var lost = expected.filter(function(n) { return citationNumbersIn(translated).indexOf(n) === -1; });
if (lost.length) {
// Keep dropped evidence reachable rather than letting it disappear.
html += '<div class="assistant-translated-sources"><strong>' +
escapeHtml('Sources not carried into the translation') + '</strong>' +
renderCitationLinks(lost.map(function(n) { return '[' + n + ']'; }).join(' '), sources, {}) +
'</div>';
}
bubble.innerHTML = html +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
reattachImageCards(bubble, imageCards); // same nodes — status polling continues
renderEmbeddedBlocks(bubble);
}
// [n] markers are left unlinked here (empty sources) so they cross as plain
// text and can be re-linked after translation.
var sourceHtml = simplifyHtmlForTranslation(renderMarkdown(raw, [], {}));
translateAssistantMessage(sourceHtml, target, provider, 'html')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
var translated = String(data.translated || '');
// Same renderer as the original bubble: headings, lists and tables come
// back, and [n] markers become the usual clickable .assistant-cite chips
// bound to this message's sources.
var kept = citationNumbersIn(translated);
var lost = expected.filter(function(n) { return kept.indexOf(n) === -1; });
var html = renderMarkdown(translated, sources, {});
if (lost.length) {
// The translator dropped markers; keep the evidence reachable rather
// than letting the citations disappear with the formatting.
html += '<div class="assistant-translated-sources"><strong>' +
escapeHtml('Sources not carried into the translation') + '</strong>' +
renderCitationLinks(lost.map(function(n) { return '[' + n + ']'; }).join(' '), sources, {}) +
'</div>';
}
bubble.innerHTML = html +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
reattachImageCards(bubble, imageCards); // same nodes — status polling continues
renderEmbeddedBlocks(bubble);
showTranslation(data.translated);
})
.catch(function(err) {
// Put the message back exactly as it was, images included.
bubble.innerHTML = bubble.assistantOriginalHtml;
reattachImageCards(bubble, imageCards);
renderEmbeddedBlocks(bubble);
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
.catch(function() {
// Some LibreTranslate builds reject html mode. Plain text loses tables
// and emphasis, but an unformatted translation beats none.
return translateAssistantMessage(raw, target, provider, 'text')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
showTranslation(renderMarkdown(String(data.translated || ''), [], {}));
})
.catch(function(err) {
bubble.innerHTML = bubble.assistantOriginalHtml;
reattachImageCards(bubble, imageCards);
renderEmbeddedBlocks(bubble);
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
});
});
}

View file

@ -315,7 +315,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
var ai = await callAI(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
tools: imageTool.tools,
tools: prepared.withholdImageTool ? undefined : imageTool.tools,
maxTokens: 2600,
images: prepared.images
}));
@ -382,7 +382,7 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
var ai = await callAIStream(prepared.messages, assistantGenerationOptions({
model: prepared.chatModel || undefined,
temperature: 0.15,
tools: imageTool.tools,
tools: prepared.withholdImageTool ? undefined : imageTool.tools,
maxTokens: 2600,
images: prepared.images
}), function(delta) {
@ -436,6 +436,41 @@ function isExplicitImageRequest(message) {
return IMAGE_REQUEST_PATTERN.test(String(message || ''));
}
// Any noun that means "a picture", in the languages the assistant is used in.
// Latin roots cover most; the Cyrillic/CJK/Arabic forms are listed because the
// assistant is translated into them.
const IMAGE_NOUN_PATTERN = /\b(image|imagen|imagem|immagine|bild|picture|photo|foto|visual|illustration|ilustraci|illustrazione|diagram|diagrama|diagramm|figure|figura|figur|flowchart|infographic|infograf|chart|graphic|poster|schema|schéma)/i;
const IMAGE_NOUN_NON_LATIN = /(изображени|картинк|рисунок|схем|диаграмм|иллюстра|图|图像|画像|イラスト|صورة|رسم)/i;
// Words that mean "do that again" — a real, if terse, request for another image.
// \b is ASCII-only, so the non-Latin forms are matched without word boundaries.
const IMAGE_REPEAT_PATTERN = /\b(again|another|one more|redo|repeat|regenerate|otra|otro|encore|nochmal|wieder)\b/i;
const IMAGE_REPEAT_NON_LATIN = /(ещё|еще|снова|заново|もう一度|再来|مرة أخرى)/i;
function mentionsImage(message) {
var text = String(message || '');
return IMAGE_NOUN_PATTERN.test(text) || IMAGE_NOUN_NON_LATIN.test(text) ||
IMAGE_REPEAT_PATTERN.test(text) || IMAGE_REPEAT_NON_LATIN.test(text);
}
// "Окей" / "Nice" / "👍" after an image turn is an acknowledgement, not a request
// for a second image. Recognising acknowledgements in every language is not
// possible, so the rule is inverted and language-independent: a SHORT follow-up
// that says nothing about a picture never gets the image tool. The worst case is
// that a terse question is answered in text, which is what it asked for anyway.
function withholdImageTool(message, history) {
var text = String(message || '').trim();
if (!text || mentionsImage(text)) return false;
var words = text.split(/\s+/).filter(Boolean);
if (words.length > 3 || text.length > 32) return false;
var turns = Array.isArray(history) ? history : [];
for (var i = turns.length - 1; i >= 0; i--) {
if (turns[i] && turns[i].role === 'assistant') {
return isExplicitImageRequest(String(turns[i].content || ''));
}
}
return false;
}
async function dispatchImageRequestFallback(ai, prepared, req) {
if (!ai || ai.imageToolHandled || (ai.toolCalls && ai.toolCalls.length)) return ai;
if (!isExplicitImageRequest(prepared.message)) return ai;
@ -581,6 +616,7 @@ async function prepareAssistantChat(body) {
message: message,
images: images,
imageContext: generatedImages.imageContext(message, history),
withholdImageTool: withholdImageTool(message, history),
chatModel: chatModel,
imageModel: imageModel,
sources: sources,

View file

@ -18,3 +18,69 @@ test('assistant image intent still handles explicit visual requests', async () =
assert.equal(isImageRequest('show me the diagram'), true);
assert.equal(isImageRequest('create an infographic for asthma'), true);
});
// A run of image turns used to make every acknowledgement produce another image:
// the model saw its own "I'll generate an educational image…" in the history and
// repeated it for "Окей" and "Nice". The tool is withheld for short follow-ups
// that say nothing about a picture, which does not depend on recognising an
// acknowledgement in any particular language.
function imageToolGuard() {
const fs = require('node:fs');
const src = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
const start = src.indexOf('const IMAGE_REQUEST_PATTERN');
const end = src.indexOf('async function dispatchImageRequestFallback');
assert.ok(start > 0 && end > start, 'image guard block located');
return new Function(src.slice(start, end) + '; return { withholdImageTool, mentionsImage };')();
}
const afterImage = [
{ role: 'user', content: 'Can you illustrate with an image' },
{ role: 'assistant', content: "I'll generate an educational image illustrating the imaging findings of periventricular leukomalacia." }
];
const afterText = [
{ role: 'user', content: 'periventricular leukomalacia' },
{ role: 'assistant', content: 'PVL is a disorder of the periventricular white matter.' }
];
test('acknowledgements after an image turn do not get the image tool, in any language', () => {
const { withholdImageTool } = imageToolGuard();
for (const ack of ['ok', 'okay', 'Nice', 'Perfect', 'thanks!', 'Окей', 'bien', 'ç', '👍', 'got it']) {
assert.equal(withholdImageTool(ack, afterImage), true, JSON.stringify(ack) + ' must not trigger another image');
}
});
test('real image follow-ups keep the tool, including terse repeat requests', () => {
const { withholdImageTool } = imageToolGuard();
for (const ask of ['again', 'another one', 'ещё', 'redo', 'make it bigger with labels',
'now show the MRI diagram', 'can you add a figure for the cystic phase']) {
assert.equal(withholdImageTool(ask, afterImage), false, JSON.stringify(ask) + ' is a real image request');
}
});
test('the guard only applies after an image turn and never blocks a first request', () => {
const { withholdImageTool } = imageToolGuard();
assert.equal(withholdImageTool('Nice', afterText), false, 'no preceding image turn, nothing to repeat');
assert.equal(withholdImageTool('Окей', []), false, 'an empty conversation cannot be repeating an image');
assert.equal(withholdImageTool('draw me a diagram of the airway', afterText), false);
assert.equal(withholdImageTool('Explain the pathophysiology of PVL in preterm infants', afterImage), false,
'a substantive question is not a short acknowledgement');
});
test('the in-chat image is a clickable thumbnail, not a full-width picture', async () => {
const fs = require('node:fs');
const root = path.join(__dirname, '..');
const { createAssistantImageStore } = await import(pathToFileURL(path.join(root, 'public/js/assistant/images.js')).href);
const store = createAssistantImageStore({});
const html = store.renderGeneratedImage('/api/generated-images/synthetic.png', 'Generated teaching visual');
assert.match(html, /<img[^>]*data-assistant-open-image="img-\d+"/, 'the image itself opens the full-resolution preview');
assert.match(html, /<img[^>]*role="button"[^>]*>/, 'and announces itself as activatable');
assert.match(html, /data-assistant-open-image="img-\d+"><i class="fas fa-expand"><\/i> Preview/, 'the Preview button still works');
const css = fs.readFileSync(path.join(root, 'public/css/assistant.css'), 'utf8');
const rule = css.split('\n').find(line => line.startsWith('.assistant-generated-image img {'));
assert.ok(rule, 'the generated-image rule exists');
assert.doesNotMatch(rule, /width:100%/, 'no longer stretched to the bubble width');
assert.match(rule, /max-width:min\(100%,320px\)/, 'capped to a thumbnail');
assert.match(rule, /max-height:240px/);
assert.match(rule, /cursor:zoom-in/, 'the thumbnail invites the click');
});

View file

@ -181,8 +181,8 @@ test('per-message translate picker offers a provider choice and leaves the raw t
const sent = JSON.parse(translateCalls[0].options.body);
assert.equal(sent.target, 'es');
assert.equal(sent.provider, 'libretranslate');
assert.equal(sent.format, 'text');
assert.equal(sent.message, 'Chest pain in a four year old.');
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');
@ -231,9 +231,14 @@ test('a translated answer keeps its citation chips, headings, lists and tables',
await new Promise(r => setImmediate(r));
const sent = JSON.parse(app.calls.filter(x => x.url === '/api/clinical-assistant/translate')[0].options.body);
assert.equal(sent.message, raw, 'raw markdown goes to the translator — no lossy pre-scrub');
assert.match(sent.message, /^1\. Give amoxicillin/m, 'ordered-list numbering survives to the wire');
assert.match(sent.message, /\| Amoxicillin \| 90 mg\/kg\/day \|/, 'table pipes survive to the wire');
// 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');

View file

@ -205,7 +205,7 @@ function client(t, options = {}) {
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
translateAssistantMessage: (message, target, provider) => apiFetch('/api/clinical-assistant/translate', { method: 'POST', headers: {}, body: JSON.stringify({ message, target, provider }), failTranslate: options.failTranslate }).then(r => r.json()),
translateAssistantMessage: (message, target, provider, format) => apiFetch('/api/clinical-assistant/translate', { method: 'POST', headers: {}, body: JSON.stringify({ message, target, provider, format: format || 'text' }), failTranslate: options.failTranslate }).then(r => r.json()),
getAuthHeaders: () => ({ 'X-Synthetic': '1' }),
fetchSavedAssistantChat: async id => ({ success: true, chat: { id, payload: { version: 2, messages: [{ role: 'user', content: 'Old question' }] } } }),
openAssistantStream: async (payload) => {
@ -359,7 +359,8 @@ test('translating the take home rerenders it and carries into Copy, Export and E
const sent = JSON.parse(app.calls.find(c => c.url === '/api/clinical-assistant/translate').options.body);
assert.equal(sent.target, 'es');
assert.match(sent.message, /Rest\*\* and drink fluids/, 'the generated take-home text is what gets translated');
assert.equal(sent.format, 'html', 'html mode keeps emphasis and tables intact');
assert.match(sent.message, /<strong>Rest<\/strong>/, 'the generated take-home is what gets translated');
const result = modal.querySelector('.assistant-takehome-result');
assert.match(result.textContent, /\[es\]/, 'the modal shows the translation');
assert.ok(result.querySelector('strong'), 'still rendered as markdown, not escaped text');