Commit graph

181 commits

Author SHA1 Message Date
Daniel
6bb5767871 feat: session analysis after a quiz, and a merged TODO for the rest
The results page showed a score and a wall of explanations. What a learner needs
afterwards is where the time went and what to go back to, so
/analysis/session/:attemptId gives them: a rail of recent sessions, the four
figures they act on — correct, completed, time per question, total time — a
donut, the weakest topics, and a paginated table of every question with its
status, difficulty, time and how peers did on it.

Time per question was not recorded at all, so it could not be reported. It is
now (`attempt_answers.seconds_spent`), banked when you leave a question and
including the one still open at submission — without that the last question of
every session would show nothing. Answers from before this read "—" rather than
claiming zero, and a question nobody else has answered has no peer rate rather
than 0%, which would read as everyone having failed it.

Also in this pass, from the review:
  * quiz categories are gone from the library — a second taxonomy beside the
    real one, putting a heading above every test;
  * the board review sets are numbered rather than dated, in both the quizzes
    and the study plans built from the same material, so a learner does not meet
    2019 in one place and VII in another;
  * the footer's standing note is one clause, and the gap above it no longer
    looks like the page ended early.

Everything else asked for today is written down in docs/TODO.md rather than
half-built: resume instead of restart, an unsuspended exam that keeps running,
deleting a session's data, reset-all-data with a warning, recommendations split
by article/discipline/system, and the adaptive session. Two questions I owe
answers to are in there too.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:07:05 +02:00
Daniel
d34233d87b feat: attending tip, per-question notes, save to folder, session clock
The question toolbar now carries what a learner actually reaches for. An
attending tip — one sentence of the kind said at the bedside, stored separately
from the explanation because it is read before the answer is known and must not
give it away. A note of their own on that question, replacing a single global
note that was one page for everything and so was never about the question in
front of you. Saving to a folder, which the collections API has supported all
along with nothing in the player able to call it. And the share link, which
previously only appeared on the start screen.

Panels open one at a time under the toolbar; two at once would push the options
off screen.

Reset question resets one question, not the attempt: a misclick should cost the
answer you just gave, not the nineteen before it.

The clock shows session time, time on this question and the running average, in
study mode as well as exam mode — four minutes on one question is the number
that says whether you are learning or stuck, countdown or no countdown. It
pauses, because time spent making tea is not time spent thinking.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:59:32 +02:00
Daniel
d1388f3335 feat: take a selection off inside the picker
A facet panel could add a choice but not remove one without finding its checkbox
again in a list of several hundred — and Reset, the only alternative, throws away
nine choices to undo the tenth. What is picked now shows as chips under the
search box, each removable on its own, with Clear all beside them once there is
more than one.

248 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:54:17 +02:00
Daniel
e63c625ed4 fix: the top bar's vibration, the session rail, and three pieces of old design
The bar vibrated because I made it fight itself
Collapsing it takes 46px out of the page, the browser corrects the scroll
position to compensate, and that correction arrives as an upward scroll — which
shows the bar, lengthens the page, and starts the loop again. The 6px jitter
guard could not help: these were real scroll events, just ones we had caused.
State now cannot change until the layout has settled, and a test reproduces the
echo rather than trusting the fix.

Session history, built properly
"Show all 15" expanded a list in place, which answers a smaller question than
the one being asked. There is now a real /sessions page: a rail that filters by
status and mode and sorts by recency, weakest or best, a running average across
what has actually been attempted, and nothing truncated. The quizzes page keeps
its preview and links straight to it. A session never attempted sorts last under
both "weakest" and "best" — the absence of a score is not a bad one.

The session rail is one row per question
A five-line excerpt per question turned a rail of twenty into a page of its own.
Number, then the stem on one line, truncated. The repeated "Question N" label
said nothing the number had not.

"Manage references and drafts" said nothing useful
"References" there means lab values rather than citations, and "drafts" means
unpublished ones — an editing switch hidden in a learner's lookup, phrased as
neither. It is now an "Edit values" toggle that says, while it is on, that
unpublished values are showing and that changes reach every learner.

Also gone: the "All past attempts" block, which the Sessions view had already
replaced.

248 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:51:46 +02:00
Daniel
f2b5e146eb feat: a study objective that changes what you see; purge unused figures
The objective did almost nothing
It scoped question counts and nothing else, which is why changing it appeared to
have no effect. An exam now carries a family (USMLE, COMLEX, boards), a
description, and the article views it offers, and `/exams/` reports what the
current objective actually changes rather than leaving the learner to guess.

Reading follows from it: an article returns only the views its objective allows,
so someone revising a basic-science step is never shown bedside dosing they must
not act on — a view you can open but must never use is worse than one you were
never offered. An editor still gets the whole article, because they cannot edit
what they cannot see. An objective configured to show nothing falls back to all
three; that is a configuration mistake, not a preference worth honouring.

Unused figures deleted, at the user's request
3,262 figures — 334 MB — that nothing had ever used. "Unused" was defined by
exclusion and every exclusion was checked rather than assumed: kept if any
question uses it as a stem or explanation image, if any question version
mentions it, or if it appears in article prose or a flashcard. 440 kept, and
five question figures spot-checked as still readable afterwards. MinIO is now
596 objects, 520 MB, down from 3,858 and 854 MB.

This is not reversible from the application; the nightly borg backup of the
volume is the only way back, and that is stated in the script rather than
assumed.

For the record, since it was asked: the extraction is PyMuPDF, with an MD5 skip
list for repeated branding images. It pulled every embedded image from all 18
source PDFs, which is why one 767-page document alone produced 908 of them.

208 backend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:47:01 +02:00
Daniel
7a764d8845 feat: a real site footer, and better retrieval queries
The links I put in the save bar are gone — that bar was right as it was, and a
row of navigation crammed above it was clutter in the one place a person is
trying to finish a question. The footer is where going somewhere else belongs.

`SiteFooter` replaces the copyright line: four columns — Study, Library, Find,
PedsHub — with About, Contact, Account and Settings among them, and the standing
note that this is revision material rather than clinical guidance, said once at
the bottom of every page. A test asserts every link points at a route that
actually exists, because a footer full of dead links is worse than a short one:
the reader learns not to trust any of them.

Two retrieval faults the writing found
A bare condition name is a thin query. "Rickets" alone retrieved five passages
about *Rickettsia* — an embedding has little to go on in one word, and the
nearest neighbours of a short string are whatever looks like it. Asking as
"Rickets in children: definition, causes, clinical features, diagnosis and
management" took the contamination from five passages to none, so both the
pipeline and the generated route now ask that way.

And a category that names a department rather than a condition retrieves chapter
headings and whatever sits near them. "Pediatric Nephrology" passed the material
check with entirely irrelevant passages, and an article called that is a
department, not something to revise. Those names are now excluded from the topic
list.

Both were found by an agent writing articles and reporting what looked wrong,
rather than by anything automated noticing.

247 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:40:57 +02:00
Daniel
16d799a273 feat: every figure in the image bank, bigger editing boxes, footer links
The figures were never in the bank
3,702 extracted figures have always been in object storage and attached to
questions by path, but were never rows in `media_assets` — so nothing could
browse, tag, search or reuse them. They are all in a "Question figures" library
now, pointing at the same keys the questions use: nothing copied, nothing moved.

Each is tagged by what it is doing, which answers the question that prompted
this. 341 are in use. 94 are tagged `detached` — the ones the stem/answer review
took off a question because the figure illustrated the answer — and they are in
the bank precisely so that decision can be looked at rather than taken on trust;
every one is also still recoverable from its question's version history. 3,267
were extracted from the PDFs and never used by any question at all.

No image file was deleted at any point. All 3,702 are in MinIO, 854 MB, and
spot-checked readable.

Bigger boxes, and Markdown where prose lives
A stem is a clinical vignette and an explanation is a teaching paragraph; 120px
meant scrolling a box to reread what you had just written. Stem 340px,
explanation 280px, option explanations 130px. Option explanations render
Markdown and maths like every other piece of prose on the site, and the stem,
explanation and each option explanation now have a Preview showing what the
learner sees — the difference between writing a table and hoping you wrote one.

Footer links
The save bar is where the eye already is when a question is finished, so the ways
onward sit beside it: back to where you came from, then the bank, the manager,
the image bank, the taxonomy and the reading library.

244 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:32:06 +02:00
Daniel
07eae7937b feat: question manager edits on a page, and articles written without a writing API
The manager still opened a modal
The bank was moved to the full editor a while back and this page was missed, so
editing from the manager still meant a dialog whose category control was a flat
select of seven hundred breadcrumb strings — no search, no way to pick a branch
and then narrow within it, and too small to follow. The full page already has
the searchable drill-down with sub-selection, images, versions and option
explanations. Edit now goes there and carries the way back, filters and page
intact. The modal stays where a quick correction belongs.

Articles, written rather than generated
Per the user's instruction: no OpenAI, no OpenRouter for writing — bge-m3 for
the search and nothing else. `scripts/article_pipeline.py` splits the job so
only the machine half is machine work:

    topics  — conditions that still have no article, biggest first
    fetch   — embed the topic, search the library, write the passages and the
              references derived from their metadata to a file
    import  — take a finished article and store it as a draft

No model API is called at any point in that pipeline. Whoever writes the prose
reads the passages and writes original text from them; the references still come
from what retrieval actually returned, so they cannot be invented by the writer
either — the same property the generated route had, kept.

The importer refuses an article missing any of short, long or clinical. A view a
reader is offered and finds empty is worse than one that was never promised.

244 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 02:02:57 +02:00
Daniel
a1459b2965 refactor: name the study plans ourselves, and stop reserving 64k tokens a call
"PREP" is the American Academy of Pediatrics' trademark for their own product.
The plans here are our own sets of questions grouped by year, so they are now
named for what they are: Board Review 2021, and Mixed Review for the plan that
draws from every year at once.

Renamed in the database as well as the code — 13 plans, 14 quizzes a learner had
already generated from a block, and the 12 year tags, which appear in the
question bank's filters and are as visible as the plans. The seeder matches both
the old and new names so a fresh import still finds its material, and the tagger
mints the new one so the next run cannot undo this. Prompts and comments that
described the source PDFs by that name now describe them by what they are.

The generation run's 377 failures were not a bug
Every call was reserving the model's full 64k output ceiling, and OpenRouter
refuses the whole request when the balance is below the reservation — "you
requested up to 64000 tokens, but can only afford 52017" — however short the
answer would actually be. `_call_model` now takes a max_tokens, and the article
writer asks for 4000, which is comfortable for three views of one topic and
keeps each request small enough to be affordable. 98 articles were written
before the balance ran down; 158 exist in total.

Generation is paused at the user's request while credits are topped up.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 01:41:18 +02:00
Daniel
1d5962b40e fix: a model call with no timeout, and tests that measured Redis
The generation run stalled at topic 28 with the process alive and the log
frozen. `_call_model` had no timeout — every other call in ai_service.py has
one — so a stalled connection to the proxy hung the caller indefinitely. An
interactive request survives that because the person gives up; an unattended run
of five hundred topics does not, it just stops quietly and looks busy.

It now takes a timeout, generous by default and 150s from the article writer:
long enough for a full article, short enough that a stall is noticed in minutes
rather than found hours later with nothing written since.

Separately, the AI Mode tests passed this morning and failed this evening with
no code between them. Not flakiness: they call the real Redis rate limiter, and
sixty-eight runs of the suite had exhausted a daily limit of sixty. A test that
depends on shared external state stops testing the code and starts reporting how
often it has been run, so the limiter is now patched out for those tests.

208 backend tests green, and the run is moving again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 22:02:50 +02:00
Daniel
18afb138dc feat: edit every view and its sources, with a way back to any earlier save
An admin could already edit an article's prose, but not which view a section
belonged to and not its references at all — generation attached those and
nothing could touch them. And the revisions the API had been writing since the
CMS landed were unreachable from the interface.

Editing one view at a time
Short, Long and Clinical are tabs, each showing only its own sections with a
count on the tab. All three live in one list because they are one article, but
editing them together made it impossible to tell which version you were
changing, and a stray edit to the clinical view while meaning to fix the long
one is a mistake nobody notices until a learner does. A parent can only be an
earlier top-level section of the same view, which is what the server enforces.

Deleting a section lifts its children rather than taking them with it: a
survivor pointing at a section that no longer exists is worse than an orphan.

References are editable and structured
Title, author and pages, so the editorial queue's "published without sources"
stays a truthful question. A save that does not mention references leaves them
alone rather than clearing them, or an older client would silently strip the
provenance generation attached.

Version history
Every save is listed with what it was, and any of them can be opened or put
back. Restoring is itself a save, so the version you are leaving is kept too — a
history you can only walk one way is not a safety net, it is a trapdoor. Someone
else's draft returns 403 rather than being readable through its history.

One bug this turned up: the page-number field was derived from the parsed array
on every keystroke, so typing "12, 14" became "1214" the moment the comma
landed. The field now holds what you are typing and the array holds what gets
saved, and on blur it shows what was actually stored so a dropped entry is
visible rather than a silent difference.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:27:52 +02:00
Daniel
e1b580386c feat: short / long / clinical, and stop broad topics retrieving index lines
Renames the middle view to Short and puts it first: it is the quickest way to
tell whether this is the article you wanted, and the full text is one click
away. Existing generated articles were migrated in place.

The prompt now asks for bullets that each carry a fact, because "X is important
to recognise" is a bullet that survives revision and teaches nothing.

The retrieval bug that made the last run mostly skips
The prose filter — drop chunks under 200 characters, since they are headings and
index lines — ran *after* taking the top fourteen hits. A broad query like
"Immunodeficiency" or a specialty name matches chapter titles first, so all
fourteen were index lines and the filter left nothing: the topic was skipped as
having no source material when the library holds plenty. Retrieval now asks for
five times what it needs and keeps the first passages that are actually prose.
Immunodeficiency went from 0 passages to 14, Pediatric Cardiology 0 to 14.

That is the same mistake the folder filter has a comment warning about — filter
inside the ranking, not after it — made two functions later.

Two things I got wrong and corrected rather than worked around: a `LIKE
'%key_points%'` check reported the migration had failed, when `_` is a
single-character wildcard and it was matching the title "Key points"; and a
variant count showing no Short sections was taken against the old image, where
Short was not yet a known variant and was being coerced to Long.

203 backend, 234 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:21:21 +02:00
Daniel
3980ecb7f8 feat: one Markdown renderer for the whole site, with LaTeX and highlights intact
Every piece of educator prose the platform stores is Markdown, and until now
only articles rendered it. A lab panel written as a table reached the quiz player
as a row of literal pipes, which is why the table conversion had to be held back.

`RichText` is now the single renderer: GFM tables, `$…$` maths through KaTeX,
images resolved through the uploads helper, external links opened safely, and
raw HTML escaped rather than executed — a stem can never inject markup into the
page around it. The question bank's `dangerouslySetInnerHTML` is gone with it.

Highlights were the hard part
Manual highlights and the read-aloud cursor are stored as character offsets into
the raw stem, and rendering Markdown destroys the one-to-one map a plain string
gave us. A rehype plugin puts it back: each text node in the output carries the
source offsets it was parsed from, so a highlight saved before this change still
lands exactly where it was drawn, and the selection arithmetic that reads
`data-start` needs no change at all.

Inside an inline-formatted run the rendered text is shorter than its source by
the marker characters, so an offset picked mid-run can be out by a few. Splitting
per text node bounds that to one node and keeps every node boundary exact —
stated in the code, because it is a real limit rather than an oversight.

With that in place the lab tables are applied: 79 stems, 82 panels. Question 3333
now reads as two tables with `3.5 × 10⁹/L` instead of `3.5 x 109/L`, and the
`inEq/L` and `mrnol/L` scanning damage repaired. Each change was snapshotted
first, so it is reversible from the question editor.

Six schematic illustrations
Drawn from scratch as SVG in `scripts/seed_illustrations.py` — bilirubin risk
zones, airway narrowing by level, dehydration bands, the fluid pathway, the
target sign, growth velocity. Each is captioned, tagged and searchable in the
image bank, and each says on its face that it is schematic and not a clinical
reference. They exist so the media library, picker and article figures can be
exercised against real files, and because an article with no figure looks
unfinished even when its prose is not.

234 frontend tests green, 11 of them new on the renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 18:12:37 +02:00
Daniel
af59fdb960 fix: MinIO name collision, and refuse to write an article from fragments
MinIO was resolving to the wrong container
Putting the backend on danvics_milvus to reach the clinical index gave it a
second service called `minio`, and Docker resolved that one first. Every object
read failed with InvalidAccessKeyId while the bucket simply looked empty — all
435 stem images unservable, and nothing in the logs saying why. The quiz MinIO
now answers to `quiz-minio`, which nothing else on this host claims.

A topic named after a shelf retrieved headings, not prose
"Pediatric Pulmonology" returned ten chunks whose top hit was 29 characters —
`**270** Pediatric Pulmonology`, an index line. Chapter titles rank well against
a query that looks like a chapter title. The model was handed a prompt with
citations and no content and said so, which was the correct response and read as
a JSON failure.

Two gates, both stated in the code. A chunk under 200 characters is a heading or
a running header rather than something to write from. A topic whose passages
total under 3,000 characters is skipped with the count in the reason, rather than
asking a model to write a medical article out of fragments — it will either
refuse or invent, and only one of those is visible.

The 71 generated drafts are deleted at the user's request. Nothing linked to
them and generation is resumable, so the cost was model calls rather than work.

Question bank corrections, from the agent that ran alongside:
262 questions had OCR-mangled units repaired — `inEq/L`, `mrnol/L`, flattened
`10⁹` superscripts and the rest — each with a version snapshot written first, so
every edit is reversible from the existing question editor. 94 stem images that
belonged to the explanation were removed; PREP's own `Item Q37A` / `Item C37B`
labels turned out to be a far better signal than word cues, taking the confident
split from 69/58/308 to 300/81/54. 13 uncertain images are listed for a person.

203 backend, 223 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 17:31:53 +02:00
Daniel
025e5bb4ac feat: article CMS, three reading views, and articles written from the library
Standardises cross-references the way we agreed, and puts a CMS around articles
so hundreds of generated drafts are reviewable rather than merely present.

Links, made rename-proof
`[[7|Febrile seizures]]` resolves by id and displays the text — the id is the
part that must not change, the text is what keeps prose readable while you write
it. `[[old-slug]]` still resolves and is rewritten to the id form on save, not in
a migration: an article nobody has touched is not broken, and rewriting prose no
one asked to change is how an editor stops trusting the editor. Every slug an
article has ever had is kept, so a rename redirects instead of 404ing, and a save
reports markers pointing at nothing — at the moment the person who wrote the link
is still looking at it.

Three views of one topic
The full article to study from, the key points to revise from, the clinical view
to act from, with doses. They are views of one article rather than three
articles, so the numbers cannot drift apart and a question linked to the topic
still means one thing. Each section carries its variant; articles written before
this are the long view, unchanged.

CMS
draft → in review → published, with an author able to submit and only a
moderator able to publish. Every save snapshots what was there, restorable, and
restoring is itself snapshotted or the way back from a mistaken restore is gone.
The editorial queue is work rather than inventory: waiting for review, generated
and unread, published without sources, published with nothing to practise,
barely written. An empty bucket is drawn as good news, not as an alert.

Articles from the clinical library
The library index is 1.8M chunks of reference texts embedded with bge-m3 — the
same model PedsHub already uses, so our query vectors are directly comparable and
nothing had to be re-indexed. Retrieval supplies the facts and the provenance;
the model supplies the prose. References are built from the metadata of the
passages actually retrieved, never from the model, so a reference cannot be
invented — the same property that makes an AI Mode citation trustworthy. A topic
with fewer than three grounding passages is skipped rather than written from
memory. Everything lands as a draft.

Two things worth naming. The generated text is original writing grounded in those
books, not extracts from them: their facts are usable, their sentences are their
publishers'. And there are two Milvus servers on this host — the collection with
the data is the one reached as `milvus`, not the similarly named one on the other
stack, which I wired up first and which silently refused.

Also fixed along the way: `litellm==1.28.13` has been withdrawn from PyPI, so
requirements.txt could no longer be resolved from scratch and the image only
built because of a cached layer. Later additions go in their own layer until the
pins are refreshed.

182 backend, 223 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 17:13:07 +02:00
Daniel
9beafff0bf feat: AI Mode — a chat that cannot cite what it did not find
The design settled earlier, built as described: retrieval decides what may be
cited, and the server enforces it.

The model is handed a shortlist of at most fourteen sources from the learner's
own library and told to cite them by marker. Afterwards every citation it wrote
is checked against that shortlist and anything else is deleted before it is
stored or shown. A hallucinated citation is not unlikely here, it is impossible
— surviving is not a decision the model gets to make. A URL it invents is not a
citation either: only the marker form counts, so a plausible-looking link stays
in the prose citing nothing.

Retrieval reuses the hybrid search already in place, and each corpus keeps its
own visibility rules — the bank predicate and exam scope for questions, the
draft rule for articles, deck ownership for cards. A question source carries the
stem only: a chat that printed the answer would hand away the practice it exists
to prepare you for.

Curated links do the job they were built for. A retrieved row an educator tied
to another retrieved row is boosted, because two things somebody already linked
surfacing for one query is evidence rather than coincidence. Nothing is stored
for this; the boost lives only in that ordering, and the answer marks those
sources so the reader knows which claim rests on an educator's judgement rather
than on a ranking.

Citations are stored with the answer as filtered, so reopening a thread shows
the links it showed at the time rather than a fresh retrieval that may now rank
differently. In the page the markers become numbers and each number opens its
source; a section citation deep-links into that section.

Two smaller decisions worth naming: a question appears in the thread the moment
you send it and is handed back to the input if the answer fails, because typed
words are not something to lose on a 502; and someone else's thread returns 404
rather than 403, since whether it exists is not your business either.

182 backend, 206 frontend green — 16 of the backend tests are the citation
contract and the retrieval boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-10 15:19:24 +02:00
Daniel
2d2845fa0a fix: legible section bar, and a real page for editing a question
Colours
`.sr-only` was used in four places and never defined, so every label meant for a
screen reader was printed on the page: "Studying for" beside the exam switcher,
"Difficulty", "Questions", "Category for …". On the old dark bar the stray text
passed for a label; on the new light one it was grey-on-grey and looked broken.
Defined it, and restyled the exam switcher for the surface it actually sits on
now rather than for the dark bar it used to.

The section bar itself was white on white, reading as a stray row of links
rather than a surface: it now sits a shade off the page with a hairline shadow,
links take the text colour at 78% instead of the muted grey, and the current
section gets a tinted pill with an underline. When the links outrun the width
the strip fades at the right edge — a hard cut just looks like a broken layout,
and "Courses" was being sliced in half.

Editing a question
The bank's Edit opened a modal that could not show option explanations, images,
versions and categories at once, and the linked-questions list on an article
offered only "Unlink" — you could read a question there and have no way to fix
it. Both now open /questions/:id, the full page, which already existed and was
reachable from almost nowhere.

The page carries where you came from: back and Cancel return to the bank with
its filters, or to the article, rather than always to a bank you may not have
used. Its header is sticky, because this page is long enough that scrolling to
the stem loses the way out entirely.

The quick modal stays where a quick correction belongs — the question manager —
and its two tests moved onto the component itself rather than reaching it
through a page that no longer opens it.

198 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:43:02 +02:00
Daniel
bbc35ce5d8 feat: two-bar header, with the section bar out of the way while you read
Eleven links, a search box, an exam switcher and a logout button were all
competing in one 52px row. Two bars, as on the reference site:

  * the primary bar — identity, search, jobs, account — never moves;
  * the section bar carries the exam switcher and the sections, and leaves while
    you scroll down, returning on the way up.

That bar is navigation: wanted at the moment you decide to go somewhere else,
in the way for every moment in between. Scrolling up is the gesture that means
"I am looking for something", so that is when it comes back.

Three details it needs to not be annoying. It collapses its height rather than
sliding away, so the page moves up with it instead of leaving a gap. Nothing
hides within 90px of the top, so a short page never loses it. And a movement
under 6px is treated as noise, because trackpads and momentum scrolling emit a
great deal of one-pixel jitter — without that guard the bar flickers on a
stationary page. Scroll handling is one animation frame per burst, and
`:focus-within` keeps the bar open for a keyboard user tabbing into links that
are visually gone.

On a phone the burger keeps the sections and the strip hides: two copies of the
same links competing for the same thumb is worse than one.

The article contents are now a rail rather than a card — sticky for the article's
whole length, scrolling on their own when there are more sections than screen,
and collapsible to hand their width to the prose, which is what a wide table
needs and nothing else on the page can give it.

196 frontend tests green, 7 of them new on the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:14:50 +02:00
Daniel
8a07703ee2 feat: study plans you can open, work through, and edit
Thirteen plans were seeded with an API to serve them and nothing that called it,
so the whole feature existed only in the database. Two pages and the editing
endpoints it was missing.

/study-plans lists the plans with progress stated in blocks — "3 of 6 blocks"
is something you can act on, where "50%" only tells you how you feel about it.
/study-plans/:id is one plan: each block shows Articles, then Sessions, in that
order, because that is the order the block is meant to be done in.

Reading is now part of a block (migration f4a5b6c7d8e9). "Mark as read" is the
learner's own claim and reversible — someone who ticks the wrong row should be
able to fix it without an educator, and progress nobody can correct stops being
trusted and then stops being used. It is a separate table from `article_views`
on purpose: opening an article is not the same claim as having finished with it.
A draft article attached to a block is listed for the educator who can open it
and left out for everyone else, rather than offered as a dead link.

Editing is inline on the learner's own page rather than a separate builder, so
the thing being changed and the thing a learner sees are the same object.
Moderators create (as a draft — an empty plan is not something to put in front
of anyone), rename, publish, delete; add, rename, reorder and remove blocks;
move questions between blocks of one plan; attach reading found by searching
rather than by id.

Two places where the obvious implementation leaves the data wrong, both tested:
deleting a block out of the middle shuffles the survivors down, or the next
insert collides with a position nothing occupies; and reordering parks every row
outside the range before writing the real positions, because (plan_id, position)
is unique and the first move would otherwise collide with a position still held.
A partial order is refused rather than half-applied.

166 backend, 188 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:10:39 +02:00
Daniel
50ddcafd82 feat: an image bank you can actually use
The media API has existed since the image-libraries work with nothing to call
it, so images could be uploaded but never described, tagged, or found again. Two
screens close that.

/media browses the bank by library, searches by what an image shows, and edits
title, caption, alt text, tags and library in place. The id sits on every
thumbnail rather than appearing on hover, because the id is what a question
refers to and you should not have to go looking for it. An image with no caption
says so — captions are what the search index is built from, so an undescribed
image is one nobody will find, and that is worth saying on the card rather than
in documentation.

The question editor asked for a filename typed from memory, which meant keeping
a second tab open or guessing. It now opens a picker over the same bank, showing
caption and tags on every result: two chest films are identical at thumbnail
size and the caption is the only thing that tells them apart. An image uploaded
from the picker goes straight onto the question — coming here to add a picture
and then having to find it again is a step that exists for nobody's benefit. The
"Browse image bank" link pointed at /images, a route that does not exist.

Permissions follow the API rather than reimplementing it: library creation and
deletion stay with moderators, describing an image does not, and an upload goes
into the library being browsed because a non-moderator must name one.

177 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:04:06 +02:00
Daniel
885e8be417 feat: one search across reading, questions, cards and images
Five corpora were each searchable from their own page, which meant knowing which
of five pages held the thing you were looking for before you could look for it.
`GET /search` runs them together.

Visibility is never re-implemented here. Questions go through the same bank
predicate and exam scope as the question bank, articles through the same draft
rule, cards through deck ownership, images through library grants. A search page
with its own idea of who may see what is how private content leaks, so the tests
that matter are the boundary ones: a peer's search reaches neither another
user's unshared question nor their deck, and a draft is invisible to everyone
but the educator who wrote it.

A section hit is reported under its article, not beside it — ten sections of one
article are one result with ten places to start reading, not ten results burying
everything else. This is what the section index was backfilled for; each one
links straight to that section.

Results are grouped by kind rather than interleaved by score. A question and an
article are different kinds of answer, and a single ranked list makes you read
every row to work out which kind each one is. Snippets show the window around
the match rather than the opening of the document, because every document's
opening looks the same. A question found only by the semantic ranker says so.

The header box has two ways out: pick a suggestion and go straight to that
article, or press Enter and search everything. Suggestions are lexical and
prefix-first — a typeahead is finishing the word you are typing, and a semantic
neighbour of half a word is noise — and debounced 180ms so typing is not a
request per keystroke. One corpus failing is logged and returned as a gap in the
answer rather than a failed page.

154 backend, 163 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 11:59:58 +02:00
Daniel
362c48926b feat: browse the library in columns, read articles section by section
Three things, all from how AMBOSS actually behaves rather than from a
description of it.

Row by row, two menus
The articles page is now the column browser itself rather than a grid behind a
"▼ All categories" toggle. Opening a topic opens its contents in the next
column, so the trail you took stays on screen and you can step back a level
without losing your place. Topics and the articles filed under them share a
column, because to a reader those are the same list — things this heading
contains — and only the icon separates a folder you can open from a page you can
read. Articles filed nowhere sit in the root column instead of being unreachable
for want of a heading. Under 720px it is one column plus a back button. Search
is a different question from browsing — you already know the name — so it still
answers with a flat list of matches.

Sections that collapse
An article is a reference you consult, so it opens as a contents page: headings
only, each expanding where it sits. A section may now sit under an earlier
top-level one (`parent_id` on the section JSON, absent on every article written
before this), which is how "ROS questionnaire" belongs to "Review of systems"
rather than standing alongside it. The contents rail nests the same way. Nesting
is refused where it could not render: its own parent, a parent later in the
article, a parent outside it, or a sub-section of a sub-section. A deep link
opens the target section and its parent — landing on a collapsed heading looks
like the link went nowhere. References are pinned last however they were
written; a reader scrolling for content should not hit the bibliography halfway
down.

Links that show where they go
`[[febrile-seizures]]` or `[[Febrile seizures|febrile-seizures]]` in article
prose becomes an in-app link that previews the target on hover: title, a couple
of sentences of actual prose with the markup taken out, and how much is there.
Following a link to find out whether it was worth following is the thing that
breaks a train of thought. Slugs, not ids, because that is what an educator
writes and it outlives a renumbering. One fetch per article for the life of the
page, a 350ms delay so crossing a link summons nothing, and no card at all on
touch, where a card would sit between the finger and the link.

Dead CSS for the old section modal and the always-open section block is gone —
nothing rendered those class names any more, and stale rules winning on source
order has bitten this page before.

146 backend, 152 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 11:42:33 +02:00
Daniel
df15a14983 docs: record the taxonomy work as done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:51:44 +02:00
Daniel
086d5605a5 feat: one screen for editing every taxonomy axis
The tags API could create, rename, reparent, delete and attach questions, but
nothing in the interface called it — an admin could only change the taxonomy
through curl. This is the screen for it.

/categories becomes Taxonomy, with five tabs: Topics, Systems, Symptoms,
Diseases, Subjects. Topics come from question_categories and the other four from
question_tags split by type, but that split is an implementation detail and not
something to make somebody navigate around, so it is one screen.

Symptoms is 4,262 rows. The old page drew the whole tree on load, which for that
facet is a second of layout and a wall nobody can read, so the tree now opens
closed with a chevron per branch, the root list is capped at 150, and searching
flattens to matches with the trail that leads to them.

Two details the tabs have to get right:
  * a symptom filed under a system has a parent that is not a symptom, so the
    system is drawn in as a heading — otherwise 726 of them lose their root and
    vanish. Headings are not editable from that tab; systems belong to Systems.
  * deleting differs by table. A category with children is refused server-side;
    a tag's children rise to its parent. The confirmation says which you get
    rather than promising one and doing the other.

"+ Questions" on any row searches the bank and attaches what you check —
POST /tags/{id}/questions for a tag, /questions/bulk-category for a topic. Both
land in the pickers immediately, since the bank and quiz builder read the same
endpoints this page writes to.

16 tests on this page (up from 10), 143 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:51:25 +02:00
Daniel
d509d3db52 feat: real organ systems, symptoms grouped by system, and admin taxonomy CRUD
Systems were never systems
The 27 top-level rows were disciplines and care settings — Cardiology,
Emergency Medicine, Neonatology, and a stray condition (Sepsis) — not organ
systems. Cardiology is a discipline; Cardiovascular System is a system. So the
facet was mislabelled, and there was no organ-system axis at all.

Both fixes, as asked:
  * that tree is now the "Topics" facet, which is what it always was;
  * "Systems" is a new flat axis of 16 organ systems, matching how AMBOSS keeps
    Systems flat while nesting Disciplines and Symptoms.

Tags can nest (migration e3f4a5b6c7d8)
`question_tags` gains parent_id and sort_order. A tag may sit under one of the
same kind (Surgery > Hand surgery) or under a system, which is how symptoms are
grouped by where they present. 726 symptoms are now filed under the system they
appear in; the remaining 3,536 stay top-level rather than being forced into an
approximate bucket. A false positive the dry run caught: "vision" was matching
"Health Supervision" — the same trap as erythema/erythematosus earlier, fixed
with a word boundary.

Admin can grow the taxonomy without a migration
POST /tags creates a top-level entry or a child; PATCH renames, reorders and
reparents, refusing a cycle; DELETE reparents children to the deleted tag's
parent rather than orphaning them, and can move its questions elsewhere;
POST /tags/{id}/questions attaches questions. Everything appears in every picker
immediately, because they all read the same endpoint.

Article sections were indexed but empty — `_rebuild_section_index` only runs on
save, so articles written before it existed had no rows. Backfilled: 10 articles,
28 sections, now embedded and searchable. Section-scoped question links already
worked (7 of 34 links name a section).

Tests: 10 new backend covering the tree shape, adding top-level and child
entries, kind rules, duplicate refusal, cycle refusal, rename/reparent, question
attachment, delete-reparents-children, delete-with-move, and the moderator gate.
141 backend, 136 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:44:39 +02:00
Daniel
04310c8980 feat: drill-down category pickers and a column browser, matching AMBOSS
The tree was nested in the database but every picker still rendered it flat as
breadcrumb strings — "Neurology › Seizures & Epilepsy › Absence Seizure" beside
"Genetics & Metabolism › Achondroplasia" — so the nesting was invisible and 780
rows competed for attention at once.

CategoryDrilldown (facet pickers, question editor)
Walks the tree one level at a time, matching how AMBOSS draws Disciplines and
Symptoms: chevron on the left, then the checkbox, then the name. Nothing is
expanded on load; expanding is a separate control from selecting, so opening a
branch never silently applies a filter; and one branch stays open per level, so
the list cannot grow back into the wall it replaced. Typing flattens to matching
rows with their trail, because when you type a name you want the row, not the
path to it. Counts are roll-ups, so the number matches what the filter returns.

CategoryColumns (articles page)
Replaces a 780-entry dropdown with side-by-side columns, as in the AMBOSS
library: choosing in one column opens its children in the next, so the trail you
took stays on screen. Under 720px it becomes one column plus a back button —
the same navigation drawn for the width available, rather than two columns
squeezed until neither is readable.

Touch targets are at least 44px throughout, and expand and select are separate
hit areas so neither is a near-miss for the other on a phone.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:34:58 +02:00
Daniel
d071e5cdc5 feat: serve uploads through storage, and move all 3,852 files to MinIO
Serving went straight to disk with FileResponse, so object storage was
effectively write-only: bytes went to the bucket and were still read from the
volume. `/uploads/{path}` now tries the local file first, then the object,
keeping the existing authorisation and path-confinement checks in front of both.
That is what makes the volume removable at all.

Migration (scripts/migrate_uploads_to_s3.py)
Every file is copied and read back with a SHA-256 comparison before anything is
deleted, and deletion is a separate opt-in flag that refuses to run if a single
file failed to verify. 3,852 files, 853.7 MB, all verified, then removed from the
volume — which now holds 0 files.

A bug this caught in its own first run: verification used `storage_service.load`,
which falls back to the volume, so it compared each local file against itself and
reported 3,852 perfect matches against an empty bucket. `s3_object` reads
strictly from S3 with no fallback, and verification uses that. The fallback is
right for serving and wrong for verifying, and the two now have separate calls.

Proven before deleting: a file removed from the volume still served correctly and
byte-identically from the bucket.

Backups, corrected: borgmatic already covers /var/lib/docker/volumes, so
quiz_minio_data is backed up nightly with 7/4/6 retention — my earlier claim that
MinIO was outside the backup routine was wrong, based on db-backup alone.
Existing archives still hold the old uploads volume, so there is no window in
which these files exist in only one place.

Tests: 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:23:34 +02:00
Daniel
db2df87fc6 feat: MinIO-backed media libraries, and file the last 316 questions
Storage
Media now goes through `storage_service`, which has two backends: the container
volume, and S3/MinIO. A volume can only be mounted by one host, has no presigned
URLs and no lifecycle rules, none of which suits ~860 MB of media. Reads fall
back to the volume when an object is missing, so the existing uploads keep
working and files can migrate gradually rather than in one risky pass.

A row stores the object key, never a URL: a URL embeds the backend, so a row
holding `http://minio:9000/...` breaks the moment the backend changes.

MinIO publishes no host ports — the backend reaches it over the compose network,
and 9000/9001 are already taken on this host by other stacks.

Image libraries (migration d2e3f4a5b6c7)
An image belongs to a library, and a person is granted a library the way they are
granted a category, so access can be given to some images without giving away all
of them. Tags reuse the shared `question_tags` vocabulary rather than inventing a
media-only one. Uploads are type- and size-checked, stored through the service,
and embedded so an image can be found by what it shows.

Classification finished
The 316 questions the chooser had declined are now filed with `--force`, which
takes the nearest candidate from the same shortlist the chooser saw. 306 were
forced, 10 the chooser accepted on this pass. No question sits on a bare system
any more:

  system only          2,730 -> 0
  condition/subsystem    214 -> 1,782
  full depth               4 -> 1,166

A forced match is a weaker signal than a chosen one, so expect more errors among
those 306 — but the original system stays as a cross-link, so nothing is lost and
they can be corrected by hand.

Tests: 8 new backend covering library scoping, edit confinement, shared-vocabulary
tags, storage indirection on upload, and type/size limits. 131 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgRcMaScVEL7TBLpnAoSV9
2026-09-10 06:45:22 +02:00
Daniel
3b1ac9aea8 feat: classify the questions left sitting on a bare system
1,486 questions had a top-level system as their primary category with no
condition to promote — General Pediatrics alone held 396 and has no
subcategories at all, so its questions are almost always about something that
already exists elsewhere in the tree.

Two stages, same discipline as the AI-mode citation contract: retrieval ranks
existing categories against the question's stored embedding and keeps the nearest
eight; a chooser picks one of those or says NONE. The model never sees the whole
tree, never writes a category name, and anything not on the shortlist is
discarded — so a category it invents cannot survive. Nothing new is created:
questions are filed into categories an educator already has.

1,170 filed, 301 declined by the chooser, 15 with no candidate above the
similarity floor. Filing depth across the bank:

  system only          2,730 -> 316
  condition/subsystem    214 -> 1,596
  full depth               4 -> 1,036

A spot check found roughly three in four correct — a question about an ADHD
medication refill was filed under Obesity. That is acceptable only because the
original system is kept as a cross-link on every moved question, so a wrong pick
narrows nothing and never loses a question. Recorded in the TODO for an
editorial pass.

Applied after a backup. Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 05:17:24 +02:00
Daniel
5b865faf80 feat: promote primary categories from system down to condition
93% of questions had a top-level system as their primary category, so the
condition tree was reached almost entirely through cross-links: every deep node's
count was a link count, and a question about Tetralogy of Fallot read as
"Cardiology" rather than as what it is about.

Where a question already linked to a condition inside its own system, that link
was the specific classification, so it becomes the primary and the system is kept
as a cross-link — nothing narrows. 1,244 promoted, leaving 1,486 at system level
rather than 2,730. Destinations check out: VSD under Congenital Heart Disease
under Cardiology, Pertussis under Bacterial Infections under Infectious Disease.

Where several candidates existed the deepest won, breaking a tie on question
count; 31 that tie on both are left alone rather than resolved arbitrarily.

1,455 questions have no condition inside their own system at all, so there is
nothing to promote — they are genuinely unclassified below the system, worst in
General Pediatrics (396, which has no subcategories at all), Emergency Medicine
and Adolescent Medicine. That needs classification work, not a data move, and is
recorded in the TODO rather than guessed at.

The one uncategorised question was the seeded febrile-seizure demo; it is filed
now, so all 2,948 resolve to a system.

Idempotent, applied after a backup. Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:32:55 +02:00
Daniel
3cbb1957cf feat: nest the system tree and give each condition one home
Nesting (scripts/nest_systems.py)
The tree was 27 systems and ~1,050 conditions, one level deep — a flat
alphabetical wall in the Systems picker. It is now System → Subsystem →
Condition: 69 subsystems, 305 conditions nested, 564 left directly under their
system because forcing an approximate bucket is worse than leaving them.
Subsystem names are standard textbook divisions, each matched by an explicit
pattern so a dry run shows exactly what moves. Three false positives the dry run
caught were fixed before writing: "erythema" was pulling Systemic Lupus
Erythematosus into dermatology, and a bare "meconium"/"aspiration" was filing
Meconium Ileus as respiratory and Meconium Aspiration as foreign-body.

One home per condition (scripts/merge_duplicate_conditions.py)
The tag→category conversion made each question's subject tag a parent, so a
question tagged Pulmonology and Emergency Medicine produced an "Asthma" row under
each. 330 of 618 condition names lived in several systems — Sepsis in seven,
Failure To Thrive in eight — so no filter ever added them up.

216 names are now folded into the row holding the most questions, and the other
systems survive as `question_category_links` on each moved question: a link is
how "this matters to nephrology *and* emergency medicine" is meant to be said,
duplicate rows are not. 347 rows removed, 1075 → 786, all 2,948 questions intact.

114 names are an even split across systems. Those are reported, not merged —
picking a home for Pneumonia between Pulmonology and Infectious Disease is a
medical call, and an arbitrary tie-break would bury it.

Both scripts are idempotent and were applied after table backups.

Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:26:48 +02:00
Daniel
eeadeb4a94 fix: category tree cleanup, mixed-content redirect, and category page clarity
Category duplicates
The tag→category conversion appended a parent name to keep labels globally
unique, so the tree was full of rows like "Cellulitis (Emergency Medicine)"
filed under Emergency Medicine. The breadcrumb already shows the parent, so the
suffix was noise in every picker. scripts/sanitize_categories.py strips it,
then merges siblings that collapse to the same name — including the
"Absence Seizure" / "Absence Seizures" pair. Applied to production after a
table backup: 491 renamed, 3 merged, 1078 → 1075, and a second run is a no-op.
Merging repoints questions, additional-category links, articles, decks, child
categories and grants before deleting the losing row.

Newly created categories not appearing
`/api/question-categories` (no trailing slash) 307-redirects to **http://**,
which the browser blocks as mixed content on an https page. Three callers used
the bare path, so the request failed silently into a catch and the list stayed
stale. Trailing slash added.

Category page
Rows now show questions filed directly here, the roll-up including everything
beneath, subcategory count, and an Empty badge for a leaf holding nothing — so
the shape of the tree is visible rather than inferred. On small screens it says
plainly that editing is easier on a desktop.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:09:18 +02:00
Daniel
beedb76afb fix: bank stylesheet was never imported; drop quiz categories; rail and sessions
QuestionBankPage.css was not imported, so none of the filter-bar styling applied
— which is why "⚙ Filters" and the question count ran together with no card
behind them. Importing it fixes the whole bar, not just the spacing.

Quiz/test categories removed. PREP lives in study plans now, so the Categories
tab, the category selector in the session menu, and the handlers behind them
were managing a concept that no longer exists.

Sessions list shows the first six with "Show all" and a link to the full history
on the analysis page, instead of printing every session inline.

The analysis session rail now runs the full height of the viewport against the
left edge rather than sitting in a boxed card, so a long list is one continuous
column. Below 1100px it returns to a card above the content.

Tests: 136 frontend green, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:04:28 +02:00
Daniel
e0dc3a5da5 docs: capture the UI feedback from 2026-09-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:02:50 +02:00
Daniel
2958779067 feat: grants can name an exam and a discipline, not only a category
An admin can now say "you edit Step 1 Cardiology" rather than only "you edit this
category". Each dimension on a grant is nullable and means "any"; a grant covers
the questions matching all the dimensions it sets, and holding several grants is
the union of their coverage (migration c1d2e3f4a5b6). A check constraint refuses
a grant that names nothing, which would otherwise mean "everything".

Permission checks now run against a predicate over Question rather than a set of
category ids, so the exam and discipline dimensions actually take effect on edit,
delete and bulk actions instead of being silently ignored.

Pediatrics is unbound back to a global tag. With counts already scoped by the
learner's active exam, one global row gives the right number per exam, so
scoping the row bought nothing and duplicating a 6,740-tag vocabulary per exam
would have to be repeated for every rename and merge.

Tests: 123 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:02:36 +02:00
Daniel
0b58b903d2 docs: mark version history and exam-scoped tags done; settle the link design
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:48:52 +02:00
Daniel
b7947b1d53 feat: exam-scoped disciplines and exam-scoped facet counts
A discipline may now belong to one exam. `question_tags.exam_id` NULL keeps a tag
shared — Cardiology means the same thing whichever exam you sit — while a set
exam_id scopes it. Boards Pediatrics and a future Step 1 Pediatrics are therefore
separate rows over genuinely different bodies of content, not one label stretched
across both. Uniqueness moves from (name, type) to (name, type, exam) to allow it
(migration b0c1d2e3f4a5).

`scripts/bind_exam_tags.py` binds Pediatrics to Pediatrics Boards and tags the
884 questions in that exam that were missing it — the whole bank is paediatrics,
so it now reads 2,948.

Facet counts are computed within the learner's active exam, and a tag scoped to a
different exam is left out: an unscoped list offered disciplines that could not
match anything they were studying. With no exam chosen, everything is offered as
before.

Tests: 4 new backend (same name once per exam, unscoped list offers all, choosing
an exam scopes counts and hides other exams' tags, switching exam switches which
Pediatrics is offered). Full suite green: 123 backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:48:34 +02:00
Daniel
817cff569d feat: question version history with restore; TODO list
Editing a question now snapshots its previous state. The last 5 are kept — the
value is undoing a recent mistake, not an audit trail, and an uncapped history of
full question bodies grows without bound (migration a9b0c1d2e3f4).

A restore snapshots the current state first, so the restore is itself undoable.
History is gated by the same per-category grant that gates editing, so it cannot
be read by someone who could not have made the edit. The question editor shows
the versions with their dates and a Restore action.

Also added docs/TODO.md tracking everything requested and not yet delivered:
AI Mode and its citation contract, global search, study-plan editing and
articles-in-blocks, admin settings revamp, image libraries and question folders,
media management, nested article sections with references and per-section notes
and feedback, per-question notes and feedback in the runner, tutorial mode, the
per-question performance table, the Overview dashboard, systems subsystems, and
dropping "Pediatrics" as a discipline.

Tests: 6 new backend (snapshot on edit, cap at five newest-first, restore,
restore is undoable, refused without edit rights, unknown version). Full suites
green: 119 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:27:44 +02:00
Daniel
8613fd1e5e feat: PREP study plans of numbered blocks
The PREP sets were loose admin-generated quizzes. They are now study plans: one
per year, split into blocks of 50 numbered "Block 1", "Block 2", plus a
"PREP Mixed" plan of 300 drawn at random across every year. 12 plans, 2,821
questions, applied to production.

Block membership is snapshotted rather than stored as a filter — a plan you are
part-way through must not reshuffle between visits. Re-running the seeder updates
years whose questions changed and leaves the mixed draw alone unless --reshuffle.

Starting a block reuses the learner's existing quiz for it; without that,
reopening a block would create a duplicate test each time and scatter the
attempts across them. Only questions the learner may see are included.

Tests: 113 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:25:29 +02:00
Daniel
899ad5e879 feat: media as a searchable corpus, ready for a vision embedder
Images were findable only by the filename someone typed. `media_assets` gives
them a title, caption, alt text, a category on the shared tree and tags, with a
weighted tsvector so they are searchable now (migration y7e8f9a0b1c2).

The embedding column is filled from the caption today. A vision-capable model can
fill it from the image itself later without another migration — and because
`embedding_model` stamps every vector, a text-embedded caption and a
vision-embedded image stay distinguishable instead of being silently mixed in one
index. Adding "media" to the embeddable kinds is all the retry task, the full
regeneration and the health report needed.

`media_tag_links.tag_id` carries no ORM-level foreign key: `question_tags` is
created by raw DDL rather than a model, so the constraint lives in the migration
where the table actually exists.

Tests: 113 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:22:52 +02:00
Daniel
4d0cdc8f2f feat: section-level index so retrieval can cite a section, not a whole article
An article embedded as a single vector, which finds the article but not the
paragraph — so a citation could only ever point at the top of a page. Sections
live in a JSON column and cannot carry a vector or a full-text index, so they are
now projected into `article_section_index`: one row per section with its own
embedding and weighted tsvector (migration x6d7e8f9a0b1).

- Rows are keyed by section id, so editing a section updates it, removing one
  deletes it, and an unchanged section is not re-embedded on every save.
- `article_section` joins the embeddable kinds, so the retry task, the full
  regeneration and the health report cover it without further changes.
- `hybrid_ids(db, query, "article_section")` searches it like any other corpus.

This is the groundwork for grouped search results (article, then the sections
that matched) and for AI citations that deep-link to the right section.

Tests: 113 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:18:53 +02:00
Daniel
587b23db8f fix: reveal rail excerpts gradually; session rail on the analysis page
Runner rail was leaking upcoming questions
It showed an excerpt for every question in the session, including ones the
learner had not opened — so the case was spoiled before they read it, the same
mistake the article page was making with answers. A question now shows its text
only once reached; the rest are bare numbers.

Analysis page
Added the "Latest sessions" rail beside the analysis, collapsible, each row
linking to resume or review.

SessionProgress component
A single flat fill cannot tell "answered 15, all right" from "answered 15, half
wrong", which is the thing worth seeing at a glance. Progress now renders as
correct / incorrect / remaining segments, shared by the analysis rail and the
dashboard panel.

Tests: the rail test now asserts an unopened question shows no excerpt and
reveals one when reached. Full suites green: 113 backend, 136 frontend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:11:11 +02:00
Daniel
48f3ded222 feat: exams as real data, AI-mode matching, and a continue-study panel
Exams (migration v4b5c6d7e8f9)
"Pediatrics Boards" was a hardcoded checkbox that filtered nothing. Exams are now
rows: Pediatrics Boards and USMLE Step 2 CK ship seeded, and everything already
in the bank is linked to the boards. Membership is a link table, not a column,
because one paediatric cardiology question can count towards several exams.

The learner's choice lives on `users.active_exam_id`, so it follows them between
devices instead of sitting in one browser's storage. Choosing an exam scopes the
bank; a question with no exam links stays visible, since unlinked content is
unclassified rather than excluded. A switcher sits in the navbar.

AI mode — matching, never generating
Both entry points build a test from the educator-reviewed questions that already
exist, ranked against the request. Nothing is invented:
- POST /questions/builder/describe turns "what I want to study" into a test.
- POST /questions/builder/from-upload matches a document against the bank. The
  file is read in memory and never stored — it is a search query, not a source
  of questions, so there is nothing to retain or expire. 10 MB cap, 30 questions.

Handing a whole document to `websearch_to_tsquery` builds one enormous
conjunction that matches nothing, so text over 300 characters is reduced to its
most distinctive terms, OR-joined, before it reaches the lexical ranker.

Continue your study (migration w5c6d7e8f9a0)
A dashboard panel with the sessions in flight and the articles most recently
opened. `article_views` records one row per learner and article, written best
effort so a reading page never fails because a bookkeeping write did.

Tests: 5 new exam tests (active exams and counts, choice persisted and cleared,
unknown/inactive refused, bank scoping including unlinked questions, moderator-only
creation) and 7 for AI-mode matching (no questions created, invisible questions
excluded, no-match reported rather than an empty test, upload limits enforced).
Full suites green: 113 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:08:38 +02:00
Daniel
25109d756d feat: hybrid search for articles and cards; full-page question editor
Retrieval generalised beyond questions
`_text_for_question`, `embed_question` and `hybrid_question_ids` all hardcoded
the questions table, so there was nothing to call for an article or a card. That
layer is now corpus-agnostic:
- `Embeddable` mixin gives articles and flashcards the same embedding,
  embedding_model and embedded_at columns questions have, plus a weighted
  full-text vector (migration u3a4b5c6d7e8).
- `embed_record(row, kind)` is one code path for all three — they share an
  embedding space, so they must share the model and provenance rules too.
- `hybrid_ids(db, query, kind)` ranks any corpus; `hybrid_question_ids` stays as
  a thin alias for existing callers.
- Article and flashcard search moved off `ILIKE '%term%'`, which could not find
  a jaundice article from "yellow newborn".
- The retry task and full regeneration now sweep every corpus, and the health
  report breaks down current/stale/missing per kind.
- Articles embed on create and on edit, with failures left to the retry task.

Quoted phrases replace the keyword-only mode
`websearch_to_tsquery` already gives "absence seizure" exact-phrase semantics,
and the semantic ranker sits out a quoted query. That covers the one case a
keyword-only toggle was for — exact lookup — per query rather than as a sticky
setting whose every position returns a subset of the default.

Full-page question editor (/questions/new, /questions/:id)
Editing happened in a cramped modal. There is now a page with room for the stem,
per-option explanations, a searchable category picker with primary plus extras,
difficulty, and images. It shows the question's id with a copy button, and
Duplicate creates a variant without retyping the stem. `GET /questions/detail/{id}`
backs it, pathed under /detail/ so it cannot shadow the static routes.

Question bank filter bar restyled — the toggle and count read as one control
instead of two grey pills crowding the result count.

Tests: 101 backend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:01:35 +02:00
Daniel
db5af8d661 fix: segmented mode control was overridden by dead CSS
Two `.custom-test-modes` rule sets survived the earlier rebuild. The later one —
from the radio-card design that the sticky bar replaced — won on source order, so
the control rendered as two oversized separate boxes instead of a segmented pair.
Removed it, along with `.custom-test-actions`, which styled an action row that no
longer exists.

Also tightened the bar: smaller segment padding, and Refresh is now a quiet ↻
affordance rather than a grey slab competing with Create Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 01:46:28 +02:00
Daniel
9b51a17507 feat: flashcard decks share the question and article category tree
Questions and articles both pointed at `question_categories`; decks had no
category at all, so the three content types could not be filtered together and a
topic's cards were unreachable from its category.

- `flashcard_decks.category_id` references the same tree (migration
  t2f3a4b5c697), so one category now spans questions, articles and cards.
- `GET /flashcards/` takes `category_id` and includes descendants, so a parent
  category picks up everything filed beneath it.
- `PATCH /flashcards/{id}` files or unfiles a deck, refusing a category id that
  does not exist rather than storing a dangling reference.
- The cards page shows each deck's category as a selector.

Tests: 5 new backend (all three types resolve to the same id, descendant
filtering, file and unfile, unknown category refused, renaming leaves the
category alone) and 1 new frontend. Full suites green: 101 backend,
136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 01:40:03 +02:00
Daniel
e9c8bfd204 feat: session rail with excerpts, and articles that read as one page
Quiz runner — session rail
The desktop sidebar was `display: none` in the player theme, so the only way to
move between questions was a dropdown that showed numbers and nothing else. It
is now the left rail a Qbank session has: one row per question with its number,
an excerpt of the stem, its difficulty, and a mark for bookmarked ones. The
active row is marked with aria-current, answered rows turn green, and the rail
sticks while the question scrolls. Below 1150px there is no room for both, so
the topbar dropdown takes over as before.

Article pages — read straight through
Each section opened in a modal overlay, which meant a topic could not be read
end to end: you opened a section, read it, closed it, opened the next. Sections
now render inline as one page under a sticky contents rail, with the last-edited
date, matching how the reference reads. Deep links still work — `?section=` now
scrolls to and highlights the section instead of trapping the reader in a
dialog, and the rest of the article stays visible around it.

`scrollIntoView` is called defensively: it does not exist in every environment.

Tests: 2 new runner tests (rail lists number, excerpt and difficulty; navigates
and follows the active row; marks answered after a study response is submitted).
Stem assertions are now scoped to the question card, since the rail repeats each
stem as an excerpt. Article tests assert sections are inline and no dialog
opens. Full suites green: 96 backend, 135 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 01:36:45 +02:00
Daniel
878e61c69b feat: facet filters on the question bank, category page, sanitised tags
Question bank filters
The panel was a dialog inside a panel inside an aside, with raw checkbox lists
truncated at `.slice(0, 40)` — so most of the vocabulary was simply unreachable.
Replaced with the same facet rows the test builder uses: Status, Difficulty,
Systems, Disciplines, Diseases, Symptoms, Articles, each opening a search +
checklist panel and summarising as "Name +N". Tag lists show the most-used first
and reach the long tail by search instead of hiding it.

Category management page (/categories)
Renaming, reparenting and delete-with-move used to live inside that filter
panel. They now have their own page: a searchable tree with question and
subcategory counts, create-under-parent, and inline delete that rehomes the
questions. A category is never offered its own descendant as a parent, and one
with subcategories refuses deletion rather than orphaning them.

Tag vocabulary sanitised (scripts/sanitize_tags.py, idempotent, --apply to write)
The tags were model-generated per question, so the same concept recurred with
different casing and pluralisation. Applied to production, after a table backup:
  83 renamed   (Adhd→ADHD, Ige→IgE, 46,Xx→46,XX)
  75 merged    (Absence Seizures→Absence Seizure, Food Allergies→Food Allergy)
  17 disease→keyword   27 unused deleted     6859 → 6740 tags
Symptom reclassification matches whole names only. Substring matching moved
"Whooping Cough" and "Rocky Mountain Spotted Fever" out of diseases, so the rule
now requires an exact match, and genuinely ambiguous terms ("seizure",
"jaundice", "murmur") are left alone rather than guessed at — misfiling a
diagnosis as a symptom is worse than an untidy vocabulary.

Test builder on mobile
The sticky bar's `margin: 4px -16px -100px` guessed the page's own padding and
overflowed when it differed; it now bleeds to the viewport instead. The bar was
also wrapping into three ragged rows — modes now span the top and Refresh sits
beside Create. Long facet names truncate rather than widening a row into a
sideways scroll, and panel rows got comfortable tap targets.

Tests: 9 new for the category page (reparent, descendant guard, delete-with-move,
default-to-uncategorized, subcategory refusal, create, search, server refusal);
the three category tests move off the bank suite with it. Full suites green:
96 backend, 133 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-10 00:13:31 +02:00
Daniel
519f2e572a feat: hybrid search on BGE-M3, with embedding provenance and a retry job
Search
- Retrieval was hybrid in name only: the keyword filter was applied to the SQL
  query, so results were the *intersection* of the two rankers. A question that
  matched the meaning but not the literal string could never be returned. It is
  now a union, fused with Reciprocal Rank Fusion (a text rank and a cosine
  distance are not on comparable scales, so RRF uses only their orderings).
- Added a generated `search_vector` tsvector + GIN index, so the lexical half is
  ranked full text rather than ILIKE substring matching.
- Chose Postgres + pgvector over OpenSearch/Elasticsearch: a search cluster
  would add a second datastore to keep in sync and a JVM on this host, to
  replace an index Postgres maintains inside the same transaction.
- Removed the keyword-only mode. It looks precise but silently drops the
  question that asks the same thing in different words.

Embeddings — measured on 500 real questions, using each question's own
explanation as a paraphrase query (known answer, no hand labelling):
  bge-small (local CPU, 384d)  R@1 0.840  R@5 0.953  186ms/query
  bge-m3 (LiteLLM proxy, 1024d) R@1 0.847  R@5 0.973   93ms/query
BGE-M3 wins on both quality and latency and needs no extra credential, since
llm.danvics.com already serves `openrouter-bge-m3`.

Three gaps this exposed, all fixed:
- Nothing recorded which model produced a stored vector, so changing models
  silently mixed incomparable spaces. `embedding_model` / `embedded_at` now
  stamp every vector, `GET /admin/embedding/health` reports current vs stale vs
  missing, and regeneration defaults to stale-only.
- The generator read the model from env while the stamp read a Redis override,
  so a vector could be labelled with a model that did not produce it. Both now
  resolve through one function, with a regression test.
- Embedding at creation is best effort, and a failure left a question invisible
  to semantic search forever. `retry_missing_embeddings` runs every 15 minutes
  via Celery beat and backfills missing or stale rows.
- Query embeddings are cached in Redis per model, so typing is not a network
  round-trip per keystroke.

`dimensions` is only sent to OpenAI's embedding-3 family; BGE-M3 rejects it.

Tests: 8 new backend tests (union not intersection, fusion ordering, per-ranker
failure degradation, provenance stamping, stale/missing accounting, generator
and stamp agreement). Full suites green: 95 backend, 127 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 23:45:33 +02:00
Daniel
17af66adce docs: sync backlog with what shipped today
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 20:11:20 +02:00