155 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bf4f895f2c |
fix: article uploads are 10 MB, type-checked both ways, and sniffed
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 1m55s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The ceiling was 100 MB per file with ten files allowed at once, and every file is held whole in memory to be parsed — so the old limit let a single request ask for a gigabyte of heap. A source article that size is not a thing anyone uploads here. Now 10 MB, defined once and used by both the multer limit and the post-upload check. The filter accepted `allowed mime OR allowed extension`, so naming a file .pdf was enough on its own, whatever it declared — and the extension is chosen by whoever uploads. Both are required now. Neither of those sees any bytes: multer filters on the headers, before the file has arrived. verifySources() runs once the buffer exists and refuses a file whose contents are not what its type claims, using the same helper as documents, S3 uploads and assistant attachments. It runs before extraction, because an extractor handed a malformed file is where the damage would happen. The CMS screen said 100 MB and listed four of the ten accepted formats; it now says what the server actually does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
bd8e413bc7 |
fix: an assistant attachment must be the image type it claims to be
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The MIME type was taken on trust here. Anything at all could be posted as image/png: it passed the size and base64 checks, was stored in the saved chat, and was handed to a provider as a data URI. Documents and S3 uploads have always been sniffed by fileType.js; this was the one upload path that was not. Now sniffed with the same helper, so there is one idea of what a PNG looks like. A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF labelled image/png are all refused with a message that says what is wrong. What this does not claim: bytes hidden after a valid PNG header still make a valid PNG, and no sniffer can promise otherwise. The protection is that the file is never executed and never served as anything but an image. Existing fixtures used buffers of 0x07 as stand-in images, which are correctly refused now. They carry real file headers instead — a fixture should be the thing it claims to be, exactly like a real upload. Also adds the deck theme system: five palettes in assets/deck-themes.json, render_pptx.py rebinding its palette from the theme rather than hardcoding it, the theme carried on the deck and validated against the same catalogue the renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme to re-skin a stored deck with no model call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ff2b2bc9d3 |
chore: Kitten and Supertonic retired; Kokoro is the local voice
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both were removed from the gateway in favour of Kokoro, so the app's curated voice lists for them went too. Kokoro's own check no longer has to exclude them — it names its own voices and the list is open, so it accepts anything that is not another family's voice. Voices stay curated per family rather than discovered. Models are discovered from /model/info; voices are not, because no TTS provider exposes its voice list consistently, and a list guessed from a model id is how a picker ends up offering a voice the provider rejects. Tests that pinned the retired voices now pin the families that remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fce05a2749 |
fix: the active library view is unmistakable, and a leading question cannot mislead the vision model
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Documents and Images were two buttons of the same weight, which reads as two buttons rather than as a current view and an alternative. The active one now carries the surface, the weight and a hairline — keyed off aria-selected, so the visible highlight and what a screen reader announces cannot disagree. The look_at_image question is written by a model that has not seen the image, from what the user said, so it can presume something that is not there — "describe this rash" about a photograph of a drug chart. Answering the presumption would send it back as fact. The seeing model is now told the question may assume something absent, and to say so first and describe what is actually there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1f06a19007 |
feat: a text-only model can ask a model that can see; and the image regex is gone
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 |
||
|
|
03621752e8 |
feat: image fallback chains for every workflow, and a library worth looking at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**Fallbacks.** One image model meant a refusal, a rate limit or a model the gateway had since dropped ended as a missing picture. Every workflow now tries its model, then each fallback in order, stopping at the first that produces an image. Primary plus two, capped: each hop is a paid request, and a chain long enough to need a cap is long enough to surprise someone. My Resources previously had no fallback at all — only the Clinical Assistant did, and only one. That is backwards: a missing figure is most visible in a deck, where it leaves a hole in a slide. The retry rule is now a classifier that says *why*, rather than a boolean. Transient faults, a 404 for a model the gateway does not have, and a content refusal all move to the next model — a refusal because policy is a vendor decision, not a fact about the request. 401/403 stop immediately (one gateway, one set of credentials, the next model fails identically), as do 413 and any other 4xx, which are malformed everywhere. Refusals are recognised from the message: no provider sends a machine-readable reason and the status varies. Each hop re-leases the job, so a chain cannot outlive its claim and let a second worker repeat the same paid work, and the row records the model actually being paid for so a picture made by the third model is not attributed to the first. The old singular `fallback_image_model` is still read, so an existing configuration keeps working without anyone re-entering it. **Library.** Documents/Images tabs in My Resources, with a real grid: fixed aspect tiles so the rows line up whatever shape the pictures are, a source badge on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails land, and a lightbox that closes on Escape or the backdrop and restores focus. Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete behind :hover would put it out of reach on touch and keyboard. Downloads go through privateImageBlob rather than a bare `<a download href>`: a mobile client's session is a bearer token an anchor cannot send, and these assets are served no-store on purpose. The gallery lives in My Resources only. Assistant images appear in it, which was the point; the assistant page does not grow a gallery of its own, and a test asserts no assistant module lists the endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
259b4858be |
feat: an image library — every picture this account has made
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good. Until now a generated image could only be seen in the resource it was made for, and there was no way to find one again or to delete it. Library → Images is a grid of every finished image the account owns, across all three workflows, newest first. GET /api/generated-images is scoped by owner_id in the statement rather than filtered after, returns only finished jobs — an unfinished one is a broken frame in a gallery — and pages by keyset, because a gallery that grows while you scroll repeats or skips rows under OFFSET. Most of this already existed. Thumbnails were already rendered at 256 and 640 by sharp and already served by ?w=, with their own checksum so the client's tamper check passes on a derived copy; hydrateImage already handles auth, the account boundary and caching. The tiles ask for the 256px preview, so thirty of them cost a few kB each rather than thirty full-size downloads, and the prompt is decrypted for the caption because it is the only human-readable label an image has. Deleting needed new work. The storage interface had no remove at all, so a delete that dropped the row would have left the object and both previews in the bucket — paid for, and still readable by anything with credentials. Storage now removes all three keys, and the bytes go before the row: a row pointing at a missing object is a broken image in a gallery, while an object without its row is only wasted space, and unreachable storage refuses the delete outright rather than reporting a success that left the picture behind. THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift, and the drift that matters is a width that gets written and never deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
83206e907c |
fix: a second deck reused the first deck's pictures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The image job key was the request-body hash plus the figure's position — slide index for a deck, reply index for a document. Two generations from the same form produce the same body hash, so figure 4 of the second generation collided with figure 4 of the first on the unique (owner, workflow, idempotency_key). The constraint handed back the existing job, and the new deck displayed the old deck's artwork. The decks are not even the same length, so the reused picture could land on a slide about something else entirely. Keyed on what is being drawn now: the body hash stays, so submitting the identical request twice still dedupes rather than billing twice, and a hash of the prompt (plus layout and shape) is what makes two different pictures two different jobs. Same fix in deckBuild and resourceImages. Also split fileLog out of logger. logger requires the database at module load, so importing it to record a diagnostic pulls in a connection pool — wrong on its own terms, and it hung the whole test suite when imageTool started logging its refusals: a unit test that never touches a database inherited an open pool handle and never exited. logger.file now delegates to fileLog, so there is still one implementation of where a line goes and how it is redacted. With that in place, every image-tool refusal is recorded durably. There are five of them, they want five different fixes, and until now none of them left any trace once the container was replaced. Verified against a mutation: restoring the index-based key fails two of the four collision tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
94f320f140 |
fix: an empty answer from the model was the one failure never caught
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The Clinical Assistant answered "image summary" with a zero-length string, saved that as the turn, and drew it as an empty bubble. No error was raised, nothing was logged, and the empty turn stayed in the conversation history where it degrades every answer after it. Confirmed by decrypting the saved chat: the final assistant turn is content length 0. The cause is one line. finalizeAssistantAnswer only regenerates an answer that shouldRegenerateTruncatedAnswer flags, and that function opens with `if (!answer) return false` — there is no dangling conjunction to detect in a zero-length string, so empty was classified as "not truncated" and returned as a result. Every other failure mode had a path; this one had none. An empty answer is now asked for once more — a model returning nothing is usually transient — and if it comes back empty again it raises 502 `empty_answer`, which both callers already turn into a visible error. Whitespace counts as empty, and so does an answer that strips to nothing. Without a callAI to retry with it still raises rather than returning empty. Verified against a mutation: removing the new branch fails five of the six tests. This is separate from the image question in the same request. No image job was created, because "image summary" does not match the text fallback pattern — which requires a verb (create/generate/draw/…) before the noun — and the model did not call the tool. That is left alone for now: the request is genuinely ambiguous, and guessing at it is how an assistant starts making pictures nobody asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e244ee5240 |
feat: modifying a deck can see it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people actually ask for unanswerable: "that slide is too crowded", "the diagram is in the wrong place", "this one looks empty" are facts about the rendered page, not about the JSON. When a vision model is configured, modifying now renders the current deck — with its figures, unlike the review pass, which runs while they are still being drawn — and hands the model one image per slide alongside the JSON. Same pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF, pdftoppm, capped at MAX_SLIDES. The vision model then does the editing, which is a second and separately measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" — the echo reported yesterday — while openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo even without sight. A model the author picks explicitly still wins over both. The result is rendered and reviewed again. Generation-only was the old rule, on the reasoning that refining is a text edit; it is not. The edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again. The reviewer may reposition but is held to the same words, so a verification pass cannot quietly undo what was just asked for. Whether an instruction achieved anything is judged on the model's edit, before the reviewer runs, or a reviewer nudging a slide into two columns would mask an instruction that did nothing. Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a render that fails — each falls through to editing blind, and a test covers each of those paths. Verified against two mutations: keeping the text model when images are attached, and dropping the verification pass, each fail exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
5614a436be |
fix: a deck asked for a figure and never got one — three causes, one symptom
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"Include a diagram" produced decks with no picture. Three separate faults, each hiding the next, found by generating the same deck after fixing each one. First, deck generation ran on the default 4000-token budget. A deck's JSON is several times the size of the prose it holds, so a long deck came back truncated, failed to parse, and fell back to markdown — which has no way to request a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram ...]" as its first bullet. Deck generation now gets room, and the fallback says how the reply failed: empty, cut short at N characters, or not a deck. Second, the figure request sat inside the layout vocabulary, one line among forty, and the model passed over it. It goes last now, after the author's own instructions — the same placement lesson the image tool taught earlier. Third, and the one that actually mattered: image_prompt is only read on the figure and image types, so an image_prompt on a bullets slide was dropped in silence. The instruction said "add image_prompt to N slides" without saying which types carry one. It now names them, and a misplaced request is honoured rather than discarded — a slide with words becomes a figure, one without becomes a full-slide image. Image markup is also stripped wherever text enters a slide, on both paths: a described figure is not a figure, and a bullet of raw markdown is worse than no bullet. Verified end to end after: the same request produced a deck with one figure, the job completed, and the exported pptx carries one embedded image across 21 slides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
2c3fbbcf37 |
fix: decks were falling back to markdown, so no figure could ever be requested
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
A deck's JSON is several times the size of the prose it holds, and generation used the default 4000-token budget — raised for refine and for slide review, but never here. A sixteen-slide deck ran past it, came back truncated, failed to parse, and fell back to markdown. Markdown has no way to ask for a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet, above the steps it was meant to illustrate. That is why no generated deck was arriving with an image. Deck generation now gets room for a deck. The fallback also says how the reply failed — empty, cut short at N characters, or simply not a deck — because those want different fixes and "not usable" covered all three. Image markup is stripped wherever text enters a slide, on both the deck and markdown paths, since a described figure is not a figure and a bullet of raw markdown is worse than no bullet. The model is also told plainly: if a figure is wanted say so with image_prompt, and if that is not on offer, write the slide without one rather than describing the picture you would have drawn. Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT, DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores intact: the label builder replaced hyphens but not underscores, and uppercased the first letter of each word rather than normalising the case, so a SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
012346528c |
fix: generation stopped working whenever the slide reviewer was switched off
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 51s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m35s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
savedFigureIds was declared inside the review branch, so with no reviewer
configured — the default, and what everyone is running — it was undefined by the
time the INSERT stringified it. JSON.stringify(undefined) is not a string, the
column is NOT NULL, and every generation failed with "Generation failed". `var`
is function-scoped, so nothing complained until the database did.
This is the second bug of exactly this shape in this file, so the test asserts
position rather than presence: the value must be declared before both the review
and the insert read it.
Found by the logging added in the same change, which is the other half of this
commit. Every modification now says what it did:
[my-resources] refine id=29 path=deck outcome=applied 13→14 slides changed=yes
[my-resources] refine id=37 path=markdown outcome=applied 2635→3018 chars changed=yes
CHANGED=no is warn-level and deliberately shouty, because that is the failure
worth catching: the response says success either way, the row updates, and the
download is identical — which is exactly how the deck bug went unnoticed. A
refusal logs its reason. ped_ai_resource_refine_total{path,outcome} counts the
same thing over time, so "did that modification do anything" is answerable
without watching logs live.
Verified across every path rather than the one that was broken: a deck
presentation modified and exported to both pptx and docx carries the change; a
legacy presentation with no stored deck still takes the markdown path and
carries it; an article generates, modifies and exports; and a presentation
generates with the reviewer off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
4f8e686907 |
feat: record what a deck wanted and could not have
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The shape vocabulary is deliberately small, which leaves the question of what to
add next. Rather than guess, it now records demand.
Two signals, because a model asks both ways. It can say so outright —
{"kind":"unsupported","need":"a SmartArt cycle of four stages"}, which draws
nothing and is told about in the same file that validates it — or it can reach
for a kind, chart type or slide type that does not exist, which is the more
common way of asking and just as much of a signal.
Both produce a log line naming what was wanted and the topic it came up on, and
increment ped_ai_deck_vocabulary_gap_total{wanted}, so it can be counted over
time in Grafana rather than noticed once and forgotten. Deduplicated per
generation and capped at twelve: a model that asks for a hundred things it cannot
have should not write a hundred log lines. It can never fail a generation — it is
a note to whoever decides what to build next.
This is also the answer to whether to run model-authored code in a sandbox
instead. The log will say whether the gap is real. Some of it is not closeable by
any sandbox, being python-pptx's own ceiling — no SmartArt, no animations or
transitions, limited chart types — and a sandbox would only let a model write
code against the same library and hit the same wall. Documented in
docs/my-resources.md, which the in-app Docs tab serves directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
af2e09c1de |
feat: a slide can be drawn from primitives when the named layouts have no word for it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m7s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The nine layouts are a fixed vocabulary and a good default, but "lay the three severity levels out left to right with arrows between them" had no expression in them at all. A "custom" slide now carries a list of shapes: positioned text, eight autoshape families, lines, images, tables, and native PowerPoint charts — column, bar, line, pie, doughnut. Coordinates are percentages of the slide rather than EMU, because a model reasons about "the left half" and not about 12192000. Shapes draw in array order, so a later one sits on top. The model never emits Python. It names shapes and the renderer draws them: running model-authored code to lay out a slide would be an enormous amount of trust to buy a feature, on a server holding clinical data and secrets. Validation lives beside the text that teaches the vocabulary, in one file, so what the model is told about is exactly what is accepted. Kinds are an allowlist, colours must be six hex digits, coordinates are clamped inside the slide — a shape at x=95 w=30 is cut to the edge rather than drawn half off it — counts are capped, a pie is held to one series, and anything that cannot be understood is dropped. A custom slide that loses every shape becomes a plain one rather than a heading over an empty frame, and one bad shape is caught in the renderer so it cannot cost the slide it sits on. A figure on a custom slide is requested through an image shape, drawn by the same path as any other, and attached by job id. Word renders a custom slide as its words in reading order with its tables and figures — lossy, and better than dropping the slide. Verified live end to end: asked to "lay the three severity levels out left to right as coloured boxes with arrows between them", the model produced [rect arrow rect arrow rect], chose green/amber/red itself, and the rendered slide was looked at. A column chart beside its commentary renders with real axes and gridlines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
154b896d5b |
feat: Word is built by python-docx from the same typed source as the deck
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Pandoc reads markdown, so every Word export had to flatten the resource to markdown first — and a deck flattened to markdown stops being one. A comparison became two headings and two lists, a callout became bold text, and a figure became nothing at all, because markdown has nowhere to put it. src/utils/docSpec.js reduces either source to the same blocks: a stored deck where there is one, the markdown where there is not. scripts/render_docx.py draws them. A comparison comes out as a labelled two-column table, a callout as a shaded box, a table as a real table, a figure embedded at its own aspect ratio with its caption, and speaker notes as muted indented text. The deck wins over the markdown beside it, because that markdown is a serialisation of the deck and reading it instead would be reading a lossy copy of what is right there. Word now carries the figures too. The export route skipped fetching them for docx, which was correct when pandoc could not place them and wrong the moment this could. Pandoc stays installed and stays the fallback: a plainer document beats a failed download. Both renderers now share one spawn helper. Verified end to end: a deck with two figures exported as a six-page Word document with both images embedded (537KB, two files in word/media), rendered to PDF and looked at — the comparison is a labelled table, the figure sits at its true aspect ratio, and the notes read as notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
9d307fd442 |
feat: a vision model looks at the rendered deck and fixes the layout
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 37s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model that writes a deck never sees it. It cannot tell that slide four overflowed, that a nine-item list would read better in two columns, or that two labelled groups want to be a comparison — those are facts about the rendered page, not about the text. So each generated deck is now rendered to PDF through Gotenberg, rasterised to one image per slide with pdftoppm, and shown to a vision model. Off unless an administrator names a reviewer, in its own admin card because it is the one setting that spends money on every generation without a user having asked for anything. One pass, on generation only: a second pass costs as much as the first and fixes far less, and refining is a text edit. It returns a patch, not a deck. Asking for the corrected deck back put the reply in proportion to the deck rather than to the number of problems, and a fourteen-slide deck came back cut off mid-object at every output budget the provider would honour — measured twice before changing shape. The patch is better for a second reason. The reviewer names a slide and an action — two columns, one column, split after bullet N, compare with these two labels — and the server moves the text it already has. The words never pass through the model, so a review cannot reword, drop or invent a single bullet. That is a stronger guarantee than instructing it not to and checking afterwards. The check runs anyway, because a bug in applyChanges would be as bad as a model rewriting the words and worse for being trusted: body text must come out the same multiset, figures the same set, and a heading may only be reused or extended. A continuation heading is the reviewer's one piece of text and is replaced when it does not continue anything. Nothing here can fail a generation — no reviewer, an unreachable one, an unparseable reply, a deck too long to look at, or a patch that applies to nothing each return the deck that was written. Verified end to end against a deck with a deliberately overloaded slide: three slides rendered and sent, one change returned, ten bullets split into five and five under "Stepwise Management … (continued)", text intact. Left switched off; enable it under Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fa2e7523d6 |
docs: My Resources, sign-in codes, invitations, and what the image carries
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
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) Has been cancelled
Nothing documented My Resources, the slide renderer, PubMed or web search, and the authentication doc predated both sign-in codes and registration invitations. docs/my-resources.md is new and covers the feature end to end: what a resource is, where its material comes from, why both searches run in the route rather than as tools the model never called, why keyword engines get the topic while retrieval gets the instruction too, how a presentation is designed as a deck rather than written as markdown, the separate multi-image path, and what the export pipeline is made of. docs/authentication.md gains sign-in codes — storage, lifetime, reuse, supersession, guessing, and that two-factor still applies — and registration invitations, including the exact condition that decides when a code may be deleted and why it is written to match the status the list displays. Both new rate limits are in the table, with a note that Express matches app.use paths on segment boundaries, so a new sign-in endpoint needs its own limiter or it has none at all. docs/deployment.md now says what the runtime image carries and why — pandoc for Word, python3 with apk-installed lxml and pillow for the slide renderer, python-pptx pinned, and that PDF conversion is not in the image at all but goes to Gotenberg, so Word and PowerPoint still work when it is down. docs/configuration.md picks up LOGIN_RATE_LIMIT_MAX, LOGIN_CODE_RATE_LIMIT_MAX and GOTENBERG_URL, none of which were listed. README gains a My Resources section and indexes the two new docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
b98ffc61c7 |
fix: expired invitations can be cleared too, revoked ones still cannot
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
An expired code is as dead as a used one and just as accounted for, so it is now deletable. The rule the code enforces is the one that matters: a code that could still be redeemed is never deleted, because that takes it off the list without taking it out of anybody's inbox — the holder keeps something that looks valid, it quietly stops working, and nothing is left to say who had it. One condition, shared by the single delete and the bulk clear: (used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW())) Written that way rather than as "used OR past its date" because the second form also catches a revoked code whose date has since passed — a row the list still labels revoked and offers no delete on, so the button and the query would have disagreed about the same row. Revoked codes keep their rows. Revoking records a decision somebody took, and a handful of them is not the clutter a pile of expired codes is. Verified against the live database across every state: active refused, used deleted, expired deleted, revoked refused, and revoked-with-a-past-date refused rather than slipping through as expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7b084c7edf |
fix: an invitation can only be deleted once it has been used
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The delete button was offered on every invitation regardless of state, and the query behind it deleted any row it was given. Deleting an unused code takes it off the list without taking it out of anybody's inbox: the person still holds something that looks like a valid invitation, it silently stops working, and there is no longer a record of who it went to or why. Revoke is what stops a live code — it leaves the row behind, marked. So the delete is now for spent codes only, in three places rather than one: the query carries AND used_at IS NOT NULL, the route answers 409 with the reason instead of pretending the row is missing, and the button is rendered only on a used row. A "Clear N used" control alongside, since the complaint was clutter and clearing them one at a time is not much of an answer. Same rule — nothing unused or revoked is touched — and it confirms first, because it is still a delete. The bulk route is declared before /invites/:id, or Express reads "used" as an id. Verified against the live database: deleting an unused invitation is refused and the row survives, deleting a used one works, the bulk clear removes only used ones, and the unused probe row was still there afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
22683f3584 |
feat: sign in with a code emailed to you, offered beside the password
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
087f717f55 |
feat: the model designs the deck instead of writing markdown for a parser to guess at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Markdown could express about five of the things the renderer can draw, so the model had no way to say "put this figure beside these three bullets" or "make this a comparison with two labelled columns" — my parser inferred a layout from the shape of a list, and inferring is what made every deck look the same. A presentation is now described as a deck: the model returns JSON naming a layout per slide and the prompt it wants each figure drawn from. Four layouts were added to the renderer for it — two tinted labelled columns for a comparison, a callout card for a red flag or a dose, a figure beside its bullets, and a full-slide figure. Articles stay markdown, which is what prose wants. Markdown is still produced, serialised from the deck, so Word export and text editing keep working and the stored artifact stays readable by a person. The deck is stored alongside it because that serialisation is lossy by design: round-tripping through markdown would throw away exactly the layout choices this was built to capture. A resource made before this, or an article forced into slides, still renders by inferring from its markdown. Nothing here can cost more than the thing that went wrong. A reply that is not a deck falls back to asking for markdown rather than saving the model's apology; a malformed slide degrades to bullets rather than throwing; a comparison with one column is not a comparison; a figure that cannot be queued leaves a slide of text rather than an empty frame; and JSON wrapped in fences or a covering sentence is read rather than refused. Verified live on "croup versus epiglottitis": the model chose section, bullets, table, compare, figure, callout and image layouts across thirteen slides, and the exported deck was rendered to PDF, rasterised and looked at — the comparison renders as two tinted cards, the red flag as a callout, and the figure sits beside its bullets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
73ce4049d4 |
feat: decks are built with python-pptx instead of pandoc, and carry their figures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 25s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Pandoc's pptx writer was the ceiling on how good a generated deck could be, and the model on top made no difference to it. It maps markdown onto a handful of reference layouts with no per-slide layout, no positioning and no control over how large an image is drawn, which is why every deck came out as bullets on a template — and why autofit had to be injected into its emitted OOXML by hand afterwards, because LibreOffice ignores the autofit pandoc leaves off. scripts/render_pptx.py draws the deck and src/utils/slideSpec.js decides what each slide is. Markdown stays the stored artifact, so "change slide 4" is still a text edit and Word export is untouched — pandoc still writes docx, where its output is good. What that buys, all of it visible in a rendered deck rather than argued for: - 16:9, not pandoc's 4:3. - A pipe table becomes a real table with a header band and banded rows, not eight lines of text with pipes in them. - A list longer than seven items becomes two columns instead of a wall of text. - Text is measured and sized to fit before the file is written, so nothing depends on a renderer honouring autofit. - Wrapped lines hang under the text instead of running back to the margin, which is the clearest single tell that a deck was generated. - An image is drawn at its own aspect ratio, centred, with a caption. Figures now reach the deck at all, which they never did. They were queued and shown on the page, but nothing recorded that they belonged to the resource, so an export could not include them: user_resources.image_ids holds them, a modification adds to that list rather than replacing it, and export fetches the finished ones to a scratch directory. They are spread through the deck rather than appended, because ending on three unexplained pictures is worse than showing each near its material, and a References slide stays last. If the renderer fails for any reason, pandoc still produces a deck — a plainer deck beats a failed download. Verified end to end: a seven-slide request with three figures exported as a 13-page deck; the slides were rendered to PDF, rasterised and looked at. All three formats still download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1ad72b134b |
feat: a resource can have several illustrations, on its own path
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources used imageTool.dispatch, which permits exactly one image per request — "Only one image tool invocation is permitted per request". That is the right rule for a chat reply and the wrong one for a twelve-slide deck where the author asked for three figures. Rather than relax a limit the clinical assistant and the Learning Hub also depend on, this adds a separate dispatcher for this feature. Same queue, same storage, same my_resources workflow, same asset endpoint — only the number of figures differs, bounded at six because each one is a paid request. "Use 3 diagrams" in the instructions is read as the number it is, and the illustration option now says several are possible rather than promising one. Three things had to be got right, each found by measuring rather than assuming: The illustration guidance has to be the last thing in the prompt. Placed before the output rules it lost — with the tool offered and the paragraph present, the model returned 3297 characters of markdown and zero tool calls, while the same tool and wording in a shorter prompt produced three calls. Even last, it loses to a prompt carrying thirty library excerpts: deterministically, with the library off "use 3 diagrams" made three calls and with the library on it made none and wrote a longer deck instead. So when the author names a number the call is required rather than merely offered. With no number named the choice stays the model's. And a model that has just made three tool calls tends to sign off instead of writing: "I'll create the presentation and the three teaching diagrams." was returned as the resource — 61 characters — because only a completely empty body counted as missing. A body with no title block and no heading is not a Pandoc document whatever its length, and if the continuation is no better than the first attempt, whichever actually reads like a resource is kept. Verified end to end with the library on: generate produced three figures and an eight-slide deck; modify added two more figures and a ninth slide. The figures were fetched and looked at — labelled airway anatomy, and a croup/epiglottitis/ bacterial tracheitis comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
055a86bbb1 |
feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The screen had been trimmed to controls with no explanation of what it was for; "Private to you" said who could see it, not what it did. It now opens with a sentence that says what you get and how you get it out, and the header reads "Only you can see these". The four choices — clinical library, PubMed, web, illustration — are one "Draw on" group instead of four separate rows, with the library ticked by default. Each option hides itself when an administrator has not enabled it, so nothing appears that a person could tick and then be refused. Modify offers exactly the same choices. It had none, so "add what the 2024 trial showed" was answered from the model's memory rather than by looking anything up. Generate and Modify now go through one gatherSources(), so they cannot drift into offering different things or searching them differently. Writing "include a diagram of the airway" in the instructions now switches the illustration option on and says why, rather than the request being dropped in silence. Switching it off by hand sticks — the hint then reminds instead of fighting — and when no image model is configured it says so rather than pretending. Both the generate and modify boxes behave this way. Two things found by testing this rather than assuming it. PubMed ANDs every mapped term, so one unrecognised word takes the query to zero. "febrile seizures" returns six results and "febrile seizures in under-fives" returns none; "the anatomy of croup: subglottic narrowing and the steeple sign" returned none until it was narrowed to "anatomy croup", which returns six. A query that finds nothing is now retried against progressively shorter versions of itself, longest first, and the response says which query actually worked so the screen cannot report one that found nothing. Those extra calls tripped NCBI's three-a-second limit and produced a 429, so retries are spaced and the first attempt waits for nothing. Separately, the searches run on the topic while the library retrieval also gets the instruction: retrieval is semantic and benefits from the context, but a keyword engine handed a whole sentence returns nothing. And when a search was asked for and came back empty, the prompt now says not to invent a citation, a PMID or a URL to fill the gap. Without that the model supplies them from memory, and a fabricated PMID looks exactly like a real one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1270899dcb |
feat: PubMed search for My Resources, and an image tool that actually fires
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).
Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search call, so
the plumbing was never the problem. The search only ever needed the topic, and
the route knows the topic before it calls the model, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.
Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).
Three fixes to illustration, which had never once fired:
- The dispatch call had been lost in a refactor. The tool was still offered, the
model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
{ request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
markdown — the same suppression that killed the searches. It now says an
illustration is available and that calling it is not a violation of that rule.
my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.
Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
571a013d29 |
feat: optional web search, admin-enabled and off by default
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The one feature here that sends text outside the building, so the defaults are the careful ones: disabled unless an administrator turns it on, opt-in per generation even then, and the option is hidden entirely rather than shown as something a user can tick and be refused. Only the search query leaves. Library excerpts, the generated resource and anything about the user never do. Both screens say so plainly, because a topic typed while drafting clinical material can carry clinical detail and the provider keeps its own logs. Four providers behind one shape, so swapping changes nothing downstream: Tavily, Serper over Google, Brave, and SearXNG — the only one where the query does not reach a commercial third party at all, which is why it is worth supporting even though it needs somewhere to run. The tool description says when NOT to search, because a model handed a search tool will reach for it constantly: not for settled clinical knowledge, which is what the indexed library is for, and one search per resource. That last one is enforced in the route with toolChoice: 'none' on the continuation rather than trusted to the model. A failed search never fails a generation — same contract as corpus retrieval. The resource is written without it and the response says what was searched for and what came back, so a query that left the network is visible rather than silent. The API key is masked on read and preserved when the field is left blank, the handling the OIDC client secret already gets, so changing provider cannot silently wipe a working key. Verified on the running instance: with nothing configured, webSearchAvailable is false, and a request asking for it anyway is ignored rather than honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7eca509b02 |
fix: slides shrink to fit, and an article is never offered as slides
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A generated deck was losing content. pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with no autofit even though the slide master has one, so a slide with too much on it is cut off mid-sentence and the rest is not rendered at all. Reproduced and counted: eight bullets went in, three came out, the third ending mid-word. Every generated deck now carries <a:normAutofit/> on its body placeholders. No fontScale, deliberately — the renderer works out the reduction, so a slide that already fits is untouched, where a fixed scale would shrink all of them. The same eight bullets now fit with nothing in the bottom 6% of the slide. This is a floor, not a licence to overcrowd. The prompt still asks for one idea per slide; this stops a long one becoming unreadable. Also: an article is no longer offered as PowerPoint. A deck of paragraphs is not a presentation. Word and PDF suit either kind, and the route refuses the combination rather than relying on the button being absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
a0d81789ff |
feat: My Resources — anyone can generate teaching material, privately
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Learning is moderator-owned: content published into categories that everyone sees, behind router.use(moderatorMiddleware). That is right for institutional material and wrong as the only way in — an ordinary user could not generate anything at all. So this is a separate pathway rather than a loosening of that one. Learning is untouched; the moderator gate stays exactly where it was. A signed-in user can generate a deck or an article for their own use, keep it, refine it and export it, and nobody else ever sees it. Private by construction. Every statement filters on the owner and there is no route that returns another person's work, which a test asserts statement by statement rather than trusting. The foreign key cascades, so deleting an account takes its drafts with it. There is no category, no publish state and no sharing: adding sharing later should be a deliberate feature, not something that leaks out of a forgotten WHERE clause. Markdown is the artifact. Every format is rendered from it on demand — pptx and docx by pandoc, both carrying the house reference deck, and PDF by Gotenberg, whose LibreOffice preserves a deck's layout in a way rendering from markdown would not. That is what makes "add a slide on when to admit" a text edit rather than a binary patch. Gotenberg was published on the host but on a network of its own, so reaching it from a container went out and back through the host gateway. It now joins danvics_convert, owned by danvics-net like the others. PDF is the one export allowed to fail: if that service is down, the deck and the document still download and the error says which. Verified end to end as a plain user: the moderator route still refuses with 403, generation returned a deck grounded on 12 corpus excerpts, the library lists only their own, pptx/docx/pdf all downloaded valid, "add a slide on when to admit" put the slide in the right place and left References last, and an unauthenticated request gets 401 while someone else's id gets 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
4f5687982d |
feat: Learning resources can be grounded in the clinical corpus
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
Learning generated everything from the model alone. A deck on bronchiolitis was whatever the model remembered about bronchiolitis, with no connection to the documents this institution actually indexed — while the assistant had been searching that corpus all along. Same collection, deliberately. mcp_bge_m3_1024 is already embedded with openrouter-bge-m3 at 1024 dimensions; a second index over the same documents with the same embedder would be a copy that drifts. What differs is the budget: a chat answer wants a few tight excerpts because the reader is waiting, a teaching resource synthesises a whole topic. So learning.search_limit and learning.context_chars default to 30 and 2500 against the assistant's 8 and 1400, and are separate keys so tuning one cannot move the other. Not unbounded, though. "No limit" only moves the ceiling from a setting to the model's context window, where overflow truncates the middle of the prompt silently — the worst place to lose source material. 60 results and 8000 characters per excerpt are the caps. Opt in per generation: a resource on something the library does not cover is better written without it than padded with the nearest unrelated excerpts. Retrieval never fails a generation — the resource is then written from the model alone, which is what happened before this existed — and every response reports what it was grounded on, so a caller can say "24 excerpts" or "the library had nothing on this" rather than quietly serving ungrounded material. Verified against the live corpus: bronchiolitis, neonatal jaundice and febrile seizure each returned 12 excerpts and ~23k characters from Nelson, Rudolph and the Pediatric Clinical Practice Guidelines. A deck generated through the full chain came back with textbook specificity that is not general recall — bronchiolar diameter, birth-weight thresholds, the full pathogen list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1d031af5d6 |
refactor: Google models go through LiteLLM; the Vertex SDK is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 58s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
npm audit reports 0 vulnerabilities. It reported 2 high and 2 moderate this
morning.
@google-cloud/vertexai was the last source of findings — gaxios and a uuid with
a missing buffer bounds check, neither reachable in this deployment because
GOOGLE_VERTEX_PROJECT is unset and the require sits inside that check. Dormant
is not the same as gone, and the provider is available through the gateway
anyway, so the direct path has been removed rather than left to rot:
- the SDK client and callVertex, which without the package could never run
- the dispatch and discovery branches that reached them
- VERTEX_MODELS, a list of ids nothing could route any more, and the two
places in adminConfig that concatenated it into the built-in set
- the health endpoint's vertex line, and the env vars documented for it
AI_PROVIDER=vertex now says where to configure the model instead of quietly
becoming something else. The Google STT and TTS paths keyed off the same
variable are untouched; neither ever used this SDK.
Verified after deploy: provider litellm, the assistant answers with 8 sources,
/api/models returns 10, and @aws-sdk/s3-request-presigner — which documents.js
needs for presigned MinIO URLs — is still declared and resolvable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
fed4bd154f |
fix: recordings that produced nothing, and the boxes that zoomed on iOS
Measured in a real browser against the app rather than reasoned about.
The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.
Three ways a recording could end with nothing to show for it:
- Safari supports none of the audio/webm types and throws NotSupportedError
when handed one. Six modules built their own recorder on resume with
"opus, else audio/webm", so resuming threw there and the recording stopped.
There is now one codec chain in the app, and no module constructs a
MediaRecorder of its own.
- audio-recorder-failed is dispatched on document, and the encounter tab
stopped its recording on any of them. The assistant's microphone failing
ended a consultation being recorded in another tab. The recorder now
travels with the event and the listener checks it is its own.
- The server answers {success:true, text:''} for silence, and five modules
assigned that straight into the transcript — emptying the box the browser
had been filling live. It reads as a recording that vanished. Text is now
required before overwriting, and a recording that captured nothing says so
instead of resetting the button over an empty box.
Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.
And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
050a7d5241 |
feat: citation quality tracking, and the SSO settings fit a phone
Citation quality - A citation naming a source that never came back is never rendered as a link, so it appears as plain text and nobody learns it happened. It is now measured on the server, where the answer and the sources both exist, so it is seen whether or not a browser rendered it. - Four Prometheus counters feed a Grafana dashboard (Ped-AI Citation Quality): answers, citations written, answers affected, and individual unresolved markers. Only answers with at least one unresolved citation are stored, with the question and the titles retrieval returned, so an operator can judge whether retrieval came back thin or the model over-cited. Rows expire after 30 days: this is a quality signal, not a transcript log. - Both answer paths are covered. /chat/stream is normal; /chat is the fallback the client uses when streaming fails, so auditing only the first would have hidden exactly the answers produced under failure. - The tracker is resolved on demand and allowed to be absent. Seven test files load this route with a hand-built list of permitted imports, and adding a hard dependency would mean editing all seven — and the eighth written later would break. Observation must never be able to fail an answer, so a missing module simply means no tracking. - Metric registration reuses an already-registered counter, because this module can legitimately load twice in one process. SSO settings on mobile - Six rows were laid out inline: flex with a 160px label and an input that would not shrink, so on a phone the row was wider than the screen with nothing to scroll and no way to reach the rest. They use .admin-row now, which already stacks below 640px. Verified at 390px and 360px: nothing off-screen, no sideways overflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
272ea94768 |
feat: admin lockdown, so several admins do not all get to change everything
With more than one admin, every setting in the panel was editable by all of them — prompts, model policy, retrieval budgets, SMTP, email templates. ADMIN_LOCKDOWN separates running the service from changing how it behaves. It is an environment variable on purpose: a setting could be switched off by the very admin it restrains, so lifting this needs host access and a restart. The server is the control. One gate refuses configuration writes rather than a check in each of the fifteen write routes, because that list grows and a route added later would quietly miss it. Reads always pass — lockdown hides nothing. Day-to-day operation stays available: invitations, announcements, registration, feature flags, and the test endpoints, which persist nothing. A setting invented later is locked until someone deliberately makes it editable, rather than defaulting to open. The panel disables what it cannot save and says why, but that is courtesy; the refusal is what enforces it. Two things this taught me, both fixed: my first version painted the panel from an IIFE, which the module conventions forbid, and fetched the whole config a second time just to read one flag — breaking the test that pins admin loaders firing exactly once. The state now rides on the invites response the panel already requests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
39c1663334 |
feat: invite-only registration
registration_enabled was a single switch: open to anyone, or closed to everyone. This adds the setting an operator actually wants in between — open to people you invited. A code is single-use, expires (7 days by default, 90 maximum), and can be revoked or deleted. It is stored hashed with only its last four characters kept, because an invite grants account creation and a database dump should not hand someone a working one. The code is readable exactly once, in the response that creates it. The claim is a single conditional UPDATE carrying every condition, so two registrations racing the same code cannot both succeed. It happens after the account exists, so a code is never spent on a failed registration — and if the race is lost, the just-created account is removed rather than left behind as a free registration. The rejection never says which of the four reasons applied; distinguishing them would tell someone probing codes which guesses were closer. Codes avoid I, L, O and U so they survive being read aloud or copied off a screen, and matching ignores case and separators. The sign-up field appears only when the server says a code is required. The admin card creates, lists, revokes and deletes, and carries the toggle. Verified against the live database: create, claim, second claim refused, unknown code refused, revoking a used code refused, delete. 684 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
846143ebe5 |
refactor: ped-ai calls clinical_semantic_search only
The nc_semantic_search alias is gone from the MCP server, so accepting it here would point retrieval at a tool that no longer exists. A stale override now stops the app at startup instead of silently retrieving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
f89dc01729 |
refactor: one place decides which bucket, on which S3, with which credentials
Three S3 configurations had grown separately — S3_* for documents, GENERATED_IMAGES_S3_* for images, and AUDIO_BACKUPS_S3_* after them — with different key names and their own client construction. That is why moving storage meant hunting through several files. src/utils/objectStorage.js now resolves settings for any purpose: its own variables first, then the shared S3_* ones, with a per-purpose bucket name (S3_BUCKET_AUDIO_BACKUPS). One endpoint plus three bucket names is enough for the whole app, and a purpose that needs its own account still overrides everything. Audio backups and documents use it; generated images keeps its own tested storage module, whose variable names the resolver already understands. Nothing existing has to change: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and the AWS_* fallbacks still resolve, and path-style addressing keeps each purpose's previous default — off for documents, so a Backblaze endpoint behaves as before, on where a custom endpoint implies MinIO. A _FILE credential now always beats an inline one, so a mounted secret cannot be shadowed by an inherited environment variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
523926ab17 |
feat: keep the screen awake while recording, and keep every recording 24h
Recording - A screen wake lock is held for as long as a recording runs. Browsers drop the lock whenever the page is hidden, so it is taken again on return — without that, one glance away ended it for the session. The lock is reference counted (two recorders cannot release each other's), never requested while hidden (the request would just be rejected), and a denial or an unsupported browser leaves the recording running. - Signing out releases it and stops the recording; nothing is sent, because the session that owned the audio is gone. - start() on an already-running recorder is now a no-op instead of replacing the MediaRecorder and silently dropping everything captured so far. - A recording that ends by itself — recorder error, or the microphone taken by another app, unplugged or revoked — takes the same path as pressing Stop, so it is transcribed and stored rather than left in a tab that still says "recording". Moving around the workspace already kept recording. Retention - Every recording is kept for 24 hours now, not only the ones whose transcription failed. /api/transcribe already has the audio, so this costs no second upload, and a storage failure is logged rather than thrown: it must never lose the transcription someone is waiting for. - One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and /api/audio-backups so the two cannot drift. Payload goes to object storage when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column otherwise; metadata always stays in Postgres, so listing, ownership and expiry behave the same either way. Object keys are scoped by owner, and the expiry sweep deletes the object with the row. Verified against the live database: round trip byte-identical, another user reads null, 950 -> 48 bytes compressed, expired rows take their objects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
f0f48a3578 |
fix: patch nodemailer; Settings offers STT models the gateway really has
Security - nodemailer 9.0.1 -> 9.1.1, clearing four high advisories, two of which are delivery bugs that matter for an app that sends mail: recipient-domain validation bypass via RFC 5322 comments, and an IDN/punycode allow-list bypass, both of which can route mail to an attacker-controlled domain. Live transcription - The Settings picker was a hardcoded list of six ids (local-whisper-*, local-parakeet-v3, gemini-*). None of them resolve on this gateway, and /api/transcribe prefers the user's choice over the admin default, so picking one broke every recording with "Invalid model name". Verified against the live gateway: local-whisper-large-v3-turbo -> 400. - The picker now lists what /model/info advertises as audio_transcription, cached for five minutes, with the built-in list kept only as a fallback and the admin default marked. - The pipeline itself is healthy: local-kokoro-tts produced 92KB of speech and mistral-voxtral-mini-transcribe returned the sentence back verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
31abddb6e6 |
fix: correct a false Settings claim; make every test child's stdout pure TAP
Feature audit - Settings claimed you could reference a template by saying "use my normal physical exam" in dictation. No phrase handling exists anywhere, and the prompt says the opposite: "Never copy clinical content from a template — only formatting and structure." So a template can never supply findings. The text now says what happens, and keeps the true privacy statement that only template categories go to the AI (Custom is filtered out in /memories/context by AI_CONTEXT_CATEGORIES). - Templates themselves are real: CRUD plus /memories/context, injected as style hints by hpi, soap, sickVisit, wellVisit, edEncounters and hospitalCourse, behind the `memories` feature flag. Docs - docs/CLINICAL_ASSISTANT.md listed six settings and offered `deepl`, which no longer exists in the code. The table now covers all seventeen keys the server reads, with their fallbacks, plus how a model reaches a user. Testing - Every test file's stdout is now pure TAP, which is the stream node:test parses results from. Three sources: a leftover debug console.log dumping 600 characters of HTML, page modules logging into a JSDOM without a virtual console, and the server startup banners. The banners are guarded by NODE_TEST_CONTEXT, set only inside node:test children, so production and `node server.js` output is unchanged (verified both ways). - Three consecutive full-suite runs at 671/671. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
2f7233f317 |
perf: store real image previews in MinIO, generated at creation and on demand
Gallery tiles are 56px but were downloading the full ~280kB original. Previews are now rendered with sharp and stored beside the originals in the same MinIO bucket under a thumbs/ prefix, so nothing about credentials, lifecycle or backup changes. Measured on live assets: 216-294kB originals become 13-19kB at 256px, about 16x smaller; 640px is about 4x. Both paths, as asked: - Rendered when a job completes, so the first viewer never waits for a resize. A preview failure never unmakes a finished job. - Rendered on demand for anything that has none — the existing 26 images work immediately with no backfill required, and the result is stored for next time. Boundaries that matter more than the speed: - Only 256 and 640 are honoured. An open width parameter would let a caller drive arbitrary resizes. - Permission is checked against the ORIGINAL before a preview is served, so a preview can never widen who can see an image. - Previews carry their own SHA-256 and owner headers, because the client verifies both on every asset; sending the original's checksum would be rejected as tampering, which is that check working correctly. - Still private, no-store. The client asset pattern was widened to exactly ?w=256 and ?w=640 and nothing else. Client-side downscaling stays as the fallback when a preview cannot be produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd |
||
|
|
db83255c58 |
feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier) The previous toggle branched the SYSTEM PROMPT, so the same question could get a different answer depending on a display setting — the bias this was meant to avoid. The prompt is now unconditional: buildSystemPrompt takes no display argument and is byte-identical either way. Hiding sources happens on the way out — the server omits them and strips the now-orphaned [n] markers from the copy it sends. The answer is generated, stored and exported with citations intact, so turning the setting back on restores them without re-asking anything. Renamed to clinical_assistant.show_sources; the old key is still honoured. Signed-out preview (admin opt-in, default off) A visitor may try the assistant; reaching for the workspace asks them to sign in. Deliberately narrow: - Reachable paths are an exact allow-list, not a pattern, so a new endpoint is private unless someone adds it on purpose. - A preview visitor gets no identity at all (id: null), so nothing can be owned, saved, billed or addressed to them. - The image tool is withheld rather than left to fail on a null owner, and no audit rows are written. - A caller presenting a token is authenticated normally, so preview can never downgrade a real session; if the setting cannot be read, authentication is required. - Actions needing an account are hidden rather than offered and refused. Composer The bar above the transcript is gone. Patient take home, Export PDF, Download transcript and Attach images moved into a + menu in the composer, and the model selector moved beside send — shown only when there is more than one model, as before. Both views now start at the same top edge, so switching modes cannot nudge the page up or down. On an empty transcript the tiled ground runs behind and below the composer, which floats on it above centre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe |
||
|
|
9788b167f2 |
refactor: retrieval is text-only; admins can add model ids discovery never returns
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 |
||
|
|
b992c6600b |
feat: admin can turn citations off
New setting clinical_assistant.citations_enabled (default true, admin checkbox). With it off, retrieval, grounding and every other rule are unchanged — answers are still built only from retrieved sources — but: - buildSystemPrompt swaps only the citation block: the "cite factual claims with [1]" rules are replaced with "do not include citations, source numbers or bracketed markers", and the note that the sourcing requirement itself is unchanged. Grounding, scope, table formatting and tone rules are byte-identical between the two modes. - The server strips any stray [n] the model emits anyway, from the stored answer rather than only the view, so saved chats and exports match what was shown. - No sources are sent to the client at all, and the status endpoint reports the mode so the UI hides the Sources panel and gives its 330px column back to the chat instead of showing an empty rail. Validated as a boolean in adminConfig, like the feature.* keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq |
||
|
|
022869f01d |
fix: mobile layout regression, workspace menu in the assistant rail, prompt as principles
Mobile body.assistant-workspace .assistant-layout is both later in the file and more specific than the .assistant-layout rule inside @media (max-width:640px), so phones were inheriting height: calc(100vh - 64px) — an offset for the app header that workspace mode already hides — plus grid rules on a flex container. The topbar then overlapped the content. Added a matching-specificity mobile override restoring 100dvh and the flex layout. Workspace menu in the rail The saved-chats rail gains a collapsible Workspace section listing the app's own tabs, so the assistant sidebar carries both its chats and the workspace navigation. It is built from the real .tab-btn elements rather than a second copy of the menu, so tabs added, renamed or hidden in index.html follow automatically and admin-only tabs stay hidden. Prompt Rewritten as principles instead of an enumerated rulebook: "respond to the user's latest message, not to an earlier one", "if it carries no question, ask what they would like you to look up", "never repeat a previous answer", and for the tool "use it when the latest message asks for a picture, or for a change to one you just made, and not otherwise". No example words in any language remain — the model reads the message as written. 1032 -> 788 characters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq |
||
|
|
a95236e363 |
refactor: the model decides about acknowledgements and images, not route heuristics
The previous fix put a hand-maintained multilingual word list in the route. That does not generalise — the bug report itself was "Окей", and the next one would be a language not on the list. The model already has the tool, the system prompt and the whole conversation, so the policy belongs where it reads it. Removed from src/routes/clinicalAssistant.js: - GREETING_RE and its short-circuit (an ASCII keyword list that decided, before the model ever saw the message, that a greeting deserved a canned reply). - The IMAGE_NOUN / IMAGE_REPEAT vocabulary lists and the answer-repetition backstop added earlier today. Moved into the prompt and the tool definition: - DEFAULT_BEHAVIOR now says that a greeting, acknowledgement or thanks in ANY language gets a one-sentence request for a clinical question, and that a previous turn's answer must never be repeated to justify a second image. - The generate_image description says to call it ONLY when the user's latest message asks for a picture or a change to one just made, and that an acknowledgement of an existing image is not such a request. Both are admin-editable (clinical_assistant.system_behavior), so this can now be tuned without a deploy. dispatchImageRequestFallback and IMAGE_REQUEST_PATTERN stay: that is the compatibility path for a serving model that writes the image prompt as text instead of calling the tool. It reads the USER's message only, never the model's answer, so it cannot replay a previous turn — it was not the cause of this bug. The DEFAULT_BEHAVIOR byte-hash lock in prompt-administration.test.js is updated deliberately, which is what that guard is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq |
||
|
|
4d92488f0c |
fix: translated answers keep their source chips; the patient take home can be translated
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
|
||
|
|
07af95b89a | feat: device TTS preferred in voice mode with model fallback; permanent src-garbage scrub; simplified-HTML translation with text fallback; New chat closes the drawer; real FA icons; audio unlock restored | ||
|
|
d4418f8a80 | feat: OWUI-style image handoff — the model calls the tool and announces preparation; enforced instruction independent of stored prompts; stream status announces generating image | ||
|
|
73955dd5f9 | feat: model selection in chat (composer pill + popup image model), deterministic image dispatch when the model writes text instead of calling the tool, markdown-rendered email, OWUI stacked composer, left mobile drawer, Go back in topbar, DeepL removed, image history inside the Create image popup |