Commit graph

12 commits

Author SHA1 Message Date
Daniel
d2a06b0fcf feat: modifying a resource is a job, the same as generating one
Modify held the request open for a library search, a PubMed search, a web
search and a restating model call. That is minutes, and a browser gives up
first — Firefox abandons a non-streaming fetch at five minutes, the same
failure generating was moved off the request to fix in ef574edd. The server
carried on and saved the result while the person watched an error, and closing
the tab killed the work outright.

POST /my-resources/:id/refine now records the request and answers 202 with the
job, exactly as /generate does. The writing moved into refineResource(), which
the job runner dispatches to by kind; the job list, the five-second polling,
the restart recovery and the three-in-flight cap are all the work they already
did, unchanged. Ownership is checked again inside refineResource because the
resource can be deleted while the job waits.

The page follows the job instead of the response. Reporting is unchanged — the
unchanged reply, what was seen and what was searched — it is only said from the
job list now, so it still reaches the person who asked for it after a reload.
2026-09-16 23:28:42 +02:00
Daniel
ef574eddcb feat: a resource is generated as a job, and DeepSeek writes it without thinking
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / End-to-end (browser) (push) Blocked by required conditions
Forgejo Docker Build / Root app tests (push) Has been cancelled
Writing a resource held the request open for as long as it took: a library
search, one or two long model calls, a review, then figures. Measured end to
end that was six minutes on 2026-09-16 (00:40 to 00:46:16, resource 53), and
Firefox abandons a request that has said nothing for five — the browser
reported "NetworkError when attempting to fetch resource" while the server
carried on and saved the deck anyway, so a generation that worked looked
like a failure and left no status line.

The request now records what was asked and answers 202; the work runs on the
server as a job; the page lists what is being written, what landed and what
failed, polls while anything is in flight, and reloads the library when one
lands. Several can run at once, a reload loses nothing, and a boot pass marks
jobs stranded by a restart as failed rather than spinning for ever.

The same generation also ran with DeepSeek's thinking on, which is what made
it take minutes rather than seconds: the 16,000-token write spent the whole
budget reasoning and returned an empty reply (completion_tokens=16000,
reasoning_chars=51573), which fired the automatic retry at four times the
budget, and the 2,000-token reviews of that deck starved the same way four
times over. Thinking is now off for the writing, the review of it and a
revision — DeepSeek's own field, sent by the model wrapper. Other clinical
routes are deliberately untouched and keep the provider default.

The review inherits the writer's rule rather than hard-coding it, so a task
that wants reasoning can still ask. Migration 1781500000000_resource-jobs.js
adds user_resource_jobs; the container entrypoint applies it before the app
starts.
2026-09-16 03:53:57 +02:00
Daniel
cd00fae460 feat: a deck has a format as well as a theme — the shape of the session, whose parts the model may reuse
A theme is a look and the slide vocabulary is what one slide can be; neither
said what a journal club is (PICO, methods, appraisal, bottom line) or that a
board review runs in case-question-answer cycles, so every deck came out as
the same lecture. assets/deck-formats.json holds ten formats distilled from
how departments run each session — grand rounds, journal club, morning
report, M&M, board review, noon conference, simulation debrief, parent
education, QI project, research abstract — each a sequence of parts naming
the layouts it is usually drawn with and whether it may repeat. The brief is
prose after the layout vocabulary; a part is a unit the model reuses as many
times as the material needs, and every slide stays one of the twelve types.
Chosen beside the theme, 'Free' by default, stored on the deck and carried
through modifications.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
2026-09-14 14:13:22 +02:00
Daniel
5d28ebad9c feat: the assistant degrades politely under load — bounded retrieval queue, per-user limit, short search cache
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Three things the load path lacked. The retrieval slots had an unbounded
line behind them, so a burst meant silent waiting; past a bounded line, or
after eight seconds in it, a caller now gets 'the library is busy' and a
503 with a retry hint. The paid routes had no per-account ceiling; they
now get one, counted in Redis so every replica sees the same count and
nothing is refused when Redis is absent. And the same library search
asked twice within a minute (a retry, a refresh) went to the library
twice; it is now answered from Redis, with 0 turning that off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-14 07:56:26 +02:00
Daniel
c7864f763e feat: sharing is by link — follow it, accept, and it is in your resources
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 12s
The author presses "Copy a share link" and sends it however they like.
Whoever follows it (app.pedshub.com/#share=<token>) is signed in first if
need be — the token survives the trip through the SSO — then shown what it
is and who from, and adds it with one press. Only the token's hash is
stored; a link lasts 30 days and can be withdrawn; accepting twice is
harmless; the owner following their own link changes nothing. Sharing by
email is gone: nobody is looked up by address. "Everyone signed in" stays
as a switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 15:20:35 +02:00
Daniel
e92b72d406 feat: preview a resource, or a theme's sample deck, as pages — without downloading
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s
A PowerPoint or Word file cannot be shown in a browser and a phone has
nowhere to open one. Preview renders the resource the way its download
would be built, turns it into one PNG per page (Gotenberg to PDF, pdftoppm
to pages), and shows the pages in an overlay that scrolls and pinch-zooms
like anything else. Rendered once per version — updated_at and theme are in
the key — and served from disk afterwards. The theme picker's sample deck
has the same Preview beside its download.

Every presentation now shows the theme picker in the library, since
markdown slides take a theme too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:48:45 +02:00
Daniel
5f738fbe30 feat: a theme is shown by a sample deck you download, not a picture
A picture of one slide answers a narrower question than the one a theme
picker is asked. What a person wants to know is what a deck will look
like in this theme — all of it, at the size it will be shown, with the
fonts substituted the way they will be. One rendered slide showed one
layout, in content someone then read instead of looking at.

So: a sample deck per theme. Every layout the renderer can draw — title,
bullets with a sub-point, both two-column forms, table, callout, figure,
full-slide figure, section divider, a custom slide with shapes, an arrow
and a chart, and a references slide — with filler text throughout.
Download it, open it, see the theme.

The text is deliberately meaningless. Clinical content in a specimen
invites you to read it, and then you are judging the teaching rather
than the type; that is what the old croup slide got wrong.

No engine. The previous preview needed a Gotenberg round trip and a
pdftoppm to produce a PNG, cached on disk because of what it cost, and
could fail in ways a missing picture cannot explain. python-pptx builds
the file in ~330ms and PowerPoint draws it. The link is a plain anchor,
so it is there whether or not anything on the server is well.

The figure placeholder rides the same path a generated figure does —
attachFigures downgrades a figure slide with no picture to bullets and
an image slide to a section, so without it the sample would silently
stop showing those two layouts.

Verified: all five themes build, 11 slides, the chart is a real chart
and the placeholder embeds as real media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 22:24:05 +02:00
Daniel
24c8d71b7e feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
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 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A .pptx landing in someone's own storage is worth having; a markdown blob is
not, and it is not what they would have downloaded. So this renders through
exactly the path the download uses — stored deck, its figures, the chosen
theme — and PUTs the bytes. The file never travels through the browser.

Offered only when a Nextcloud is connected: an action that always fails is
worse than one that is not offered. An article offers Word, a deck PowerPoint,
and asking for slides from an article is refused with the reason.

Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two
callers want it and neither should grow its own copy of the WebDAV dance — make
the dated folder a segment at a time, PUT, migrate a legacy plaintext token —
because it reaches into storage that is not ours and a second slightly
different copy is how the two drift. It also replaces a route importing another
route.

Also: a model that leaves the roster now leaves every list that names it.
clinical_assistant.allowed_models and the image roster are advisory copies of
the roster, and a stale id there was invisible until someone asked a clinical
question and the request failed at the gateway. Removing or disabling a model
prunes it; clearing the roster clears them. Re-enabling deliberately does not
re-allow it — that is a separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:34:58 +02:00
Daniel
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
2026-09-12 06:55:16 +02:00
Daniel
491a2b0811 fix: the My Resources diagnostics survive a deploy
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
logRefine writes the one line that answers "did that modification change
anything" — path, before and after size, CHANGED=yes/no, figures, model,
instruction. It went to console, so it lived in the container's stdout and was
destroyed the next time the container was recreated.

That cost a diagnosis today: a modification came back unchanged, the user asked
why, and the evidence had already been deleted by a deploy. The deck-fallback
warnings and the deck-vocabulary gaps had the same problem, and those exist
specifically to be read later — the vocabulary gaps are meant to show which
shapes to build next, which is a question about weeks, not about one container.

All of them now go through logger, which writes the dated file in the
scribe-logs volume and ships to Loki when it is configured, and carries the
event as structured data rather than only as a formatted string.

console.error is left alone: those are failures, and logger.error already
echoes to the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:09:34 +02:00
Daniel
07d1c541a0 fix: a deck the model fumbles once is asked for again, not abandoned
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 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A presentation is generated as a typed deck — the model chooses each slide's
layout — and that deck is what scripts/render_pptx.py renders. When the reply
did not parse as a deck, the route fell straight back to asking for markdown,
and slideSpec.build() then inferred slides from it. Both paths go through
python-pptx, but the fallback's layout is guessed from heading and list
structure rather than chosen, so everything lands as title-and-bullets.

Measured on the stored library: since decks landed, 7 of 8 generations produced
one and 1 did not. Models are stochastic, so one unlucky reply was costing the
whole layout. It now asks a second time with the same prompt before giving up.

The fallback was also invisible. It warned to the console, where the person who
would simply have generated again could not see it, so they kept the plainer
deck without knowing a better one was one click away. The response now carries
deckFallback and the UI says it came out as plain slides, and why.

Fixed the reason heuristic while adding tests for it: truncation was claimed for
any reply not ending in "}", which is every prose refusal. It is now only
claimed for a reply that began as JSON and stopped.

The four generate tests run the handler. Verified against a mutation: removing
the retry fails tests 6 and 7 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:34:14 +02:00
Daniel
e69eb9a9f7 fix: modifying a presentation failed whenever illustration was ticked
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made
it undefined there. With illustration on, a deck modification therefore appended
the *markdown* instruction — "Returning the markdown is still required; a tool
call is not a substitute for it" — to a prompt whose body asked for deck JSON.
The model was told to produce two different artifacts in one reply, the reply
parsed as neither, and deckBuild.parse returned null, so the handler answered
502 "That change could not be applied."

Moved the declaration above its first reader. Same class of fault as the
savedFigureIds one, in the same file.

Two things made it hard to see, both fixed:

- The library row read created_at, so a modification that did apply left the
  visible timestamp on the generation time. That timestamp is what led to
  "modification doesn't work" — it was the only signal available, and it was
  reading the wrong column. Rows now show the modified time when there is one.
- A model can also return the document back unchanged. That was logged
  server-side and answered "Applied. Download it to see the result", which sent
  people to download an identical file. The response now carries `unchanged` and
  the UI says so, keeping the instruction in the box so it can be reworded.

Also surfaced has_deck on the library list: 28 of 38 stored presentations have
no deck and go through the weaker flat-markdown path, and nothing in the UI
distinguished them. They now read "plain text, no slide layout".

test/my-resources-refine.test.js runs the handler rather than reading it, since
all three faults were invisible to source reading. Verified against a mutation:
putting the declaration back where it was fails test 1 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:05:39 +02:00