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
The syntax was known only to the code that parses it. This is the author's
side of it: `[[264|respiratory failure]]` and why the id form is the one that
cannot rot, `==key points==` and the three rules the parser follows,
`{{phrase|tip}}`, what each of the three views is for, how to link a question
or a whole topic, and what deleting does to a draft versus to something that
has been published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Four things a later session would otherwise have to rediscover from the code,
including the two whose whole point is that they run before the obvious place
to look for them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
Three things above the stem — the session's name, the mode row, the "Question 4
of 12" counter — carried eighteen pixels under the header, twenty over the
counter and a line of spacing inside it. Together that was a third of the
screen before the first word of the question. Tightened, all of it.
The clock beside them may no longer be the part that gives: squeezed, "0h 00m"
broke after the hours and each of the three readings became two lines of its
own height, which is what pushed the group out of the bar it sits in.
And Next is an equal half of the bar rather than two thirds of it. It is the
only coloured thing down there; it does not also need to be a blue band across
most of the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
A summary where nothing is emphasised is a shorter article, not a revision aid.
An educator can now run a highlighter over the sentence that matters, from the
editor's toolbar or by typing the markers, and the reader sees it in yellow —
most usefully in the high-yield view, which is what it is for.
A remark plugin rather than a rewrite of the source string, for the same reason
as tip terms: a reader's own highlights and the read-aloud cursor are stored as
offsets into the raw markdown, and editing the string before it is parsed would
shift every offset after the first key point. Splitting the parsed text node
keeps each piece pointing where it came from.
`<mark>`, not a styled span: find-in-page and print already know what it means,
and so does a screen reader. A lone `==` stays the characters it is — "Sodium
== 140" is a lab value — and a highlight may not run across a line, so one
stray marker cannot swallow a section.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`.form-label` and `.input` are written in four places — the articles page, the
flashcards page, the article editor, the category tree — and were defined
nowhere. An undefined label class is an inline element, so the label sat flush
against an unbordered box: "Titlee.g. Febrile seizures". They are now the
standalone form of the `.form-group` rules, defined once, because a form should
not look different for having no wrapper around it.
And the section bar comes back when the pointer is on the logo. It hides while
you read, which is right, but scrolling up was the only way to see it again —
no use to a reader looking for the way *out* of the page rather than the way
back up it. Focus does the same thing, so a keyboard reaches it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The offer to practise a topic was a paragraph under the last section: it
appeared only where questions happened to be linked, explained itself at length
to everybody including the reader who never wanted questions, and sat where
nobody looks.
Now it is a button in the controls row, on every article whether or not
anything is linked — and that is the point of it. A reader's question is "are
there questions on this?", and a control that vanishes when the answer is no
cannot answer it. With nothing linked it opens a dialog that names the study
objective, says there is nothing for *that* objective, and offers a custom
session; the objective is usually the whole reason, and a learner who cannot
see which one they are on has no way to tell that from "nothing exists on this
topic at all". With questions behind it, it is the builder it always was.
Reading pages still never show a stem, an answer or an explanation.
Also: the two selects stack and the dialog scrolls inside itself under 520px,
and every control is left to the site-wide 16px rule for coarse pointers — a
field under 16px makes iOS Safari zoom in on focus and never zoom back out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"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
Three things this site does, and the third had no picture. A talk built from
the same evidence the bank is written from — a template, the figures dropped
in, exported as PowerPoint — is hard to say in a sentence and easy to show, so
this says nothing at all: a slide assembles itself, the deck beside it lights
up as each one lands, and the eye works out what it is watching. The one link
is navigation, not explanation.
Three slides share a 21s loop, each a third of a turn behind the last by way of
a negative delay, so there is no clock in JavaScript and nothing to unwind on
unmount. Every part inside a slide rides the same loop and the same delay plus
its own `--d`, and that is what staggers the build. Under calm motion the loop
is not applied and the three lie side by side, finished.
Also here, because it is the same argument: the study panel's caption is gone.
The panel is a picture of a question marking itself, which is the whole of what
the caption said, and a screen reader is told that by the figure's own label.
And the hero no longer says "pediatric". The domain says whose site this is,
more steps are coming, and a line naming one exam has to be rewritten the week
the next one is added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
`0700e68` and the two commits before it added the overview, its stylesheet and
the search-panel context, and committed only the page. App.jsx imports
`./context/SearchPanel` and DashboardPage.jsx imports `./DashboardPage.css`,
neither of which was in the tree — so master has not built from a clean clone
since. It built here because the files exist on this disk.
Nothing new: the four files as they already are, plus SearchOverlay's `tab`
prop, which is what the context passes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
The taxonomy rebuild regressed a previously-closed item without anybody
noticing: merging duplicate topics moved questions up onto the surviving root,
so 254 sat at system level where 108 had a perfectly good condition beneath
them. All 108 promoted, zero ambiguous — the script already knew where each
one went; nothing had told it to look again after the merge.
The remaining 146 stay put. There is no condition inside their system to hold
them, which is a gap in what has been written rather than a filing mistake, and
the tally by system says where the writing is missing.
And `Radial Head Subluxation` is `Nursemaid's Elbow`. Two rows for one idea
split its questions and offered a browsing learner the same topic twice; the
survivor carries both names so the merge loses nothing that anyone searches for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**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
A tour is a confession that the product does not explain itself, so this one
names the three things a learner would otherwise go looking for and stops:
the session chosen for them, search against AI Mode, and what the ring is
measuring. Anything needing six steps wants rebuilding rather than narrating.
Steps are declared against a `data-tour` attribute rather than a class or a
ref, because a tour that reaches into another component's internals breaks the
first time somebody renames something for a visual reason. A step whose target
is not on the page is dropped from the count rather than shown pointing at
nothing — an educator and a learner do not see the same dashboard, and "Step 2
of 6" must not promise a step that will never come. The count is fixed when the
tour opens, so a block arriving late cannot renumber it under somebody midway.
Only on the overview, and after the objective rather than beside it: two things
asking for attention at once is one of them being dismissed unread. Seen-ness
is per browser, because a tour is about the interface rather than the account.
The dimming is four rectangles rather than one enormous box-shadow, which would
repaint the whole screen on every scroll event — and this follows scrolling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The classifier review is moot: it reviewed a per-question organ-system pick,
and systems hang off the topic now — `question_tag_links` holds no system rows
at all, every one of the 569 topics carries a system, and all 2,922 questions
reach one through their topic. There is nothing left to review.
Admin settings was revamped by the Settings rebuild a day earlier and the list
never caught up. The short view is called high yield in the interface, so the
outstanding item says so.
Two are non-goals rather than work: coach marks over a product somebody is
trying to use are a substitute for the product explaining itself, and clinical
AI Mode belongs to ped-ai — this one should not grow a second answer to a
question that one already answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
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
`/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
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
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
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
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
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
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
**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
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
`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
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
**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> </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
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
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
The article was capped at 1080px and centred, so most of a wide window was
margin while the prose ran as a thin ribbon down the middle — and following a
cross-reference opened a whole second reader, rail and all, inside half of
that. Each further click started further right.
The rail is flush against the left edge of the window now, sticky and full
height with its own scroll, and the article takes the rest. Split view is a
third column of the same grid at a fixed width, so the article reflows once
when the pane opens and never again however deep you go. The pane renders the
reader bare — no rail, no depth switch, sections already open — because a pane
is a column to read, not a page to navigate. It keeps a trail: following a
reference from inside it replaces its contents and offers a way back, since
the only way back before was the browser, which takes the article with it.
The offset the rail starts at is measured off the navbar rather than written
down, because that bar collapses as you scroll and a hard-coded number would
leave a band of page above the rail or hide its first line.
The depth switch moved up beside the sections, where it reads as what it is —
our high-yield — and the rail follows it: a contents list naming sections the
body is not showing is worse than no contents list. A breadcrumb in the sticky
bar tracks the section under your eye, the rail is collapsible and remembers
it, and the toolbar carries text size and save-to-a-library.
And the summary no longer prints `[[288|eczema]]` at the reader. At the head of
an article it is prose in context, so it renders with its references live; in a
card or a list row the card is itself a link, and a link inside a link swallows
the click that was meant to open the article — so there it is flattened to its
labels.
Full-bleed and the session chrome are now separate claims. A reader wants the
window; a reader does not want the section strip taken away, because every link
on it is somewhere they may legitimately go mid-article. That is not true
inside a session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
`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
Nothing had imported it since editing moved to the full page; the only thing
that still referred to it was its own test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A tree you expand in place grows downwards, so opening a branch six deep
pushes everything under it off the screen and the thing you were looking at
ends up somewhere you have to hunt for. Columns grow sideways instead: each
level is its own list, the branch you opened stays lit where you left it, and
the path you took is readable straight across the headings.
The strip scrolls so the column just opened is at the right edge with the one
before it still beside it — two levels of context, and the ancestors clipped
off the left with their headings legible, so going back is a click on
something visible rather than a Back button. The strip is the scroller, not
the page, so descending never moves the page under the reader.
Not wired into a page yet; the reading library is being rebuilt alongside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Exit, Pause and End Block are the loudest decisions on the screen and were
three grey pills the same size as everything else. Each is an icon and a word
now, bold enough to reach for without reading, and on the exam's navy they are
white with End Block in red.
The rail said "Session questions" on every session ever sat. It says which one
this is instead — the mode, the title, how far through — so a repetition, which
is titled as one when it is built, is visibly practice rather than a score that
counts. The Review badge moved there with it; two of them on one screen saying
the same word about the same block was one too many.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
The exam bar had a navy background and dark navy text on it, so the two
numbers it exists for — which item, which block — were invisible, and the
rail's own hide handle was a white disc with a white chevron inside it. The
round arrows were drawn by the chrome while the labels still carried chevrons
of their own, giving every control two. All of it is flush now: the player's
page gutters were still there, framing a full-window exam in white.
The block's tools are the block's. Labs moved to the bar across the top, so the
copy of it under the stem is gone, and reading a paper aloud is not something
an exam offers — Listen, Listen through and the highlight clear are study's.
Next carries on being Next at the last item. There is nowhere further to go, so
where it goes is out: it ends the block. Disabling it there left the hand that
had pressed it four times with nothing under it. End Block stays where it is —
two controls, one outcome, because what you reach for at the end of a block is
not always what you have been pressing all the way through it.
The lab panel was two components sharing one class name, so the list's layout
leaked onto the column holding it and its controls — written for a dialog —
came out as bare browser buttons. Now it is its own thing, wide enough that
the reference range no longer runs off the right-hand edge, and stacked so
every value is legible at any width. The disclaimer about ringing your own
laboratory is gone; nobody sitting a board question is going to.
And nobody edits the reference table from inside a session. An educator
answering a question was one keystroke from changing a range every learner
reads. It lives in Settings → Tools now.
Also: the full-window session shell is claimed by the player rather than
guessed from the URL — /study/42 is also the screen that asks whether to start
one, and that screen is an ordinary card that was being left marooned in an
empty window with its own navigation stripped. And "Multiple choice" above five
lettered options labelled the obvious.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
Only the question scrolls in the middle column now. The bar carrying the item
counter and the tools was inside the scrolling area, so it went up with a long
stem — exactly when you wanted it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
three controls at the foot
The rail is a sidebar now rather than furniture: a long stem wants the
width, and the list is one press away on a handle that sits where the
rail was. Remembered, because somebody who put it away meant it.
Labs moved off the top toolbar onto the question's own bar, beside the
attending tip, and open as a column next to the case instead of a dialog
over it — a reference range is read while re-reading the stem, and a
dialog covers the thing it is for. Below 1150px there is no room for a
third column and it goes back to being a dialog.
The calculator is the exam's alone. A study session has no clock to beat
and no arithmetic to do under one; both it and the old Lab values button
were in that toolbar because the exam player and the study player were
one screen.
And the bar at the foot is three things: leave, back, on. The question
count and the list behind it are in the rail, and on a narrow screen
behind the site's own menu button — repeating them crowded the one row
that has to stay legible under every question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The section bar is gone while a session is open. Every link on it leaves
the session you are sitting, and the 46px it takes is 46px the question
wanted — which is the whole argument for the player being a fixed-height
box in the first place. It comes back when you leave. The page's gutters
and its 1200px cap go with it: those are for reading, and a player is a
box whose columns scroll inside it.
Skip and Next are two words again. They are not the same decision —
moving on from a question you have not answered is a choice — and the
only reason to merge them was a pair of arrows at the top of the screen
that said Next for both. Those arrows are gone instead: the bar at the
foot is sticky and carries Prev and Next already.
Favourites are out of Collections. They are marked while sitting a
session and sat again from the builder, filtered by system or discipline
like anything else, so a card in Collections was a third place to meet
the same star and the one furthest from where it is used. Mark is called
Favourite everywhere now, including the builder's own filter.
AI Mode is a page: a rail grouped by age in whole local days rather than
elapsed hours — a chat from 23:00 last night reads as yesterday — a
centred ask box, and starting prompts that are all questions a library
can answer. Attach was left out rather than drawn: there is no endpoint
behind it, and a button that does nothing is worse on a page whose whole
argument is that it only says what it can source.
Two real defects turned up in that work. A brand-new conversation's first
question was wiped by the fetch that followed creating it, which was a
flicker before and fatal for a question arriving from the search overlay.
And `updated_at` is serialised naive, which JavaScript reads as local
time and can slide a chat a whole offset into the wrong day; handled in
the page, but the honest fix is timezone-aware timestamps on the API.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Ctrl+K anywhere. Searching and asking are the same act from where the
learner sits — they have a question and want it answered — so which one
happens is a toggle rather than two destinations to choose between before
typing. It opens over whatever is on screen, because a question arrives
while you are reading something and having to leave that page to ask is
how the question gets dropped.
Search keeps its furniture: suggestions, the history of what was asked,
and the keys spelled out. Space fills the box with a suggestion rather
than following it, so a near-miss is edited instead of retyped. AI Mode
drops all of that — a list of half-remembered searches is not help while
writing a question — and gains a clear button and a submit arrow.
Submitting in AI Mode does not show results. It hands the question to the
conversation at /ai?ask=, which is where the follow-up will be asked from
anyway.
The header's search box becomes the door to it rather than a second box
beside it: two fields that do nearly the same thing is how people learn
to trust neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
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
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