A marker names a source by the `number` dedupeSources assigns server-side.
Rendering looked it up as sources[n - 1], which works only while the array
order and the numbers agree. Nothing breaks that today, but it is an implicit
contract across a network boundary: any later filtering or reordering of the
list — hiding low-score sources, say — would point citations at the wrong
source silently, which is worse than not linking at all.
Matching on the number cannot drift. Positional lookup remains as the fallback
for a list whose entries carry no number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Generated assets are served `private, no-store`, which is right for a clinical
app on a shared workstation — but it meant every gallery render re-downloaded
every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px
tiles, on every open of the Create image popup.
Two changes, both reusable anywhere in the app:
- A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to
disk and the no-store posture is unchanged. Entries are keyed by owner ticket
as well as asset, and cleared when the account boundary moves, so one account
can never read another's bytes out of memory. Concurrent tiles asking for the
same asset share one request rather than racing.
- Any img carrying data-image-thumb gets a downscaled copy instead of the
original, so a 56px tile no longer decodes a 300kB image. The gallery asks for
256px and the in-chat preview for 640px; opening the full view still gets the
original. Browsers without OffscreenCanvas, and any decode failure, fall back
to the full image rather than showing nothing.
This does not reduce the first fetch. Serving genuinely smaller bytes needs
server-side resizing, which needs an image library this project does not carry —
worth a deliberate decision rather than adding a native dependency in passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
Multimodal removal
The multimodal path called nc_multimodal_search against a second hardcoded
collection whose embedding service (multimodal-embeddings:7999) was never
deployed and ENABLE_MULTIMODAL_RAG has always been false, so it only ever logged
"multimodal search skipped". Removed rather than left as dead weight:
- clinicalRetrieval: normalizeMcpMultimodalResponse, isVisualSourceQuery,
isRadiologyQuery, buildMultimodalSearchQuery, classifyAndRerankMultimodalResults,
selectMultimodalResults, visualIntent, visualMetadataScore,
shouldRejectVisualSource, allowsFrontMatterQuery, looksLikeFrontMatterPage,
looksLikeTextOnlyPage and MULTIMODAL_CANDIDATE_LIMIT (~140 lines).
- clinicalMcpClient: multimodalSearch.
- The route's visual/text slot split is gone; the whole search limit is text.
- The "[visual PDF page match]" prompt label and the "visual PDF page" source
badge are gone with it.
Adding models
Model availability could only be ticked from what the gateway advertised, so an
admin could never offer a model discovery did not list. Each list now has a text
field: a typed id joins the same checkbox list, is enabled by default, is
de-duplicated, and persists through the normal allowed_models save.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
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
Reverts 126d7928 and 2d292d12 in full. The markdown normaliser and the trailing
emphasis and dollar handling return to exactly the state tagged
pre-citation-fixes-20260828, which has been rendering acceptably in use.
The one change kept is mermaid. DOMPurify 3.1.6 strips an attribute whose value
contains "-->", and every mermaid flowchart contains one, so the sanitiser
removed data-mermaid and querySelectorAll('[data-mermaid]') never matched: the
diagram sat on "Rendering graph..." forever. Reproduced against the exact pinned
build from cdnjs; sequence diagrams using "->>" were unaffected, which is why
only flowcharts failed.
The diagram source is now percent-encoded into the attribute and decoded when
read, so no arrow ever appears in an attribute value. Nothing about the
sanitiser configuration changes and no markup is newly allowed. The reader
tolerates an unencoded value, so anything already in flight still renders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The earlier fix corrected two of the repair rules and left the rest with the same
flaw, and the punctuation boundary it introduced let the table case back in. A
review found the remainder.
Every repair rule inserted newlines with no idea whether it was inside a markdown
table row. A dose row such as "| Dexamethasone | 0.6 mg/kg PO (max 16 mg) - single
dose | 1 |" was split mid-row, which drops that row's citation and every row
below it out of the table. A "#" column destroyed the table outright. The rules
now skip table rows, dividers and headers entirely.
Ranges were still split in three other places: in a heading, so "### Dexamethasone
0.15 - 0.6 mg/kg" rendered as a heading reading "Dexamethasone 0.15"; after a
citation; and beside a hash, where "Room # 4" became a heading. A hyphen now
starts a list item only when a number does not follow, and a run-together heading
is recognised by the capital letter after the hashes rather than by position.
Adjacent citation merging could also cross a paragraph break, turning "[1]\n\n[2]
Vancomycin only if MRSA" into "[1, 2]Vancomycin only if MRSA" -- joining two
paragraphs and moving a citation onto a claim it never supported. Merging is now
limited to citations on the same line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three rendering defects in the clinical assistant, all confirmed by running the
code rather than reading it.
A spaced hyphen anywhere in a sentence was rewritten as a list item, so every
numeric range written that way was split in two:
"Give dexamethasone 0.15 - 0.6 mg/kg orally."
-> "Give dexamethasone 0.15\n- 0.6 mg/kg orally."
which renders as a truncated sentence followed by a bullet, and a dose range
therefore reads as a different dose. The same applied to SpO2 targets, pH ranges
and age ranges. A hyphen now starts a list item only at the beginning of a line
or after sentence punctuation, which still catches the case the rule was written
for.
An answer ending in a bold phrase lost its closing marker, because trailing
emphasis was stripped unconditionally and the strip ran twice. Only an unpaired
marker is removed now.
Inline maths swallowed dollar amounts: "Costs $5 to $10 per dose" rendered the
text between the signs as an equation and dropped both signs. A $...$ span is
now treated as maths only when it contains something mathematical, so subscripts
and fractions still render.
Citation handling is unchanged and covered by the same tests: clusters still
merge and sort, unknown source numbers stay literal, links still resolve by index,
and bare numbers in a table's Source column still become bracketed tokens.
The state before these fixes is tagged pre-citation-fixes-20260828.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>