Commit graph

350 commits

Author SHA1 Message Date
Daniel
04abd78380 feat: cards remember, render as prose, and are reachable from a question
Three things the card system did not have.

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

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

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

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

Migration m3d4e5f6a7b8.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration k1b2c3d4e5f6.

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

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

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

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

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

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

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

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

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

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

Migration j0a1b2c3d4e5.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:23:06 +02:00
Daniel
4ee808c200 fix: 108 questions promoted off their system, and one idea under two names
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
2026-09-12 18:19:55 +02:00
Daniel
ab059673e5 content: every article has a high-yield view, and three ties that were not ties
**Missing views.** The note said 10 articles without a clinical view and 13
without a short one; the real numbers were 8 and 11, and the eight originals
turned out to carry no `variant` key at all, so everything in them was being
read as the long view. 33 sections written across 11 articles, every sentence
traceable to that article's own body, and the legacy sections stamped with the
variant they were always being treated as. No doses appear anywhere, because
not one of those eight states one. Every article now has a high-yield view and
`mdm_pass check` reads 330 of 331 conforming with no placeholder sections.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:16:59 +02:00
Daniel
0700e68eaf feat: three coach marks on the overview, once per browser
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
2026-09-12 18:10:30 +02:00
Daniel
bd321b002d docs: close the items this session settled, and two that are not tasks
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
2026-09-12 17:59:48 +02:00
Daniel
dd7bd3668e feat: an account may have no password, and may set one later
Single sign-on wrote a random string nobody would ever know. That reads as
"has a password" to everything that asks — so Settings demanded a current
password before it would let those accounts set their first, and the only way
through was to click "forgot password" for a password they never had. The same
trap was waiting for anybody who only ever signs in with a code.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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