Commit graph

219 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
Daniel
ab059673e5 content: every article has a high-yield view, and three ties that were not ties
**Missing views.** The note said 10 articles without a clinical view and 13
without a short one; the real numbers were 8 and 11, and the eight originals
turned out to carry no `variant` key at all, so everything in them was being
read as the long view. 33 sections written across 11 articles, every sentence
traceable to that article's own body, and the legacy sections stamped with the
variant they were always being treated as. No doses appear anywhere, because
not one of those eight states one. Every article now has a high-yield view and
`mdm_pass check` reads 330 of 331 conforming with no placeholder sections.

**Pediatric Dentistry** was neither an educator's job nor a delete. Its body is
real — scope, disparities, records and consent, public health, biomaterials —
and only its clinical view was the disclaimer. The three placeholder sections
are gone and the article stays: the reader hides a view an article does not
have, and a discipline overview about consent and public health has no bedside
path to write. Inventing one was the thing not to do.

**The 15 tied conditions were stale**, ticked with the evidence rather than
worked: the taxonomy rebuild gave every one of them a single home, and the
duplicate-name query now returns nothing.

**The 31 tied questions were 3**, and reading all three showed none was a real
tie. The best of them: a question about acute flaccid paralysis after minor
trauma was filed between "Nursemaid's Elbow" and "Radial Head Subluxation" —
two names for one condition, neither anything to do with the question. It is
under Spinal Cord Injury now, cross-filed to Acute Flaccid Paralysis, and the
two elbow links are removed rather than cross-filed: a wrong link surfaces a
question under a topic it does not belong to, which is the opposite of what
cross-filing is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:16:59 +02:00
Daniel
dd7bd3668e feat: an account may have no password, and may set one later
Single sign-on wrote a random string nobody would ever know. That reads as
"has a password" to everything that asks — so Settings demanded a current
password before it would let those accounts set their first, and the only way
through was to click "forgot password" for a password they never had. The same
trap was waiting for anybody who only ever signs in with a code.

Null says the true thing. Signing in refuses an account with no password the
way it refuses a wrong one, because which accounts have one is not a question
that endpoint answers. Setting a first password asks for no current one;
changing an existing password still does. `/auth/me` reports whether there is
one at all and nothing about it, because Settings has to choose between "Set a
password" and "Change password" and cannot tell from the outside.

The random strings already written are left alone. They are unguessable, so
nothing can sign in with them, and clearing them would mean deciding from
outside which accounts were meant to have one.

Identity is the email address throughout, so the three ways in are three ways
into the same account: single sign-on, a code, or a password — and a person may
acquire or drop the third at any point without losing the other two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:39:29 +02:00
Daniel
25a9a8aca4 feat: sign in with a code sent by email
A password is a thing to remember and a thing to lose. Somebody who can read
their own mail can now sign in without one: ask, receive six characters, type
them into the page that is already open.

A code rather than a link, and the difference is not cosmetic. The token in a
link was 256 bits, unguessable however long it lived, so its length, its expiry
and its rate limit were three independent decisions. Six characters is 2^30,
and the three stop being independent — so they are argued together:

  * six characters of the invite alphabet, imported rather than copied, because
    there should be one answer to which characters a person may be asked to
    retype and that one already drops O/0 and I/1;
  * a code answers five guesses and is then retired, not slowed — whoever is
    typing has lost the mail or does not own it, and both are one click from a
    new one;
  * one code live per person, since several would mean one guess tested against
    all of them;
  * ten verify attempts per address per fifteen minutes, so nobody buys five
    fresh guesses at a time by asking again.

Tens of guesses an hour against a billion, and the victim gets a mail for every
code burned. Eight characters would buy a thousandfold against an attack the
guess budget has already ended, and cost every person two more characters.

The attempt count lives in the row, not the cache. The Redis limiter fails open
when Redis is down, which is right for what it usually guards and wrong for the
only thing standing between a patient stranger and six characters.

Verifying is scoped to the address. A short code looked up on its own would be
tried against every code live on the site at once — the short code's one real
weakness, closed by knowing whose code it should be before comparing.

Fifteen minutes, because a first mail between strangers is routinely greylisted
five to ten and a code that expires before it arrives is not a sign-in method.
Shortening it buys nothing: one code is live and it answers five guesses
however long it sits there.

Nothing distinguishes an address with an account from one without — same
message, same status, same duration, and both rate limits counted before the
account is looked up, so a 429 cannot become the tell. Redis keys are
fingerprints, and the table holds a fingerprint rather than the code.

SSO stays first where it is configured, and a password is still one click away
for anybody who has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:29:28 +02:00
Daniel
1c66eb84b1 fix: two ways to be told an address has an account, and a door to writing
`/auth/forgot-password` and `/auth/resend-verification` both take care to say
"if that email exists" and both then answered the question anyway.

The reset limiter returned early for an unknown address, so it counted nothing
for one and counted for the other: ask four times and a registered address
gets 429 while an unknown one gets 200 for ever. It counts either way now — in
Redis for an address with no rows to count, keyed by a fingerprint, because a
list of addresses somebody tried is itself worth not keeping.

Resend answered "Email already verified." for a known verified address and "if
that email exists" for everything else, which is not a hint but an answer. One
sentence for every outcome now.

And Editorial has a way to write something. Drafting was only reachable from
the library — a page about reading, behind a button an educator arriving to
work has no reason to look for — so the two panels now open from a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:18:30 +02:00
Daniel
3418ed023b fix: WebP figures, the openai SDK removed, and a voice a site can add to
Three things landed together; the message names all of them, because a commit
that mentions one is a commit nobody finds the other two in.

**Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached
in the media library — are WebP now, with `questions.image_path`,
`questions.explanation_image_path` and `media_assets.path` repointed together.
Serving already converted them on the way out, so nothing was broken; this
removes the step and makes what is stored the same thing that is served. The
originals stay: they are the only copy of what came out of the PDF, they cost a
few megabytes between them, and a conversion nobody can undo is not one to run
against a live bank. Paths are found by what the columns say rather than by
listing a bucket, because three tables record them and updating two would be
worse than none.

**The openai SDK is gone.** Ten call sites — one more than the map said, the
Celery article drafter — every one of them a POST with a JSON body, and not one
reading usage, cost, tool calls or logprobs. Every other call to the same proxy
was already plain httpx: embeddings, the ChromaDB embedding function, speech
both ways, model discovery, the vision probe. So this deletes an abstraction
rather than swapping one for another, and leaves one HTTP client instead of
two. `chat()` and `achat()` return the message content; a `ProxyError` carries
the status and the first 500 characters of the body, which is where the proxy
explains itself.

Behaviour is preserved deliberately, including a 600-second fallback timeout
for the four call sites that were running on the SDK's ten-minute default.
Lowering that is a real change and belongs in its own commit.

Proved against the live proxy on both services rather than only against mocks:
a completion, an async completion, a real 400 the vision probe still classifies
as a refusal, 407 models read from the catalogue, and a word read off an image.

**Voice.** A chosen voice is honoured whatever serves it. The prefix check only
accepted a locally served one, so a site adding a hosted voice would offer it
in Settings, save the learner's choice, and then quietly read every question in
the default voice. The list has always come from the database — adding a voice
is a row in Settings → AI models, never a code change.

And the sign-in page stops offering a locked door: `signup-policy` reports
whether registration is open at all, and the Sign up link goes when it is not.
The switch existed and the only way to discover it was to fill the form in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 17:13:05 +02:00
Daniel
3f57bda6aa feat: a reading voice in Settings, and a tutor that reads every figure
The voice picker was a dropdown in the quiz player, beside the question — the
one control on that screen with nothing to do with answering it, and one a
learner sets once and never touches. It is a setting now, on the user rather
than in a Redis blob, with a play button beside each voice because a voice is
worth hearing before it is chosen. Choosing nothing stays a real choice: it
means whatever an administrator marked default, so a site that changes its
default reaches everybody without a row being edited.

The tutor reads figures from `question_media` rather than the two legacy path
columns. Those agree exactly today, so nothing was being lost — the first
question given a second figure in the editor would have been the one that
broke it, silently and only for the tutor. The legacy columns remain as a
fallback for anything not projected into that table yet.

And the retrieval thresholds are written down in docs/retrieval-thresholds.md:
the three answers, the sixteen queries they were measured against, why they are
deliberately not the retrieval floor, and how to re-measure when the corpus
grows. Worth keeping the headline in mind — "discuss love" scores 0.491,
alongside "tell me a joke". A number in the 0.4s is noise, not a weak signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 16:48:09 +02:00
Daniel
6203c3a92f fix: figures a browser will actually draw, and two requests that could hang
Twenty-one stem figures are JPEG 2000. Chrome dropped it in 2015, Firefox and
Edge never had it, and the slim base image ships no MIME table — so
`guess_type` returned nothing, the fallback was `application/octet-stream`, and
`nosniff` finished the job. Those figures rendered nowhere but Safari.

The bytes were never the problem: Pillow decodes JP2 here perfectly well. Only
the delivery had to change, so it changes the way everything else already does
— through the thumbnail machinery, as a cached WebP derivative, stored beside
the original. A format no browser draws now asks for conversion whatever size
it was requested at, decided by the file's own magic rather than by the query
string. The 41 KB original comes back as an 83 KB full-size WebP or a 5 KB
thumbnail, and the stored file is untouched.

`.jp2`, `.jpx`, `.jpf` and `.webp` are registered at import, because a
container with no `/etc/mime.types` is a container that mislabels every one of
them. `.webp` had no figures behind it yet and would have failed the same way.

Two calls could hang for ten minutes. The SDK reads for that long by default
and this client retries nothing, so a stalled connection is a stalled request —
three of them in extraction, which does its own retrying. Both now pass an
explicit two-minute timeout.

Also removed: `EMBEDDING_PROVIDER`, which looks like a switch between a local
encoder and a remote one and is read nowhere, with a comment claiming
embeddings run locally when they have always gone over the network to the
proxy; and a `.replace("openai/", "")` that existed only to undo a prefix
nothing adds any more. The JPEG 2000 comment named the wrong mechanism — the
filename is no guide because there is no MIME table, not because it lies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 16:29:45 +02:00
Daniel
17f238bded feat: three answers, chosen by a number rather than by the model
Retrieval could not say "nothing". `hybrid_ids` fuses two rankers by reciprocal
rank and throws the distances away, and it returns the union — so the shortlist
was never empty, the "nothing matches" branch never fired, and a question about
photosynthesis came back with six paediatric sources and an instruction to
answer only from them.

So the fix is not more scenarios in the prompt. It is one calibrated number,
and three short prompts chosen by it in code. Asking a model to work out which
situation it is in is the part that does not work, and it is also the part that
makes prompts long.

Measured against this corpus with the bodies now embedded — eight clearly
on-topic questions and eight clearly off-topic:

  off-topic  0.339 – 0.499   the French revolution … photosynthesis
  on-topic   0.586 – 0.740   what causes croup … posterior urethral valves

The thresholds sit in the gap. They are deliberately not the retrieval floor:
that one decides what is worth putting in a list, where a weak hit costs a
reader a glance. These decide whether an answer claims to come from the
library, and a wrong claim costs them their trust in every other answer.

Above 0.55 the answer is sourced and cited, as before. Between 0.50 and 0.55 it
says nothing covers this directly, names what the closest material is, and
marks which parts came from where. Below, it says so in one line and then helps
anyway from general knowledge, citing nothing — refusing outright reads as a
broken assistant rather than a careful one, and the shortlist is not handed to
a model that has just been told the library does not cover the question.

An unmeasurable closeness is not a low one. No vector database or a downed
encoder returns None, and retrieval still found its rows by other means, so
those are still cited; dropping every citation because the ruler is missing
would be the worse failure.

Also: only published articles are indexed now. A draft is unfinished by
definition and has no business in a search result or in that shortlist. The
index follows publication both ways, and the fifteen-minute sweeper drops rows
whose article has been deleted or unpublished — an article that is never edited
again would otherwise keep its rows for good.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 16:15:05 +02:00
Daniel
8362d706ac feat: search that looks at what an article says
Both halves of hybrid retrieval were reading the same 331 titles and summaries.
The lexical half was fixed earlier; this is the semantic one. `content` is NULL
for 323 articles because the generator writes into `sections`, so the vector for
98% of the library described the heading and nothing under it.

Depth is carried by the section index, where the longest section in the corpus
is under the embedding clamp — so every sentence of every body is embedded whole
somewhere, and nothing is truncated at that level at all. The article vector is
a topical signal instead: title, summary, the full outline, and an even slice of
every section's opening, budgeted so the clamp never silently fires. Round-robin
rather than head-and-tail, because truncating the head of a twelve-section
article stops in the pathophysiology and drops treatment and management — which
is where the words somebody actually searches for live.

`article_section_index` is populated and stays populated. The rebuild was a
private helper in one router, so the three other writers that save sections —
the generation task, the pipeline script and the seeds — silently skipped it.
That is how 323 articles came to have no rows at all. The generator itself is
one line poorer for it now.

A retrieval bug found on the way: the section-to-article rollup concatenated
rather than fused, so a section matching at rank 1 landed behind every weak
whole-article match and never reached the page. And `/articles/?q=` had no
rollup at all.

3,833 vectors in 332 seconds, batched 32 to a request — a normal article save
is now one round trip rather than fourteen. Proved against the vectors restored
from backup: "surgery for infant stridor that fails to improve" found
Laryngomalacia at rank 159, below the floor and invisible; it is rank 1 now, and
the section corpus answers it at rank 1 having previously been unable to answer
it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:57:42 +02:00
Daniel
c6660c68ed content: 95 more summaries that listed topics instead of saying anything
The colon pattern found 36. A verb-presence sweep found 75 more, and it was
wrong in both directions: it spared 21 genuine discipline overviews whose verbs
were simply not on the list, and it passed catalogues whose nouns are spelled
like verbs — "Mechanism, staging, and management of hypoxic-ischemic
encephalopathy, the leading cause of neonatal brain injury" satisfies a test
for "cause" and contains no verb at all.

A whitelist cannot tell those apart, so the first sentence of all 241 remaining
summaries was read rather than filtered, which found 41 more. 131 of 331 are
now claims instead of contents lists, in the shape of the one that worked:
what the condition is and who gets it, then what changes management.

The eight seeded demo articles all carried the same "Starter article for
demonstration" line as their summary. Each now has a real one written from its
own body — see the note below, because that line was doing a second job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:51:09 +02:00
Daniel
ce1c0775ab feat: a session prepared for you, and a model that can see when the one on the job cannot
**Prepared sessions.** Most of this existed: unanswered first, weakest topic
next, wrong-before-right after that, all scaled by what share of the real paper
each topic carries. What it could not do was change with time, say anything
about itself, or be reached without filling in a form.

Evidence now decays on a thirty-day half-life. Exponential rather than a fixed
window because memory has a slope, not a cliff — under a window, 29 days counts
fully and 31 counts for nothing — and because it is memoryless, so an answer's
weight does not shift when unrelated questions are answered, which is what lets
the preview stay a valid forecast. Spring is worth an eighth of last week. Two
things decay: a question's recall probability, drifting towards even rather
than past it, so an old right answer becomes eligible rather than wrong; and a
topic's accuracy, against a prior of two "no idea" answers, which fixes "right
once, known forever".

Strict unanswered-first meant that on a bank of 2,900 nothing was ever
recycled — spaced repetition existed and was unreachable. Review now takes up
to two fifths of a session. And the damping that spread the picks across topics
was applied only to seen material, so a learner with no history was handed the
heaviest domain entire instead of a spread; that was live.

The plan is the product. It is computed, shown, and then the session is built
from that plan's own ids and the plan returned with it, so the two cannot
differ; every figure in it is a tally over the chosen questions rather than a
forecast. No model touches the ranking — a learner asking "why these twenty"
has to get the same answer twice.

**Vision.** The proxy's own `/model/info` says which models can see, so nothing
is hard-coded: 77 report yes, 11 no, and 328 say nothing at all, which means
absent rather than incapable — so those are asked once with an 8px PNG and the
refusal cached. The deployment's main model turns out not to see, and questions
carry figures the learner is looking at, so the tutor was answering about an
image it had never been shown. It routes to a configured tool model now, folds
the description back in as text saying plainly where it came from, and caches
on the bytes because the same figure is re-sent every turn.

Also fixed on the way: `article` was missing from the admin's task list, so
article drafting always ran on the fallback model whatever an administrator
chose; and `.jpx` stem images were sent as JPEG because `mimetypes` guesses
that from the name, so the provider rejected them two hops later.

An administrator must pick a tool model in Settings → AI models. Until then the
tutor says a figure exists that nothing could read, rather than describing one
it cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:46:04 +02:00
Daniel
0a331cf4eb feat: the landing page shows the product instead of describing it
Six cards of prose sat under two mock players that already showed the thing.
"A player built for how the paper asks. Sit a session as study or as an exam
block…" is the picture above it, written out again, and a stranger reads
neither. They are gone, and so is the heading over the band — it named what the
pictures show.

A third panel joins them: an analysis with a ring that fills and three bars
that grow. No figures and no labels, because a number on a landing page is a
number nobody checks; the shapes move and where they sit says what they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:40:37 +02:00
Daniel
83f6cb15cf fix: search the article body, and cap an upload at what it is for
`search_vector` weighted title, summary and `content` — but `content` is NULL
for 323 of 331 articles, because everything the generator writes goes into the
`sections` JSON and only the eight hand-seeded samples ever used the column. For
98% of the library the body contributed nothing to full-text search, so a term
that appears only in a section — a drug name, a diagnostic criterion, an
eponym — returned nothing, and did so silently.

A generated column cannot contain a subquery, so the extraction is an IMMUTABLE
function it can call, and `content` stays in the expression for the eight that
use it. Proved rather than assumed: "supraglottoplasty" appears in no title or
summary in the corpus and now finds Laryngomalacia; before this it found
nothing.

Uploads are capped at 2 MB rather than 10. A document here is a query, never
content — read once to find matching questions in the bank and then
discarded — so the cap is about how much text is worth reading, and past two
megabytes somebody is uploading a textbook.

The previous commit's message covers only the litellm removal; it also carried
the 36 rewritten article summaries and the prompt rule behind them, which were
finished in the same window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:23:32 +02:00
Daniel
9b9795dee3 refactor: drop the litellm SDK for the openai client
litellm was doing nothing this project needs. Nine call sites, all the same
shape — model, messages, a temperature, an api_base pointing at the proxy — and
no streaming, tools, response_format, fallbacks, retries, Router or cost
tracking anywhere. `_proxy_model()` prefixed every model with `openai/`
specifically to stop litellm routing by provider, which is to say the SDK was
configured to behave like the OpenAI client it now is. Embeddings, model
discovery, speech and transcription already went over plain httpx.

The client is built in one place instead of thirteen assembled kwargs dicts,
and three things about it are deliberate: the base URL normalises to end in
`/v1`, because the SDK appends to whatever root it gets and litellm happened to
tolerate the bare host; `max_retries=0`, because the SDK retries twice by
default and would have turned the hand-written three attempts in
`extract_questions` into nine; and a placeholder key when none is configured,
so an unconfigured deployment fails at the request with the 502 every call site
expects rather than inside the constructor with a 500.

Verified against the live proxy rather than only against mocks: sync client,
async client and `_call_model` each returned from llm.danvics.com, and the
service boots clean. Nine distributions dropped, 156 to 147.

This also unblocked requirements.txt, which could not be edited at all while
litellm==1.28.13 — withdrawn from PyPI — was pinned in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:19:08 +02:00
Daniel
889c40483c feat: link a question to its reading from either end, and a player that reads like a Qbank
**Linking.** A question could be tied to an article only from the article, by
typing the question's number into a box — so opening a question you had just
linked showed no sign of the link, and there was no control to add one. Both
ends now search: find the article by title from the question, find the question
by stem from the article, pick which section of the article the link lands on,
and see what is already linked. One shared finder, so the two ends of one
relationship cannot describe it differently. `GET /questions/{id}/articles`
mirrors the endpoint that already existed the other way, and `GET
/articles/linked` is retired — it answered this question by shipping the whole
prose of every linked article to the quiz player for a list of titles.

"Practise this topic" is a reader's control and no longer appears on an editing
screen.

**The player.** The rail was a bordered card floating in the page with a
scrollbar of its own, so a session had two scrollbars side by side and a
collapse handle tucked inside the card's padding. It is a column now: flush,
full height, its own background rather than its own border, the handle on the
boundary it moves, and a progress bar under the count. The bar at the foot is
the bottom edge of the window — three flush segments, no gaps, no pills —
because Exit as a small grey pill beside a large blue Next made leaving look
like the accident.

Study mode no longer asks whether you are sure. Leaving suspends: every answer
is saved, nothing is graded, and it is waiting where you left it — so the
dialog asked permission for something reversible, under a name for something
that does not happen. An exam still asks once, because a block has a clock, and
it now says what it is: "Leave this block?", not "End Session".

Options are lettered. The explanations already are — a stem extracted from a
board PDF says "Preferred Response: E" — so numbering them 1 to 5 left the
reader translating between two labellings of the same five lines. The tutor is
told the same letters, and the answer key is marked against its own option and
declared authoritative, so a model that would have answered differently cannot
tell a student the marked answer is wrong.

"Preferred response" and "Source page 518" are gone: the first labelled a block
that is obviously the answer, the second named a page of a book the learner
does not have. The clocks moved out of a grey strip across the explanation,
where they read as part of the answer, to the foot of the rail with everything
else about the session.

**AI Mode.** Sources are headed and counted at the end, where evidence belongs,
with the practise button after them rather than above. That button appears only
when there is something to build from and says what it will build — it used to
sit under "how can I help you today?" offering to make a session out of
nothing. A cited question opens in place: `/questions/:id` is the editor, so
following one dropped a learner into a form for changing the question they had
just been told about. And a session built from a chat is named like every other
session, rather than after the chat — asking "hi" produced "hi — practice".

Also: two test questions with raw `<p>&nbsp;</p>` in their stems were live in
the bank; retired. And 36 article summaries were written as a table of contents
with the colons filed off — "Peanut allergy prevention and management: LEAP
guidelines by risk tier, risk stratification, and anaphylaxis treatment" — every
noun phrase sounding informative and none of them saying anything. Rewritten as
claims, with the rule added to the prompt that produced them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 15:17:04 +02:00
Daniel
52ef7acdea feat: articles are collectible, and Summary is a toggle
A library holds both articles and questions now. It held only questions, so the
bookmark on an article had nowhere to write and stood in for the questions
filed under the topic instead — which is not what a reader who saved the
reading asked for, and left a topic with no questions unsaveable. Its own
table rather than a nullable column beside `question_id`: that shape allows a
row with both or neither, and every read then has to say which kind it is
looking at.

Which libraries already hold an article is now asked of the server, as one
question. It was kept on the device because the API could not answer, which was
wrong on the second machine and silently so. Putting one back is the same
control rather than an undo somewhere else.

"Short" is called Summary, because that is what the section is called, and it
is a toggle rather than one tab of three — the whole topic, or the part of it
worth revising, which is a different kind of choice from Long versus Clinical.
It names its own state, so a reader can tell why two thirds of the contents are
not there. The stored variant stays `short`: renaming it would be a data
migration to change a word on a button.

Also: `litellm==1.28.13` has been withdrawn from PyPI, so requirements.txt
could not be edited at all without the pip layer failing to rebuild — which is
what blocked pinning Pillow. Repinned to 1.53.1, the nearest still published;
the three things we use are unchanged in it, and both suites pass on the new
set. Pillow is pinned properly now rather than arriving through PyMuPDF.

One consequence, handled: `litellm.utils.get_valid_models()` now returns
nothing unless a provider's own API key is in the environment, and ours is a
proxy. That branch is only reached when no proxy is configured, and it now says
so instead of answering with an empty list that reads as "this site has no
models".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 14:52:20 +02:00
Daniel
cedab6e91e feat: thumbnails for uploaded images, at two widths and no others
A question's stem image is two to four megabytes of scanned radiograph, and a
media grid is forty of those pulled at full size to draw forty postage stamps.
`?w=256` and `?w=640` now serve a WebP copy instead, made on the first ask and
kept beside the original under `thumbs/{width}/{key}` — same bucket, so nothing
new has to be configured for them to be backed up or thrown away.

Three rules, all about not making this a way to spend the server's afternoon.
Those two widths and no others: any other `?w=` is refused with a 400, because
an endpoint that resizes to whatever the query string asks for is a CPU sink
anybody can point at. Never enlarged: a 180px image asked for at 640 is served
as it is, since scaling up invents detail and charges bytes for it. And best
effort throughout — a PDF, an SVG, a truncated upload or a file that is not the
image its name claims all serve their original rather than failing, because a
preview must never take down the page that wanted it.

Authorisation is unchanged and still runs first: a thumbnail of a file you may
not read is a file you may not read. They stay `private, no-store` like
everything else here — they are behind authentication, so there is nothing for
a shared cache to do with them, and the win is the byte count.

EXIF rotation is read before anything measures the image. Every phone stores a
portrait photograph sideways with a flag; a thumbnail made without reading it
is a sideways thumbnail.

Pillow rather than sharp, which is Node. It is not pinned in requirements: the
pin invalidates the pip layer, and that layer no longer builds because
litellm==1.28.13 has been withdrawn from PyPI. Re-pinning litellm is a
deliberate upgrade of the AI layer, not something to slip into this. Noted in
the TODO.

Also: the article hover-card excerpt was printing `[[288|eczema]]` at readers.
The generic markdown-link rule does not know our own cross-reference syntax, so
it left the brackets and the id behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:49:20 +02:00
Daniel
5d59e00144 refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.

So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.

The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:42:51 +02:00
Daniel
b06560c515 fix: the ear to ENT, gynecology to its own branch, and Surgery back into medicine
Three decisions the owner made about where content lives.

Otitis media was split across two roots — the acute form and mastoiditis under
Infectious Disease, effusion and the recurrent form under Otolaryngology — so
somebody looking for "the ear" found half of it twice. All of it is in ENT now,
with the three forms as children of Otitis Media rather than four siblings
saying nearly the same thing. The questions keep a link back to Infectious
Disease; they are still infections.

Surgery was a root with 34 questions, no children and no ABP domain, so that
content was invisible to the blueprint entirely. Nothing about those questions
is "surgery" in the sense the outline uses: they are bowel obstruction in a
neonate, a burn, a torsion, a swallowed coin. Each is now filed under the topic
that names its condition — read off the stem and the keyed answer one at a
time, because no rule derives that and a rule that guessed would be worse than
the root they are leaving — and every one keeps Surgery as a subject tag, which
is the axis a discipline belongs on.

Gynecology is its own branch, mapped to domain 20 as the outline has it at
20.B. It was living under Adolescent Care, so Genitourinary drew from a pool
missing a third of what it should hold. The sexually transmitted infections
beside it stay where they are: those are 3.B, and the outline means them there.

Four topics were made for questions that had nowhere honest to go — Burns,
Foreign Body Ingestion, Procedural Sedation, Chest Wall Deformity.

569 topics, 28 roots, 2,924 questions, none orphaned, none without an organ
system, and no two ABP domains sharing a category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:06:59 +02:00
Daniel
b2a353cf58 fix: one topic per idea, and one ABP domain per topic
The complaint was that biostatistics and research ethics were the same thing
filed twice. They were — and under them the topic tree had no home for
research, biostatistics, ethics, patient safety or quality improvement at all.
Domains 23 (Ethics, 2%) and 24 (Patient Safety, QI and Research Methods, 2%)
both pointed at the single row "Professional Topics", and 19 (Nephrology, 3%)
and 20 (Genitourinary, 3%) both claimed "Nephrology & Urology". Six per cent of
the paper rested on two rows that could not be told apart, so an adaptive
session could not weight either half of any of those pairs.

Every domain now owns its own topics, with none shared: 19 takes the kidney
topics and 20 the genital and urological ones, and Professional Topics gains
Ethics, Patient Safety & Quality Improvement, and Research & Biostatistics. A
blueprint-shaped paper draws from all 24 domains with no shortfall; two of
them — Psychosocial Issues and Child Abuse and Neglect, 3% each — had a pool of
literally zero before.

And 93 topics that were one idea under two names are now one: 25 "Pediatric X"
leaves under a root already called X, on a site that is paediatric throughout;
66 plain synonyms (Nocturnal Enuresis and Enuresis, Down Syndrome and Trisomy
21, Eczema and Atopic Dermatitis, Chlamydia filed once per discipline that
mentioned it); and two abuse topics onto the ABP's own headings. Every merge is
written out by id *and* name and refuses to run if either has moved — no
normaliser, no fuzzy key, because a key that ignores digits puts Trisomy 18
into Trisomy 21. Questions move before any row is deleted, and a cross-filing
that would have been lost becomes a link instead.

566 topics, 2,924 questions, none without a primary topic, none without an
organ system. Backups either side in backups/topics_*_reorg_*.sql.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:42:00 +02:00
Daniel
bb72aa5f60 feat: AMBOSS exam chrome, review as a study session, and no silent submissions
The exam player takes the window. The shell was sized against the header with
a number that did not include the navbar's own 32px of margin, so the block bar
— the one thing on the screen that must always be reachable — sat below the
fold and had to be scrolled to. Exam mode now hides the site chrome entirely
and is the viewport, which makes the arithmetic honest and matches what a board
looks like: item and block in a box at the left, the two arrows in the middle,
the tools at the right, the question-status rail down the side, and the clock,
Pause and End Block along the bottom.

Shortcuts is gone from the bar, and the labs open into the column beside the
question in both modes rather than a box over it.

Nothing is handed in behind the learner's back. The clock reaching zero stops
the block and says so; closing Time's Up submits, and the player stays put
showing the answers, which is the review. The server no longer settles an
expired attempt at all — listing sessions used to mark any paper whose clock
had run out, so opening a page could score a block the learner had walked away
from, and the first they knew of it was a result.

Reviewing an attempt is now the player with the answers in, not a dropdown and
a card. Same rail, same layout, same labs, same way out — and on a phone the
same burger opens the same question list, from one shared rule about which
routes are a session.

Also: the rule-out toggle sits beside its option instead of pinned to the far
edge of the card, so an option box is as wide as its own words; the voice
picker leaves the player, since a reader's voice is a setting and not a
decision to retake every session; figures carry no invented "Figure 1" — a
label is what prose refers to, and the backfill knew of no prose, so 346 of
them said only that an image was an image; and the landing page shows the two
modes happening rather than promising six things in a sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:37:58 +02:00
Daniel
2b5262d255 feat: the objective is enforced, folders are made where you are, and the
landing page describes this product

An objective is now required to build a session, not only asked for in
the interface — the interface asks, and this is the same rule where it
cannot be walked past. Only where there is something to choose: a
deployment with no exams, and the first administrator of a fresh one,
must still be able to build a session. A rule that locks an empty site is
not a rule, it is a fault.

Saving a question into a folder is one box that searches what you have
and offers to make what you do not. It used to say "make one in the
question bank" and leave you to go and do it, which means leaving the
question you were reading and coming back to find your place. A name
that already exists exactly is not offered twice; a partial match offers
both, because wanting a narrower folder called "cardio" is not the same
as wanting the one called "Cardiology misses".

The landing page is rebuilt. Its copy described a product from months ago
— "upload a PDF, AI extracts questions", which is one feature of many now
— and it was 568 lines of inline style objects, which cannot express a
hover, a media query or a keyframe. The figures come from
/api/public/stats and count up; a failed fetch renders the section
without them rather than showing noughts, which would be a lie about an
empty bank. Motion is CSS and SVG, and prefers-reduced-motion turns all
of it off — including forcing the scroll-revealed elements visible,
since a hidden element with its animation removed is how respecting that
setting turns into a blank page.

Two smaller ones from the screenshots: the collections shelf is boxed
rather than scrolling past everything else on the page, and its rows no
longer carry the entire stem — lab tables and all — in a native tooltip
that covered half the screen and could not be dismissed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:50:10 +02:00
Daniel
77c09db057 feat: the objective is not optional, and the site can count itself
Choosing what you are studying for has no way past it now but to answer.
It decides which questions exist, how relevance is weighted and what
readiness measures against, so an account that never answered it was
being shown the whole bank by accident rather than by choice.

What is guarded instead is asking a question that cannot be answered: if
the list of objectives fails to load, or there are none, nothing is shown
at all. A modal with no options in it is not a question, it is a locked
door.

GET /api/public/stats, unauthenticated, so the landing page can state what
there is rather than what someone typed into the markup months ago — a
number written into a page goes stale the week after and nothing breaks
to say so. Counts only, and only of published material: how much there
is, never what it is, so there is nothing here to walk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:40:48 +02:00
Daniel
16aed6b6b0 feat: Cap on its own host, hints per topic, and an objective is asked for
Cap moved from /cap/ under this app to cap.pedshub.com, so anything else
on this machine can use the same instance. Caddy terminates it, the
backend keeps verifying over the compose network rather than going out
and back, and the widget endpoint is configuration rather than a path
baked into the component. Verified: a challenge is issued on the
subdomain, and a token that was never issued is still refused.

"Correct using hints" is now a per-topic figure. The knowledge profile's
accuracy bar was two-tone because /study-tools/recommendations carried
only `answered` and `correct`; the hint count existed lifetime-wide but
never per topic, and inferring one from the other would have been a
different set of answers drawn as though it were this one. The column
was already on attempt_answers, so it is a group-by, and the bar is
three-tone as the reference has it.

And the objective is asked for. It decides which questions exist, how
relevance is weighted, and what readiness measures against — and it was
possible to sit a whole board paper without ever being asked, because no
objective quietly means the entire bank. That is a reasonable default and
a poor thing to arrive at by accident. Five of six accounts here had
never set one.

It can be declined: "everything" is a real answer, and trapping somebody
behind a modal because a list failed to load would be worse than the gap
it closes. Declining is still a choice made, which is the point.

Also in this commit, from the exam-player work: Show answer in study mode
that reveals without recording an answer, review keyed on the attempt
being closed rather than every question being answered — a block that
timed out with nothing answered is over too — and the exam top and bottom
bars. That work found something worth knowing: the exam player is *served*
questions with no correct answer and no explanation, so review cannot
un-hide what it never had, and the player refetches the marked version
once the attempt closes. Nothing is revealed while a block is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:24:23 +02:00
Daniel
4ca7f6b1f2 feat: Cap replaces hCaptcha, self-hosted beside the app
Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.

The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.

Two things that had to be found rather than read:

Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.

And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.

Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.

Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.

And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:14:14 +02:00
Daniel
91b8e24d6b feat: hCaptcha replaces Turnstile
One verifier, backend/app/services/captcha.py, and one widget,
components/Captcha.jsx. There were two copies of each and they had
drifted: the register widget loaded the script itself while the landing
one relied on a page-level effect elsewhere in its file, and on the
backend auth failed *open* on an unreachable Turnstile while contact
failed *shut*.

Both failure modes were kept rather than one quietly chosen, as an
explicit `fail_open` argument with the reason written down: an outage that
stops people creating accounts costs the site its users, while an outage
that bounces a contact message costs the sender one retry.

An unconfigured secret still skips verification entirely, as before, so a
site with no keys keeps working.

The keys in .env are empty. The Cloudflare ones there were live and are
now dead, so **there is no captcha on register or contact until hCaptcha
keys are issued** — this is not a state to leave a public site in.

Also corrected on the way: docs/frontend.md still documented
`login(email, password, turnstileToken)`, whose third argument had
already gone from AuthContext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:56:44 +02:00
Daniel
dd71bed184 feat: adaptive weighs the paper as well as the learner, and Session
analysis is a real tab

Adaptive selection knew what you were weak at and nothing about what the
exam is made of, so being weak at something worth 5% of the paper ranked
the same as being weak at something worth 1%. Every score is now
multiplied by the weight the board publishes for that topic's domain —
the same `exam_blueprints.weight` behind the Relevance column.

A topic the blueprint does not cover takes the median published weight. A
zero would make unmapped material unreachable and the highest would make
it the priority; neither is a claim the blueprint supports. With no study
objective the multiplier is absent and selection is about weakness alone,
exactly as before.

Weight scales weakness, it does not replace it: a topic you are certain of
does not surface because it is worth 5% of the paper, because (1 −
accuracy) is near zero and no multiplier rescues that. docs/adaptive-
sessions.md says all of this, including what is still open.

Session analysis is the third tab rather than a link out of the page —
two of the three used to change what you were reading and the third took
you somewhere else. The tab bar is one component both routes wear,
AnalysisSessionPage's body is a component the tab renders in place, and
the tab lives in the address so a link opens where it says.

Two things that were wrong turned up in that work: a session nobody had
sat showed 0% in the figures and "0% correct" in the donut — two separate
statements of a score on a session that had none — and the old third tab
disappeared entirely for anyone with no attempts, so the strip silently
changed shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:54:09 +02:00
Daniel
3a94d89e7c feat: a solid highlight, a five-minute warning, and the adaptive algorithm
written down

The yellow was a gradient stripe under the x-height, which reads as an
underline rather than a highlight and all but vanishes on a wrapped line.
It is a solid band now, the way a highlighter leaves one.

A block that ends without warning ends on whatever question you happened
to be reading. Five minutes out it says so — once, because a warning that
returns every second is a warning nobody reads.

And docs/adaptive-sessions.md, because "prioritised by impact" was a
phrase with no written meaning. It says where the code is, what the three
rules are — unanswered first, weakest topic among those, then wrong ones
oldest first with the category damped so twenty questions do not all come
from your worst subject — and, as plainly, what it does not do: it is not
weighted by the exam blueprint. Weakest and most-of-the-paper are
different questions and we answer only the first. The refinement that
would combine them is written down too, with the column that already
holds the weights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:47:30 +02:00
Daniel
7ba37561e4 feat: the exam player as the reference has it
The incomplete-block warning is the one from the screenshot: a red
heading that says the block is incomplete, the count of unanswered items,
the sentence about resuming not matching exam day, and End Block against
Remain in Block. My version asked the question in my own words and led
with the wrong button.

Pausing says "Exam Paused" and offers Return to exam. Nothing else — the
warning about real exams is somebody else's disclaimer, not ours.

Exit session asks "Are you sure you want to end this session?" before it
goes, rather than going.

Time's Up says what it is and the button says Close, which is the only
thing left to do: it is already handed in and marked, and Close lands on
the session's analysis.

One name for one action: the bottom button read Skip on an unanswered
question and Next on an answered one, while the arrow an inch above it
said Next for both.

And the rail shows stems again once the block is handed in. Numbers while
it is being sat — reading ahead is not something the exam being rehearsed
allows — but there is nothing left to protect afterwards, so the review
reads like study mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:43:25 +02:00
Daniel
6240a1c9da fix: put the disciplines and diseases back, and order Performance as shown
Retiring three vocabularies at once was my call and the wrong one. Keyword
had to go — it was the old route to an organ system, which a topic now
carries, and that took Systems from half the bank to all of it. Subject
and disease went with it on the argument that the topic tree says the same
thing. It mostly does, and "mostly" is not a reason to remove the
vocabulary people had learned to filter by.

203 subjects and 2,275 diseases are back, with their 14,029 links, and the
Disciplines and Diseases pickers with them. Keywords stay retired.

The backup I wrote before deleting was not where I said it was:
`./backups` is mounted on db-backup, not on backend, so the file went with
the next container rebuild. The rows came from the nightly dump instead,
which is what that dump is for. scripts/restore_subject_disease_tags reads
a pg_dump extract, is idempotent, and resets the sequence afterwards so
the next tag created by hand does not collide with a restored one.

/tags serves subjects and diseases from their own links again, and systems
through the topics that carry them.

The Performance tab is ordered as the reference has it: the trend beside
the split it is a trend in, and Completion's four figures underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:39:22 +02:00
Daniel
a16e45a7f1 fix: user deletion, a real end-block dialog, and the axes people pick by
Deleting a user failed with a not-null violation from quiz_attempts.
Every foreign key to users is already CASCADE or SET NULL in Postgres,
but the ORM relationships had no passive_deletes, so SQLAlchemy insisted
on emptying each one itself by writing NULL into columns that refuse it.
passive_deletes leaves it to the database, which knows. The two tables
that genuinely cannot forget a user — question_categories and
quiz_categories are NOT NULL and NO ACTION — hand their rows to the
administrator doing the deleting: the taxonomy is the site's, not the
author's.

/tags counted through question_tag_links, which is now empty, so every
organ system read zero and an active exam hid them entirely. It counts
through the topics that carry them instead: 2,919 of 2,924 questions, all
sixteen systems with real numbers. The session builder's Disciplines and
Symptoms pickers were over the retired vocabularies and are gone —
Topics is the same axis said once and said better, 673 against 203.

The end-block dialog offered one button. A confirmation with one button
is not a confirmation: it now leads with the way back into the block,
says how many are unanswered as a sentence rather than a grid to count
by eye, and the unanswered are numbers you can press to go there.

Registration asks for the password twice, on both forms — a password you
cannot see is one you can mistype into an account you then cannot open.

The public pages had no footer, so signing in meant losing the way to
About, Contact and the clinical disclaimer. They sit in a plain layout
that keeps it.

Draft batches can be filed from the workbench: the topic they file into
is a picker at the top, and nothing crosses over until it is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:28:16 +02:00
Daniel
ebcc11ae12 feat: retire the tags, and stop settings from hiding from the page that
claims to hold them

The scaffolding is down. 203 subject, 2,275 disease and 4,281 keyword
tags, and 25,356 links, deleted — backed up first to a 1.9MB JSON of
replayable rows, because "we can always put it back" should be true
rather than said. The 16 system rows stay: categories point at them.

With them go the things that only existed to feed them — the
classify_questions task, its snapshot helpers, POST /tags/classify and
its status poll — and the three Taxonomy tabs that would now always read
zero. A tab showing 0 forever teaches people the page is broken.

The organ-system filter in the session builder moved onto categories with
the rest, including everything beneath a matched topic, so it groups the
way the analysis does.

Registration: `settings:registration_enabled` was set to false, and there
was no switch anywhere on the site to set it back. The API had always
accepted it; the Site policy page had never shown it. So the site could
be closed to new members with the admin looking at three switches, all
correct, and no way to see the one that was actually refusing them. It is
now the first switch on that page, and says plainly that the ones below
it have nothing to act on while it is off. The SSO-only flag was hidden
the same way and is shown when SSO is configured.

Deleting a topic no longer silently unfiles its questions. It asks where
they go, and says how many are waiting, unless the topic is empty — the
same rule promotion now follows. Its extra category links move too,
minus any that would duplicate a pair the destination already has.

Back links: Trash, Extraction jobs, Taxonomy and the Handbook had none at
all, and Access pointed at the wrong section. They are one component now,
each returning one step to the section it was opened from. Editorial has
its own entry in the section bar, so its Tools card is gone rather than
being a second door to the same room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 05:06:58 +02:00
Daniel
8614792bdf fix: the other registration form, our own transcriber first, and a rail
that stops answering the wrong question

Invite-only was set and the sign-up form had nowhere to type a code.
There are two registration forms — /register and the modal on the landing
page — and only the first had been taught about invite codes. The modal
is the one most people meet, so turning the gate on failed everybody with
"an invite code is required" and no field to satisfy it. It now asks the
same signup-policy question and shows the same field.

Dictation records to our own transcriber first and falls back to the
browser's recogniser only where recording is unavailable. It was the
other way round for speed, but the browser's speech stack announces
itself to the user in ways we do not control — Firefox interrupts the
page with a warning about a missing Speech Dispatcher library, which is
alarming and is not about us.

A draft question could be promoted into the bank with no category. That
question would reach nothing: no discipline, no organ system, no
relevance, no row on any tab of the analysis — in the bank and invisible
to every page that counts. Promotion now refuses, before an id is spent.

And "Your overall analysis" is out of the session rail. It put lifetime
figures one click away while you were standing in front of a single
session, which is the thing that was supposed to have moved to the
Performance tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 04:41:27 +02:00
Daniel
13d5f50c58 feat: an organ system is a fact about a topic, not about a keyword
A question reached a system through a symptom keyword it happened to
mention — question → keyword → parent system — and only 726 of 4,281
keywords had ever been given a parent. The Systems tab saw 1,492 of 2,924
questions while Disciplines saw all of them.

The system now sits on the category: question_categories.system_id. Every
question has a category, so every question reaches a system. 2,919 of
2,924, and all sixteen buckets have real content.

It stays a third way of asking rather than the discipline tree relabelled
because a topic's system is assigned separately from where it sits in the
tree. scripts/assign_category_systems takes the discipline as a default
and lets the topic's own name overrule it, which is exactly the case that
makes the axis worth having: conjunctivitis is filed under Infectious
Disease and is an eye, osteomyelitis is filed there and is a bone. 110 of
660 topics were decided that way.

Two regex traps caught in the dry run and fixed before applying:
"adRENAL" matched the kidney rule, and "Abnormal Uterine Bleeding" matched
the bleeding rule. Both now have a specific rule above the general one.

I first tried to fix this by parenting the orphan keywords to systems,
deriving each keyword's system from the questions carrying it. The dry run
showed why that was the wrong shape: it reached only 534 of 3,555 orphans,
and inherited every coarse edge of the discipline map — conjunctivitis came
out as Multisystem because conjunctivitis questions are filed under
Infectious Disease. That script is left in place, unapplied, as the record
of a measurement worth keeping.

No ForeignKey on system_id in the model: question_tags is a raw-SQL table
with no ORM class, and declaring one leaves every metadata build unable to
resolve it. The constraint is real in Postgres.

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