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
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
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
pptxgenjs is gone, and with it 269 lines of hand-rolled markdown parsing.
It stretched every image. Reading the slide XML it emitted shows why: it writes
the target box verbatim with <a:stretch/> and a no-op srcRect, so a 200x800
image handed an 11.8x3.9 box came out 1:4 squashed to 3:1. It could not do
better — it never measures an image, and its own getSizeFromImage is commented
out and marked "currently unused", reaching for a package called sizeof that
does not exist. pandoc measures them: a 300x175 source renders at aspect 1.714
and a 160x360 at 0.445, verified by rendering the deck to PDF and looking at it.
Tables, ordered and unordered lists, bold, italic and subscripts all come out
natively, and the fonts, palette and slide layouts come from
assets/learning/slides-reference.pptx. Design now lives in that file: restyling
the decks means editing it in PowerPoint, not editing this route.
Only images the requester owns can reach a deck. pandoc resolves an image link
against the filesystem, so a markdown link naming any local path would read that
file into the presentation. Images are fetched by id through the ownership
check, written into a per-request temporary directory under names we choose, and
every image link that did not resolve is removed rather than passed through. The
directory is removed in a finally block, and the conversion has a 60s timeout so
it cannot hang a request.
pandoc is in the image rather than a sidecar, because an export must not fail
for reasons outside this container. It costs 197MB (307 -> 504).
Removing pptxgenjs also removed image-size, and with it both high-severity
advisories — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, ICNS/JXL/HEIF parser
denial of service, ranged <=2.0.2 with no fixed release to upgrade to. npm audit
goes from 2 high and 2 moderate to 2 moderate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Reproducibility means two things here: the same commit builds the same image,
and the running container can be asked which commit it is.
- Base images are pinned by digest, not by tag. A tag moves; two builds of one
commit could otherwise differ. These are manifest-list digests, so buildx
still picks the right architecture.
- scripts/build-image.sh also writes ped-ai-local:<revision>, an immutable
name a deploy can refer to instead of chasing :latest. Its summary goes to
stderr so stdout stays the Compose invocation.
- Compose takes the image from PED_AI_IMAGE, so a deploy runs a specific
revision-tagged image while a local build still uses the local tag.
- scripts/deploy.sh pins that image in the file Compose interpolates from,
waits for health, then asks /api/build which revision is actually serving
and rolls back to the previous image if it does not match. Healthy is not
the same as running what you asked for. The rollback path was exercised.
- The entrypoint applies migrations before the app starts, so code and schema
arrive together. node-pg-migrate takes an advisory lock; losing it is not an
error, it waits and looks again, so a rolling restart does not fail. A real
migration failure stops the container rather than serving on a schema that
does not match the build. RUN_MIGRATIONS=false opts out.
- The Forgejo workflow builds through that same script, tags by full revision,
and has an opt-in deploy job. It refuses to run if the deploy directory has
uncommitted work rather than resetting over it.
The running image was labelled revision=unknown, and /api/build said "unknown",
because `docker compose up --build` never passes GIT_REVISION. That is exactly
the hole this closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Adds an optional secret-fetch step at container boot. When OPENBAO_ADDR,
OPENBAO_ROLE_ID, and OPENBAO_SECRET_ID are set, the entrypoint
authenticates to OpenBao via AppRole, pulls kv/ped-ai/prod, and exports
each key as a process env var before exec'ing node. When OPENBAO_ADDR
is unset the entrypoint is a no-op — the legacy .env flow continues to
work unchanged (e2e container, local dev, rollback).
Changes:
- docker-entrypoint.sh: new — AppRole login + KV fetch + env inject +
exec. Fails fast on missing/invalid creds; unsets bootstrap vars
before launching node so they don't linger in the process env.
- Dockerfile: multi-stage copy of /bin/bao from openbao/openbao:2.5.3
(multi-arch handled automatically by buildx manifest-list resolution).
Adds jq for JSON parsing. Wires ENTRYPOINT to the script; CMD
remains ["node", "server.js"].
- .env.example: documents the three vault-bootstrap variables at the
top and notes that everything below is vault-sourced when OPENBAO_ADDR
is set.
Rollout is two-phase for safety: rebuild image with unchanged .env
(proves no regression in legacy mode), then add the three OpenBao vars
and restart to cut over to vault-sourced secrets. Rollback at any point
is blanking OPENBAO_ADDR in .env + restart.
argon2 requires node-gyp + python3 + g++ + make to build its C
extension. Added as a virtual .build-deps package so it's compiled
during npm install, then purged to keep the Alpine image slim.
FINAL WORKING SOLUTION:
Previous attempts failed because:
- transformers.js v2.17.2 is ES module-only
- Module workers require complex CSP and external imports
- importScripts() doesn't work with ES modules
Solution:
- Use transformers.js v2.6.2 (has worker-compatible UMD build)
- Bundle library + models, serve entirely from our server
- Classic worker with importScripts() - no CSP issues
What's self-hosted:
- ✅ transformers.min.js (760KB) - at /models/transformers.min.js
- ✅ Whisper models (42MB) - at /models/Xenova/whisper-tiny.en/
Worker loads:
1. importScripts('/models/transformers.min.js') - OUR SERVER
2. Loads models from /models/ - OUR SERVER
3. ZERO external network calls
4. Works in any network (firewalled, air-gapped, etc.)
This is the production-ready, truly offline solution.
Issue: transformers.js is an ES module package and cannot be loaded
with importScripts() in classic workers.
Solution:
- Changed to module worker (type: 'module')
- Import transformers.js from CDN as ES module
- Models (42MB) still served from local server at /models/
Trade-off:
- Library (900KB): Loads from cdn.jsdelivr.net once, cached
- Models (42MB): Self-hosted, served from /models/ (no CDN)
This is necessary because:
1. @xenova/transformers is ES module-only (package.json: "type": "module")
2. ES modules cannot use importScripts()
3. Module workers require HTTPS for imports
4. CDN is HTTPS and cacheable
If CDN is blocked:
- Use Web Speech API (with privacy warnings)
- OR use Server Transcription (Vertex AI/AWS)
Models remain self-hosted as they're 40MB+ and contain the AI.
BREAKING FIX: Browser Whisper now fully self-contained
Previous issue:
- Loaded transformers.js from cdn.jsdelivr.net
- Downloaded models from cdn-lfs.huggingface.co
- Failed in corporate/clinical networks with firewall
- Stuck at "Initializing..." with no progress
Solution:
- Bundle transformers.js library (~876KB)
- Bundle Whisper tiny.en model (~42MB)
- Serve everything from local server
- Works in ANY network environment
Changes:
- whisperWorker.js: Load transformers from /models/ instead of CDN
- Dockerfile: Download models during Docker build
- Add download script for local dev
- Add comprehensive setup documentation
Docker image size: +~42MB (one-time cost, runtime benefit)
Tested: Works on unrestricted and firewalled networks
- transcribeAWS.js: convert browser WebM/Opus → PCM 16kHz mono via
ffmpeg before sending to AWS Transcribe — PCM is unambiguous and
most reliable; gracefully falls back to ogg-opus if ffmpeg absent
- Dockerfile: install ffmpeg (apk add ffmpeg) so Docker image works
out of the box with AWS Transcribe
- README: document Amazon Transcribe setup, ffmpeg requirement,
Transcribe Medical specialty options, and env vars reference