Commit graph

366 commits

Author SHA1 Message Date
Daniel
febb14490c feat: ground AI drafts in the library and PubMed, and mend the card system
Some checks failed
Tests / backend (push) Failing after 15s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 26s
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.

- *The clinical library.* The indexed shelf the clinical assistant already
  searches, over MCP on the internal network. Ported from ped-ai: sessions are
  reused, a dead one is reopened once, and a library that cannot be reached
  never fails the article — it just means the educator is writing without it,
  and the progress line says so.
- *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the
  two lessons that cost somebody an afternoon over there: PubMed ANDs every
  term, so "bronchiolitis management in infants" can find nothing where
  "bronchiolitis management" finds six — hence the query ladder — and three
  esearch calls in a row will trip the rate limit, hence the spacing. The
  reference list is written from the records rather than by the model, so every
  line is a paper that exists with a PMID somebody can look up.

Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text
citations, in one draft.

**The card system, which turned out to be half-built:**

- There was no way to make a deck by hand, and no way to edit a card at all —
  you could browse, view and delete. Both are there now, the editor taking
  front, back and a picture.
- Filing, writing, sharing and deleting are all educator work now, behind one
  named gate rather than four scattered checks. A learner studies.
- A deck generated from an article inherits that article's category instead of
  landing in Uncategorized for somebody to file by hand.
- A link inside a card previewed instead of going. A card is a box a few lines
  tall, often inside a flipping panel, and a hover card anchored in one is
  clipped by it — so the link read as broken because clicking it did nothing.
  Where there is no room to preview, the honest behaviour is to take you there.

**An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so
a drafted article was neither "generated, unread" nor anything else: the tile
counted it and there was nowhere to click. Drafts are stamped with the model
that wrote them, and there is now a plain Drafts queue that cannot be fallen
through.

**The sign-in code email** is laid out rather than written: the code is the
biggest thing on the screen, then which account it signs into, then a way back
to the page, then permission to ignore the whole thing.

Also: a back link out of a deck, in the same words as the rest of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:44:42 +02:00
Daniel
158930d532 feat: section links in prose, a picker that writes them, and cards that render
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 36s
**Cross-references can name a section.** `[[264#workup|the workup]]` opens the
reader at that heading, which is what a sentence about one part of a long
article actually means. Whole-article `[[264|label]]` is unchanged, and a
section renamed since is not a broken link — it lands at the top of the right
article, which is a mild disappointment rather than a dead end.

**A picker that writes the marker for you.** 🔗 Link an article, in the editor:
type a few words, click the article — or one of its sections — and the marker
is on the clipboard with the right title as its label. Getting an id used to
mean opening the library in another tab, finding the article and reading the
number out of the address bar, which is four steps and a chance to mistype,
every time. Its own small endpoint, because the listing deliberately does not
carry sections and this needs nothing else.

**Three things about cards that were built but never drawn:**

- A card can carry an image. The column is there, the API returns it, the
  editor accepts one — and no view in the app rendered it, so every picture
  anybody attached to a card was stored and never seen. Both card views show it
  now, small until clicked like every other figure.
- The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The
  study view has rendered them as links for a while; now both do.
- There was no way to make a deck by hand. Every deck came out of a model —
  generated from a document section or an article — so an educator who wanted
  to write six cards had nowhere to put them, and the add-a-card route could
  only add to a deck that did not exist yet. `+ New deck` on the cards page.

**Generate cards ran in silence.** It starts a real job, and the only place its
progress was drawn was inside the refine panel — which lives in the editor and
is shut. Pressing it on the reading page did nothing visible for ninety
seconds. It now says what it is doing where it was pressed.

**Overlays were invisible to learners.** A stored width is a fraction of the
image, and the stroke is drawn with `non-scaling-stroke`, which makes
`stroke-width` a count of screen pixels — so 0.006 meant six thousandths of a
pixel. The editor has always multiplied by its rendered width; the viewer now
does the same sum. Every region an educator has ever marked was invisible to
everyone who was not editing it.

Also: the figure viewer no longer scrolls, at any width, and the page behind it
is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a
figure opened half-way down an article drifted while it was read). Options are
full width on a phone. The question toolbar's seven glyphs are four, with the
rest folded into the ⋯ that was already there, spelled out in words. The jobs
popover closes on a click anywhere outside it. And the editor has a way back to
Editorial — "back to the article", from an article you opened to edit, is a
loop.

The contract snapshot caught both new routes on the way through, which is what
it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:05:02 +02:00
Daniel
2d55805471 chore: keep playwright's output out of the repository
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 29s
Tests / e2e (push) Failing after 33s
Reports, traces and the last-run marker are per-run artefacts; CI keeps them
for a fortnight as an upload, which is where they belong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 01:23:45 +02:00
Daniel
e72cdd6716 feat: a versioned API, refresh tokens, and an end-to-end stack that found four bugs
**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.

**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.

**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.

It found four things in its first hour:

- **A fresh deploy could not start.** `create_all()` ran before
  `CREATE EXTENSION vector`, so any database that had never had pgvector
  installed died on the first table with a vector column. Invisible here
  because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
  Media in the library is nobody's to read by default, and nothing made an
  exception for a drawing an article actually shows — so every illustration
  added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
  nginx's address for every request, so ten bad passwords from anybody locked
  out everybody, and no log line could say who. nginx now takes the real
  address from the proxy and overwrites the header on the way in; uvicorn runs
  with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
  820px in the stylesheet. Between them the menu button claimed the contents
  drawer and then toggled a class on a rail that was still in the layout: the
  contents did not open and the site menu did not either. The button was dead
  on every tablet.

And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.

The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.

CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 01:23:38 +02:00
Daniel
1f52c1217f refactor: the app is a PWA, so the native wrapper goes
1,881 files: a Capacitor shell for Android and iOS, a Forgejo workflow that
signed and uploaded an APK, and `/api/mobile` — the API that shell talked to.
Nothing in the web app called it, and the PWA (manifest, service worker, icon)
is untouched: installing PedsHub on a phone works exactly as before.

`/api/mobile` was worth removing on its own account. `GET /api/mobile/sync`
returned, for every quiz a learner could see, every question in it *with its
correct answer and explanation* — "bulk sync with answer keys for offline
study". It is the same hole that was just closed on the question bank, still
open through a second door, and `GET /api/mobile/quizzes/{id}` was a third. It
also carried a second password-login endpoint with its own rate limiter, which
is twice the sign-in surface for an app that no longer exists.

The tests that reached those endpoints were asserting quiz visibility and
grading, not the transport. They assert the same things through /quizzes and
/attempts now, so nothing is less covered than it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 00:26:35 +02:00
Daniel
c22e7f9547 fix: the answer side of a question needs an attempt, or the job of writing it
Asked for: no tutor on a question outside a session. The tutor is handed the
correct answer and told it may explain it, so it is answer-side content — and
once that rule is written down, the same rule catches two bigger holes:

- `GET /questions/bank` returned `correct_answer`, `explanation`,
  `option_explanations`, `key_points` and `attending_tip` for every question in
  the bank, to any signed-in learner. It is the question manager's listing, but
  nothing stopped anyone calling it: the whole answer key, one request away
  from the questions it answers. Stems are still listed to everyone; the answer
  side now goes only to whoever writes that question.
- The explanation image behind a question was readable by the same rule, with
  no attempt behind it.

"Whoever writes it" is one function now — `may_edit_question` — and it means
moderation, authorship, or an editorial grant that reaches where the question
is filed. Everyone else earns the answer by sitting the question, which is what
an attempt is. The bank browse, the search, the session and the review are all
unchanged; the frontend already sends `attempt_id` everywhere it shows an
answer.

The question manager was reachable by a learner with no grant, and would now
load as a bank of stems with every answer field blanked — a broken page rather
than a door that is not theirs. It says so instead.

Also, while looking at where cards surface: the answer review showed neither
the topic reading nor the cards written against a question, though the player
has shown both under the answer for a while — and the review is the one place
a learner goes through everything they got wrong. The list form of that
component fetched its cards and then dropped them on the floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 00:13:43 +02:00
Daniel
c1391c1554 fix: the phone pass — figures, drawers over panes, and the player's chrome
- A diagram opened on a phone was a caption above an empty screen. The SVGs
  are written with a viewBox and no width or height, so they have a shape but
  no size: Chrome falls back to 300×150, Safari resolves them to nothing inside
  a shrink-to-fit box. Vector figures now get the width they are allowed and
  take their height from their shape. Photographs are untouched — the same rule
  would blow them up.
- "Source: Schematic drawn for PedsHub." now reads "Source: PedsHub", on all
  five drawings, set through the API.
- A drawer opened from the header appeared behind the split-view pane. It is
  the header's own control, and the header is above everything, so the drawer
  is too — and its backdrop now starts below the header rather than covering
  the button that opened it.
- The session header repeated the question counter that the bar above the
  question already gives, and on a 390px screen those two rows were a fifth of
  the scroller, above every question. The counter goes; on a phone the whole
  header card goes, because the drawer already carries the session's name, its
  mode and how much is answered.
- Every control in the question's toolbar now names itself. Below 800px the
  words are hidden and only the glyph is left, so a button whose name lived in
  that hidden span had no name at all to a screen reader and no tooltip to a
  long press. "Clear highlights" is no longer a second pencil an inch from the
  pencil that opens the note.
- The last line of an explanation sat flush on the bar at the foot of the
  player, which reads as content cut off rather than content finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:50:36 +02:00
Daniel
b345709c4b fix: drawers that close, sources that are sources, and a model that does not haggle
Reported from a phone, all of it:

- The menu button opened a drawer and then did nothing. Pressing the same
  button is how a thumb closes a drawer; the only way out was the strip of page
  beside it. It toggles now — chats, contents, a session's questions and a
  finished attempt's rail, all four.
- AI Mode's chat list started at the top of the window, so its first row sat
  behind the header: unreadable, untappable, and covering the button that would
  have closed it. It starts below the header now, the way an article's contents
  already did, and the measurement they share is one hook rather than two.
- The star that saves an article hung its panel from its right edge. That star
  is the first thing in the reading bar, so on a phone two hundred pixels of the
  panel were off the left of the screen, over the title. It measures and picks
  a side.
- Cited questions were listed under "Sources". A question is not something you
  read, it is something you sit — so it stays out of the list and out of the
  count, and still counts towards the session the button builds.
- The session offer counted its questions out loud, which invites haggling over
  a number the learner does not set. "Practise this", then "Your session is
  ready". Twenty is the cap, as it was.
- Asked for five questions, the model explained itself: how many it had looked
  at, what it could go and fetch. It is now told to ignore the number, not to
  apologise for it, not to offer to find more, and to say the same thing again
  if asked again.

Also: AI refine is off the reading page. Drafting is drafting — it belongs in
the editor, next to Save, not on the page a learner is reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:34:24 +02:00
Daniel
3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00
Daniel
2e8f8dda94 fix: moderator flag in /auth/me, and AI Mode answers that go wrong
The moderator-only UI was hidden from moderators. `/auth/me` never returned
`is_moderator`, and the whole frontend decides what to show by reading it —
which is why "Draft with AI" appeared to do nothing: the panel was never
rendered for the person who owns the button.

AI Mode, from the reports alongside it:

- An empty answer was stored and drawn as a blank card. Both the empty reply
  and the reply that is empty once an invented citation is deleted are now
  refused before anything is written, so the question comes back in the box
  with a reason. Old blank cards render an "Ask again" instead of nothing.
- The model narrated its own retrieval — how many sources it had seen, what it
  could go and fetch — when asked for more questions than it could cite. It is
  now told to name what there is in one sentence and stop.
- A thread took its name from the learner's raw typing, so the rail read "hi,
  how do i treat a febrile seizure?". Openers are trimmed, "i" is a capital,
  and the cut falls on a word.
- A cited article opened somewhere else. It now gets the same hover card as a
  cross-reference in prose — excerpt, new tab, and the pane beside the answer.
- A built session said "questions ready" with no number: the endpoint returns
  `count`, not `questions_count`.

And a race the last of those exposed: the guard that stops the transcript
being fetched over an answer that has just arrived was cleared as soon as the
request finished, so whether the exchange survived depended on when React ran
the effect. It now tracks which thread is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:05:55 +02:00
Daniel
e1b8ba012d fix: the AI draft opens what it wrote, and Editorial opens the editor
**"Draft with AI does nothing" was almost true.** The job worked — about forty
seconds, then a draft saved — but the panel closed onto a library that looked
unchanged, and the only progress it ever showed was the same "Drafting…" line.
The job now reports the article it made and the page goes straight into it, and
the wait is counted in seconds so it is visibly a wait rather than a hang.

**Editorial rows open the editor.** It is the queue of things to *do* to an
article; rows that opened the reader made an editor press Edit on every one.

**No Contents button on a phone.** The header's menu opens the contents, and a
second door in the prose is the same mistake the player had. An open drawer now
has a backdrop that closes it, and choosing a section closes it too — it sits
over the prose it just scrolled to.

**References wrap.** A source title is often a filename with underscores and no
spaces to break at, so the list ran off the side of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:37:46 +02:00
Daniel
672e1c2014 feat: citations you can click, and a session offered rather than entered
**The numbers were decoration.** `[1] [2] [3]` in an answer were plain text
that looked like references and did nothing — worse than not numbering them.
Each is now a control that takes the reader to the source it stands for, which
flashes so it is clear which one was meant.

**A session is built, then offered.** "Practise these 3 topics" used to build a
session and immediately leave the conversation for it. Leaving mid-conversation
to sit twenty questions is a decision: the session is now made either way and
the answer says so — *Start now*, or *Later*, which leaves it in the sessions
list and lets the chat carry on.

**And the chats rail is behind the header's menu on a phone**, like a session's
questions and an article's contents, opening as a drawer from the left with a
backdrop that closes it. The in-page "☰ Chats" bar is gone: one control, in the
same place, on every page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:37:46 +02:00
Daniel
18fa1913a4 fix: the contact form's messages were readable by anyone
`GET /api/contact/submissions` had no authentication. `require_admin` was
imported inside the function body and never used as a dependency, so the import
read as protection and was none: anyone who guessed the path could read every
sender's name, email address and message. `PUT .../read` was open the same way.

Both now depend on `require_admin`, with a test that a learner gets 403 and an
administrator gets the list. A row with a null timestamp no longer takes the
whole listing down with it — which is the only reason the hole showed up as a
500 rather than as data.

Also: the tutor's site switch lives in Redis, which the tests share with the
running site, so turning the tutor off in the interface turned a test red. The
test now sets the flag it depends on and puts it back.

And the tutor button is hidden until the server says it is allowed, rather than
shown and then withdrawn — on a site with it switched off that flicker reads as
a bug rather than a policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:37:28 +02:00
Daniel
4b8d66cf2c fix: one door to the questions, and difficulty as chevrons after the answer
**The rail was still drawn twice on a phone.** The fix for it was written next
to the rule it fixes, and lost: `.quiz-player.is-boxed .quiz-sidebar { display:
flex }` sits further down the file at equal specificity, and equal specificity
is settled by order. The narrow-screen rules now live at the end of the file,
with a comment saying why they are there.

**And a second door beside the first.** "Question 2 of 20 ☰" opened the same
list of questions as the menu in the header — a few pixels below the control
that is on every page in the same place. It is text now.

**The more-menu no longer opens off the screen.** It hung leftwards from a
button near the right edge, so on a phone half of it was outside the viewport
("…A FOLDER"). On a narrow screen it is a sheet above the bottom bar instead.

**Difficulty is three chevrons.** A word takes the width of a word, which is
why it only ever appeared where there was room for one; three marks fit a list
row and are read without reading. Where they appear is the part that matters:
in the player only once the answer is on screen, and in the rail only against
questions actually answered. Being told a question is easy while you are
working on it tells you how hard to look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:10:42 +02:00
Daniel
cbff4ab073 content: five diagrams, drawn rather than generated
Salter-Harris I–V, the parabolic profile and its two lanes, the conducting and
respiratory zones, the four types of respiratory failure, and the mole as the
bridge between one atom and a weighable mass. Each is in the image bank with a
title, a description and a source, and embedded in the article it explains — so
it behaves like every other figure: a thumbnail in the prose, the full view on
a click, findable by what it shows.

Drawn as SVG and not generated as pictures, for two reasons. A schematic has to
have every label exactly right and stay right at any size, and one assembled
from shapes is corrected by editing a line rather than by rolling the dice
again. And a generated raster of anything clinical would be a fabrication
dressed as evidence, which is the one thing a teaching figure must never be.

The sources are kept here beside the docs because they are text, and text
belongs in the repository where it can be diffed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 22:00:49 +02:00
Daniel
242e787040 feat: tidy a hand-drawn mark, and see an image as a learner does
**Tidy.** Asked whether the drawing could be straightened like Photoshop, and
whether that needs a model: it does not, and one would be worse — slower,
different every time, and unable to say why. Three geometric questions with
definite answers — is this a straight line, an ellipse, a rectangle — measured
against the points actually drawn, with Ramer–Douglas–Peucker underneath for
the curves that stay curves.

Offered per shape and never automatic, which is the whole design: a trace along
the edge of a lesion is *supposed* to wander, and a tool that straightened it
would be correcting the finding rather than the drawing.

**Preview.** The media library can now open an image exactly as a learner meets
it — full screen, description, source, overlay behind its own switch. Checking
a caption or a mark by imagining the reader's screen is how a mark ends up in
the wrong place on it. The viewer is now its own component, so the figure in
prose and the library open the same one.

And the footer says what was asked for instead of the disclaimer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:50:31 +02:00
Daniel
04abd78380 feat: cards remember, render as prose, and are reachable from a question
Three things the card system did not have.

**Spaced repetition.** There was none. "Known" and "to review" were React state
that vanished on reload, so a deck of two hundred was two hundred cards every
time and the only spacing was whichever cards a learner remembered to skip.
Verdicts are now kept, and the deck comes back in the order the learner's own
history calls for: due first, most decayed first, then never seen, then the
rest — because somebody who has met the whole deck recently should still get a
deck rather than a screen saying come back on Thursday.

It borrows the question player's arithmetic rather than choosing its own.
`recall_probability`, `DUE_RECALL`, the thirty-day half-life: two schedulers
with two ideas of "due", in one product that shows a learner one readiness
number, is how the number stops meaning anything. Two outcomes and no
four-point scale — a scale asks a learner to rate their own recall in units
they have never calibrated, and the extra resolution is noise.

**Cards are prose.** Both faces go through the same renderer as everything
else, so a card can carry `[[264|respiratory failure]]`, a `==key point==`, a
teaching tip or a figure. That is most of what "link cards to things" turns out
to mean.

**A deck is reachable from the question.** Beside the topic-reading chip under
the correct answer, one chip per linked deck. Read from the question's end
only, deliberately: a card that listed the questions it belongs to would hand a
learner revising the deck the shape of the exam, and the answer with it.

Migration m3d4e5f6a7b8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:50:31 +02:00
Daniel
ddd8c4c4e3 feat: the session moves along the difficulty range, per topic
The half of "adaptive" that was never built. Difficulty was a filter a learner
could set and nothing the session did on its own, so somebody at 30% on a topic
and somebody at 90% were asked the same questions in the same order.

A question's value is now scaled by how far its level is from the one the
learner's readiness on *that topic* calls for: under 0.45 wants easy, up to
0.78 medium, above that hard. Per topic because strong on growth and weak on
arrhythmias is the normal case and one number across the two describes nobody.

A multiplier and not a filter, for the same reason the reranker is a
permutation: thinning the bank to one level makes it three times smaller, and
on a narrow topic that is the same eight questions every time. One step away
keeps 0.72 of its value, two steps 0.5, and an unlabelled question 0.92 —
unknown is not wrong.

The first band's edge is 0.45 rather than 0.55 so that a topic with no evidence
— which sits at exactly NEUTRAL_RECALL — gets medium questions. Handing
somebody the easiest questions in a subject nobody has measured them on is a
poor way to find out what they know.

Written up in docs/adaptive-sessions.md, which now says five rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:39:23 +02:00
Daniel
c60295e264 fix: thumbnails may be kept, the embedding model may not be changed here
**Caching.** `/uploads` answered `private, no-store` for everything, so a page
of forty thumbnails re-fetched forty images every time it was drawn. A
derivative may now be kept for a week by the browser that asked for it —
`private`, never a shared cache, because a shared cache in front of
access-controlled images is how one learner is served another's figure. It is
safe to keep because it cannot change: `thumbs/256/<key>` is made once from an
immutable original. Originals still say no-store.

**The embedding model is env-only.** Every vector in the database came from it,
and vectors from different models are not comparable — change it and search
returns noise until 3,000 questions, 334 articles and every card have been
re-embedded. The settings page now shows it as text with Test and Regenerate
beside it, and the API refuses a change rather than ignoring one, naming
`LITELLM_EMBEDDING_MODEL` in the refusal.

**The figure audit retries and gives up.** Its second run met a proxy outage and
reported all 327 figures unreadable, having changed nothing but spent the time.
Three tries each with backoff now, and it aborts after twelve consecutive
failures: a run that says "everything is unreadable" has told you nothing.

**`.env.example` is complete.** It listed 23 of the 53 settings; it now lists
all of them, grouped, each with the default it falls back to and — where it
matters — what happens if it is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:35:26 +02:00
Daniel
409eb53c3d feat: a traced mark is drawn as a curve, not as the dots it was sampled from
Asked how the overlay stays neat, the honest answer was that it did not. A
finger reports a position every few milliseconds, so a freehand mark is a string
of samples, and joining them with straight segments renders every sample as a
corner — visibly faceted on a traced anatomical edge, worse on a phone where the
hand is less steady and the samples are further apart.

Centripetal Catmull-Rom, converted to cubic béziers. Catmull-Rom because the
curve passes *through* every recorded point: somebody tracing the edge of a
lesion has said where the edge is, and a spline that smooths their marks away
from it is drawing something they did not mean. Centripetal rather than uniform
because uniform overshoots into cusps wherever the hand slowed down and the
samples bunched — which on a slow careful trace is everywhere.

Rendering only. What is stored stays the points that were recorded, so the marks
can be re-edited, re-smoothed, or read by something else later. The editor and
the reader's viewer share the one function, so what an educator draws is what a
learner sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:11:17 +02:00
Daniel
500e8430a5 fix: the audit may not detach a clinical figure on its own opinion
The rule was in my head and in a commit message; it needed to be in the task.
The first run detached sixteen good figures — a tick on a leaf against a July
fever, fungal hyphae against a scaly rash, a recessed chin in a two-week-old's
notes, an ECG on a tachypnoeic neonate — because a judgement about relevance was
allowed to act on a photograph, and the connection between a clinical figure and
its question is often indirect.

Two changes. The prompt now separates the two kinds of figure and says how each
is judged: a clinical one — photograph, radiograph, ultrasound, ECG, fundoscopy,
otoscopy, microscopy — is refused only when it is anatomically impossible, and a
table, citation, chart or nomogram is refused whenever it is about another
subject. And the model must now say which kind it is looking at, so the code can
enforce it: "no" on anything clinical becomes "unsure", which flags it for a
person instead of detaching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 21:11:17 +02:00
Daniel
658482c53a fix: one menu, at the left, holding whatever the page has
The previous attempt split it in two — a session button on the left, the site
menu on the right — which is not what was asked for and is worse: a second menu
an inch from the first is a choice nobody wants to make.

One button. It sits at the left-hand end of the bar on a phone, where it always
fits, and what is behind it changes with where you are: the site's sections
always, plus the questions in this session, or the contents of the article you
are reading. A control that changes what it holds must at least not change
where it is.

Reading registers its contents with the same mechanism a session uses for its
questions, so the drawer grows the tab and nothing else has to know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:58:38 +02:00
Daniel
9658ddb207 feat: look at every figure and detach the ones that belong to another question
An HPV vaccination question was carrying a table of craniofacial reconstruction
by age. Extraction takes figures off the page a question was printed on, which
is usually right and occasionally catastrophically wrong — and the mistake is
invisible to a text review, because nothing about the stem or the filename says
the picture is about something else. My earlier pass read stems and answers and
could not have caught it.

So this looks. Every figure goes to a vision model with its question and is
asked whether it belongs: strict about "no", generous about "unsure", because
detaching a figure a question needs is worse than leaving a stray one attached
for somebody to notice. A clear mismatch is detached and the image stays in the
bank with a note saying what happened, so nothing is lost and the decision can
be checked.

Ran over all 341: **30 detached, 2 unsure, 309 kept.** Among the thirty — the
rule of nines on a hepatitis B newborn, a molluscum table on a collapsed
athlete, a textbook citation page on a drowning, and the craniofacial table
that started this.

It also describes each figure while it is looking at it, and keeps the
description. 435 assets said "Figure from question #1206", which is a filename
with extra steps and is why the image bank could not be searched; 335 now say
what they show, and none of the placeholders remain.

The model is named in the task rather than read from the admin's `tool`
setting: this is a one-off audit, and it should not quietly become the thing
that decides what the tool model is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:54:23 +02:00
Daniel
bc29e74842 fix: three things a phone got wrong
**Two menus, one button.** The burger became the session's question list while
a session was open, so the site menu and the question list took turns on the
same control and the one you wanted was the other one. The session now has its
own button at the left-hand end of the bar, where it always fits; the burger on
the right is always the site menu.

**The rail was drawn twice.** `.quiz-player.is-boxed .quiz-sidebar` sets
`display: flex`, which outranks the narrow-screen rule that hides it — so on a
phone the list of questions appeared squeezed into the page *and* in the
drawer.

**The tour pointed off-screen.** A coach mark for a target below the fold
explained something the reader could not see. The target is brought into view
first, centred so the card has somewhere to sit — and left alone when it is
already comfortably on screen, since scrolling then just jerks the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:43:05 +02:00
Daniel
572bf6877c feat: mark regions on an image, and cite where it came from
The drawing tool for the overlay contract: freehand, rectangle, ellipse and
arrow, in five colours and three weights, over the image at full screen. Every
coordinate is normalised to the unit square, so one drawing is right in a
256px thumbnail and on a projector; strokes are drawn in screen pixels rather
than viewBox units, because a unit square stretched over a 3:2 film would
otherwise make horizontal strokes half again as heavy as vertical ones. Pointer
events throughout, so a finger traces an edge as well as a mouse does.

It hangs off the media library's edit dialog, beside two new fields: the
citation and a link to check it.

The drawing tool was written by a Fable subagent against
docs/image-overlays.md, in three files of its own; the wiring, the schema and
the reader's half are mine. Its judgement calls I have kept: Cancel asks before
discarding a traced edge (a stray tap on a phone is the same harm as Escape),
undo is a history stack rather than "remove the last shape", and freehand
points closer than 0.0025 apart are thinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:43:05 +02:00
Daniel
4782e2f1a4 fix: AI Mode opens on the starters, not on a paragraph about itself
The rules were explained before anybody had asked anything. What the assistant
will and will not do is visible in the answers themselves — each carries its
sources, or says it has none — and a page that explains itself up front is a
page nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:34:46 +02:00
Daniel
809ac2fca6 feat: figures are thumbnails that open into a viewer, with source and overlay
A 2,000px radiograph written into an article rendered at whatever width it
happened to be — a wall of greyscale in the middle of a sentence, four
megabytes to draw it, and no way to look at it properly. Every image in prose
is now a 256px thumbnail with the author's label under it, and a click gives it
the screen.

The viewer puts what is known about the figure beside it: its title, what it
shows, and where it came from. `media_assets` gains `source` and `source_url`
for that — a citation belongs to the file, because the same figure used in
three articles is cited the same way in all three, and a licence that turns out
to be wrong is one row to fix rather than three paragraphs to find. Asked for
when the figure is opened, not when the page is drawn.

And `overlay`: the regions an educator has marked, as vector shapes in
normalised coordinates on the unit square, so one drawing is correct in a
thumbnail, in the viewer and on a projector. Off until the learner turns it on
— marks shown before they have looked answer the question for them. Vectors
rather than a second burnt-in picture, for four reasons written down in
docs/image-overlays.md. The tool that draws them is next; this is the storage,
the contract and the reader's half.

On a narrow screen the description stacks above the image rather than beside
it, where it can be read before scrolling to the picture.

Also here: `classify_question_difficulty`, which labelled all 2,924 questions
in batches of twenty-five against a written rubric — 622 easy, 1,634 medium,
668 hard, no failed batches. The column had been NULL on every row since it
existed. Migration l2c3d4e5f6a7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:33:00 +02:00
Daniel
0d41179b30 feat: the topic behind the right answer, as a chip where the eye already is
Reading links existed and sat at the foot of the whole explanation block, under
everything else, as a headed list. A learner who has just been shown the right
answer is looking at the right answer — so the link now sits directly under it,
as a chip, the way a reference site does it. Outside the option's button rather
than inside it: a link nested in a button is neither, and the click would have
toggled the explanation instead of opening the article.

Also a real bug beside it. Key points were gated on `option_explanations`
being non-empty — the wrong field entirely — so a question with key points and
no per-option reasoning showed none of them. It happens not to bite today
because exactly one question in the bank has either, which is its own finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:18:05 +02:00
Daniel
2d80df8d73 feat: the id, the slug and the marker at the top of the editor
Both identifiers were looked for and neither was on the page: the id had to be
read out of the address bar and the slug out of a form field below the fold.
They are now two chips under the title, each copying itself.

The third chip is the one that earns its place — the cross-reference marker
itself, `[[264|Pediatric Respiratory Failure]]`, id and title already
assembled, because that is what somebody wants those two things *for*.

And the broken-link check finally shows. The backend has computed
`broken_links` on every save since markers existed and nothing had ever
rendered it, which made the check pointless: a dead cross-reference found a
week later belongs to nobody. Saving now says which markers point at nothing —
and saves anyway, because a link to an article that has not been written yet is
a note to write it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:06:56 +02:00
Daniel
dadd2447b9 fix: the landing figures count when you can see them, and no contact form
The counters ran on mount, which is while the visitor is still reading the hero
two screens above — so the animation finished before anybody could see it and
the numbers simply appeared. They now start when the figures come into view,
and a ref stops a later re-render sending them back to zero.

The contact section is gone, and with it the Contact link in the footer. The
endpoint behind it is untouched, so the form can come back somewhere else
without being rebuilt.

And the clinical tools say "the full vaccine schedule" rather than naming the
two bodies that publish it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 20:06:56 +02:00
Daniel
448bfdd71c fix: the difficulty facet counts itself, and the adaptive item is closed
Two halves of one TODO, settled with a measurement rather than a guess.

*Shrunk readiness* was already done — `CandidateRanking.accuracy()` pulls a
topic towards NEUTRAL_RECALL by PRIOR_ANSWERS, so one miss does not read as 0%
— and there is now a test pinning it, because the note claiming otherwise
outlived the fix by weeks.

*Difficulty as a dimension the session moves along* cannot be built, and the
reason is a number: all 2,924 questions have a NULL `difficulty`, and the
empirical route is no better at 788 answers over 706 questions from 5 learners,
about one answer each. A ladder scored against that would be scoring noise
while looking as though it worked.

What is built instead is honesty in the control that already exists: the
Difficulty facet counts each level under the other filters and disables one
that would empty the bank, so nobody picks Hard and watches the count fall to
zero with no explanation. Reopen the ordering when something writes that
column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:21 +02:00
Daniel
afc223d79e fix: an article opens as the article, and Editorial can delete
Opening on the high-yield view was a defensible default and a bad one in
practice: an author who had written ten sections opened their own page, saw
two, and reasonably concluded the rest had not saved. It also made every
reader's first impression of a topic its revision card. The article now opens
on the fullest reading it has, and High yield is a switch you throw.

Delete is on the Editorial row as well as in the editor. Having it only in the
editor meant opening an article in order to get rid of it.

And the Library button is gone from the Editorial header: it is the Reading
page, it is in the menu, and a third door beside the two controls that are this
page's own job is a door in the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:21 +02:00
Daniel
5670ebbdda feat: the deck studio is a deck being made, not three pictures of slides
Three improvements and one real bug.

The bug: `.lp-sl-wash` is absolutely positioned and ends in an opaque white, so
it painted *over* its static siblings — the title slide laid its rule, heading
and picture out correctly and then covered them with its own background. An
explicit z-index on the wash and its siblings fixes it.

The improvements: the slide now sits inside editor chrome, with an export
control that lights up as each slide lands, because three slides in a row are
three pictures and the same three inside a window are a deck being made; a
pointer moves across the canvas, places two things and presses export, which is
what makes it read as being *built*; and the artwork is worth looking at — a
figure on the title slide, a line chart with gridlines, a shaded band and
plotted points, a two-segment ring and a legend. Each slide carries its own
accent, so the three do not look like one slide shown three times.

The warm accent was half theme and half amber, which against a blue primary is
the colour of a wet pavement. It is mostly amber now.

Also: "Pediatric clinical tools" is "Clinical tools", for the same reason the
hero no longer says pediatric.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:06 +02:00
Daniel
831cb01650 feat: an article follows a topic, rather than copying it once
"Questions filed there later are not added" was the honest description of what
the previous commit built, and it was the wrong thing to build. "The Cardiology
article covers the Cardiology questions" is a standing statement about the
material, not a snapshot of who happened to be filed where on the afternoon
somebody pressed a button — and a copy stops being true the first time a
question is added, silently, with nothing on any screen to say so.

So the claim is now stored, and it is what writes the links:

* `question_article_links` is still the **only** table anything reads. No count,
  no QBank button, no mirror panel on a question, no AI Mode boost learns a
  second question to ask.
* `article_topic_claims` records *why* some of those rows exist, and is the one
  place that makes them — when the claim is staked, when a question is filed
  into the category (single, bulk, or on create), and on a half-hourly sweep
  that catches whatever bypassed both.

A link made this way is an ordinary row and can still be deleted by hand; a
sweep puts it back, which is the honest consequence of a standing claim.
Dropping the claim is how you stop it, and the panel now lists what an article
follows with two ways out — stop following and keep the links, or stop and
remove them.

Migration k1b2c3d4e5f6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:57:06 +02:00
Daniel
d834ac830a fix: the drafter writes all three views, and says its errors out loud
Two things, both about the AI draft nobody could tell was working.

The prompt never mentioned that a section belongs to one of three readings, so
every generated article was one long view and the other two tabs stayed empty
— on 300-odd articles. It now describes what each view is for, asks for the
high-yield one as tight lists with `==key points==` marked, says to omit the
clinical view for a topic with no bedside, and says explicitly not to
categorise or link the article, because those are an educator's judgements.

And an unknown or missing variant is read as "long", which is what every
section written before this already is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:31:28 +02:00
Daniel
aafea65a52 feat: delete an article, and a trash for the ones that were published
There was no way to delete an article from anywhere in the interface. The API
had one; the only route to it was curl.

Now there is a control at the foot of the editor, and it does one of two things
depending on the article's history — and says which before it is pressed:

* A draft that was **never published** is deleted outright. There is nothing to
  restore, and a trash full of abandoned stubs is a second list to maintain.
* Anything that has been published, even once, is **marked** and appears in the
  trash on Editorial, restorable exactly as it was. Somewhere there is a
  learner's note against one of its sections, a question linked to it, and a
  link somebody sent a colleague; a DELETE typed in the afternoon should not
  settle any of that.

`first_published_at` is what decides, stamped on the first publish and never
cleared — unpublishing does not make an article unseen, so it does not make
deleting it safe either. Backfilled from `reviewed_at` for everything currently
published, because an article with a null stamp reads to the rule as a
never-published draft.

A binned article is out of the listing, the editorial queue, every slug and id
lookup, and — immediately — the search index, so it cannot still answer a
learner's question from the trash.

Also on Editorial, because a hundred rows is a queue you work through and not a
page you scroll past on the way to the next queue: each bucket keeps its own
box, its own scrollbar and its own filter.

And the editor finally has a way out that is not Save: Back and Discard, with
an inline confirmation when there are unsaved changes. The way out was the
browser's back button, which throws the sitting away without saying so.

Migration j0a1b2c3d4e5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:31:28 +02:00
Daniel
db242d7e83 feat: link a whole topic to an article in one action
One question at a time is right for a cross-reference and wrong for "every
Cardiology question belongs to the Cardiology article", which is most of what
an educator is doing in that panel.

Choose a category and every question filed under it — primary category or
additional, and its subtopics unless you say otherwise — is linked in one go.
The count is fetched first so the button carries the number: "Link 43
questions" is a decision, "Link this topic" is a guess, and the difference
matters when the topic turns out to be the whole of Cardiology. Capped at 300,
with a refusal that names the number and suggests a subtopic.

Ordinary link rows, not a rule. The article does not "claim" the category; it
gains the questions in it now. A rule would be a second answer to "which
questions belong to this article" — one the counts, the QBank button, the
mirror panel on each question and the AI Mode boost would each have to learn to
ask — and it would silently attach tomorrow's questions to an article nobody
has looked at since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:31:28 +02:00
Daniel
2472435863 docs: how article links, highlights and views are actually written
The syntax was known only to the code that parses it. This is the author's
side of it: `[[264|respiratory failure]]` and why the id form is the one that
cannot rot, `==key points==` and the three rules the parser follows,
`{{phrase|tip}}`, what each of the three views is for, how to link a question
or a whole topic, and what deleting does to a draft versus to something that
has been published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:30:43 +02:00
Daniel
7f5e691670 docs: rerank, vision, uploads and the small-talk gate in the synopsis
Four things a later session would otherwise have to rediscover from the code,
including the two whose whole point is that they run before the obvious place
to look for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:10:08 +02:00
Daniel
a020e2bb31 fix: a greeting does not name the thread
The first message named the conversation, so a rail of them read "hi", "hello",
"hi". The name now waits for the first turn that is actually a question —
usually the very next one — and the thread stays "New chat" until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:09:55 +02:00
Daniel
c099dd16fb fix: the player's furniture stops pushing the question down the page
Three things above the stem — the session's name, the mode row, the "Question 4
of 12" counter — carried eighteen pixels under the header, twenty over the
counter and a line of spacing inside it. Together that was a third of the
screen before the first word of the question. Tightened, all of it.

The clock beside them may no longer be the part that gives: squeezed, "0h 00m"
broke after the hours and each of the three readings became two lines of its
own height, which is what pushed the group out of the bar it sits in.

And Next is an equal half of the bar rather than two thirds of it. It is the
only coloured thing down there; it does not also need to be a blue band across
most of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:09:55 +02:00
Daniel
d591813f02 fix: an upload is what its bytes say, not what its name claims
The document matcher decided by extension and fell through to "decode whatever
this is as UTF-8" for everything that was not a .pdf. A shell script, an HTML
page, a CSV or a JPEG all became a search query, and a file called report.pdf
holding something else was read as a PDF.

Three questions now, in order, before anything else touches the file: is it
under 2 MB (read one byte past the cap, so a huge file is never held in memory
to be measured); what is it, sniffed from the leading bytes; and what text is
in it. PDF, DOCX and images, and nothing else — 415 with a sentence naming what
to upload instead. DOCX is parsed from the zip with no new dependency and is
checked against the one attack that shape allows, a member that unpacks to far
more than the file's size suggests. An image is read by the tool model, and
where no tool model is configured it says so rather than 500ing.

On injection, since that is the question people mean: there is no path from an
uploaded file to code that runs. The extracted text is a search query — bound
parameter to Postgres, never concatenated into SQL — and the content of a
message to a model. It is never rendered as HTML, never written to disk, never
passed to a shell. Control characters are stripped because they make queries
that match nothing, not because anything would interpret them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:05:12 +02:00
Daniel
a765913440 feat: ==key points==, highlighted in yellow
A summary where nothing is emphasised is a shorter article, not a revision aid.
An educator can now run a highlighter over the sentence that matters, from the
editor's toolbar or by typing the markers, and the reader sees it in yellow —
most usefully in the high-yield view, which is what it is for.

A remark plugin rather than a rewrite of the source string, for the same reason
as tip terms: a reader's own highlights and the read-aloud cursor are stored as
offsets into the raw markdown, and editing the string before it is parsed would
shift every offset after the first key point. Splitting the parsed text node
keeps each piece pointing where it came from.

`<mark>`, not a styled span: find-in-page and print already know what it means,
and so does a screen reader. A lone `==` stays the characters it is — "Sodium
== 140" is a lab value — and a highlight may not run across a line, so one
stray marker cannot swallow a section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:05:12 +02:00
Daniel
a55644818e fix: define the two form classes nothing defined, and peek the nav on hover
`.form-label` and `.input` are written in four places — the articles page, the
flashcards page, the article editor, the category tree — and were defined
nowhere. An undefined label class is an inline element, so the label sat flush
against an unbordered box: "Titlee.g. Febrile seizures". They are now the
standalone form of the `.form-group` rules, defined once, because a form should
not look different for having no wrapper around it.

And the section bar comes back when the pointer is on the logo. It hides while
you read, which is right, but scrolling up was the only way to see it again —
no use to a reader looking for the way *out* of the page rather than the way
back up it. Focus does the same thing, so a keyboard reaches it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:05:12 +02:00
Daniel
1be6d10550 feat: a QBank door on every article, which says why it is empty
The offer to practise a topic was a paragraph under the last section: it
appeared only where questions happened to be linked, explained itself at length
to everybody including the reader who never wanted questions, and sat where
nobody looks.

Now it is a button in the controls row, on every article whether or not
anything is linked — and that is the point of it. A reader's question is "are
there questions on this?", and a control that vanishes when the answer is no
cannot answer it. With nothing linked it opens a dialog that names the study
objective, says there is nothing for *that* objective, and offers a custom
session; the objective is usually the whole reason, and a learner who cannot
see which one they are on has no way to tell that from "nothing exists on this
topic at all". With questions behind it, it is the builder it always was.

Reading pages still never show a stem, an answer or an explanation.

Also: the two selects stack and the dialog scrolls inside itself under 520px,
and every control is left to the site-wide 16px rule for coarse pointers — a
field under 16px makes iOS Safari zoom in on focus and never zoom back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:04:51 +02:00
Daniel
ce8074cbe3 fix: a greeting is not a query, so nothing is searched for one
"hello" came back with four confident citations and a list of topics the
learner might like, drawn from questions about caustic ingestion. Retrieval had
done nothing wrong: reciprocal-rank fusion always returns an order, and the
similarity gate that exists to catch this is a threshold — "hi" embeds at 0.46
against a corpus of clinical prose and is caught, "ok" at 0.51 and "good
morning" at 0.50 are not.

So the decision is made before any measuring: a message made entirely of
pleasantries, or asking what the assistant is, gets mode "chat" — no retrieval
at all, no shortlist to cite from, and a prompt that says what it can do
without claiming to know what is in the library, because it has not looked.

The vocabulary is closed rather than a length rule, so "croup dose?" is still a
query. A missed greeting costs a slightly odd reply; a swallowed question costs
an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:03:30 +02:00
Daniel
177df84817 feat: a deck building itself on the landing page, and no captions anywhere
Three things this site does, and the third had no picture. A talk built from
the same evidence the bank is written from — a template, the figures dropped
in, exported as PowerPoint — is hard to say in a sentence and easy to show, so
this says nothing at all: a slide assembles itself, the deck beside it lights
up as each one lands, and the eye works out what it is watching. The one link
is navigation, not explanation.

Three slides share a 21s loop, each a third of a turn behind the last by way of
a negative delay, so there is no clock in JavaScript and nothing to unwind on
unmount. Every part inside a slide rides the same loop and the same delay plus
its own `--d`, and that is what staggers the build. Under calm motion the loop
is not applied and the three lie side by side, finished.

Also here, because it is the same argument: the study panel's caption is gone.
The panel is a picture of a question marking itself, which is the whole of what
the caption said, and a screen reader is told that by the figure's own label.

And the hero no longer says "pediatric". The domain says whose site this is,
more steps are coming, and a line naming one exam has to be rewritten the week
the next one is added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 19:03:30 +02:00
Daniel
532d613393 feat: question folders, per-section notes, and two feedback paths
Four things that share a spine, so they arrive together.

**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.

Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.

**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.

**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.

The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:37:43 +02:00
Daniel
73ab2515f5 fix: ship the three files the overview page imports
`0700e68` and the two commits before it added the overview, its stylesheet and
the search-panel context, and committed only the page. App.jsx imports
`./context/SearchPanel` and DashboardPage.jsx imports `./DashboardPage.css`,
neither of which was in the tree — so master has not built from a clean clone
since. It built here because the files exist on this disk.

Nothing new: the four files as they already are, plus SearchOverlay's `tab`
prop, which is what the context passes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:37:23 +02:00
Daniel
031de53034 feat: rerank what a learner is shown, with Cohere through the proxy
Retrieval fused a bi-encoder and BM25 by reciprocal rank. A bi-encoder embeds a
document long before the question exists, so the two never meet: it is good at
"same topic" and mediocre at "answers this". A cross-encoder reads the pair.

The proxy already serves three — `cohere-rerank-v4.0-pro` is the default and
measurably better than the fast variant. Query text goes exactly where the
embeddings already go, and nothing new was signed up for.

It found a defect nobody was looking for. In AI Mode each finder scored
`1/(1+rank)` *within its own corpus*, so the best article, section, question and
card all scored 1.0 and the shortlist was a meaningless round-robin. A
cross-encoder is the first thing in this system that can compare a question
with a section. Candidates per kind widened so it can select rather than merely
reorder.

Measured against labels neither ranker produced. Questions, 60 disease tags:
precision@3 0.394 → 0.483. Sections, 60 article titles: 0.772 → 0.833.
"Management of bronchiolitis" led with influenza transmission and a pregnancy
question; "when do you image a first febrile seizure" returned the definition
rather than the sentence saying imaging is unnecessary.

And the honest negative, in docs/reranking.md: board vignettes are written
*not* to name their diagnosis, so on "what causes croup" it prefers a question
that says the word in passing over the barking-cough vignette that never says
it. Some of the bi-encoder's strength is traded away.

Not on the typeahead. A page of results is a choice being made and worth a
third of a second; a typeahead is a word being finished, runs on every
keystroke, and has nothing to judge yet.

The three-state thresholds stay on cosine, argued at the constant: a reranker
only ever sees a shortlist and structurally cannot answer the corpus-wide
question those numbers ask, and whether an answer claims to come from the
library is a promise that must not depend on a network hop.

Every failure returns None and leaves the order alone — unconfigured, no proxy,
connect error, bare 502, timeout, non-JSON, a duplicate or out-of-range index,
a non-numeric score, a list the wrong length. Verified against the running site
with a bogus model name: same results, fused order, no error to the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:23:06 +02:00