Commit graph

224 commits

Author SHA1 Message Date
Daniel
b2a75b9e08 feat: signed in over there, signed in here
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Failing after 37s
Tests / e2e (push) Failing after 38s
Opening one PedsHub app while already signed in at the provider for the
other one should not produce a sign-in page. It asks the provider once,
with prompt=none — "do you already know this person?" — and if the
answer is yes the round trip finishes with no screen and no click.

The refusal is the interesting half. login_required,
interaction_required, consent_required and account_selection_required
are the provider saying nobody is signed in, which is an answer rather
than a failure: the visitor lands on the page they asked for, with no
message and no sign of having been anywhere. Anything else still goes to
/login?error=sso_failed, and a silent attempt that throws is swallowed
too — nobody should be interrupted by a request they did not make.

The whole risk in this is a loop between two sites, so: at most one
attempt per browser session, never after somebody has signed themselves
out, and never inside a native shell where there is no third-party
cookie to carry the provider's session. Signing out sets a marker that
outlives the tab; pressing any sign-in control clears it, because that
is a person saying they have changed their mind.

A deep link survives the trip. The intended path rides in the server
session rather than the URL, and is validated on the way back — a
scheme, a host or a protocol-relative //evil all collapse to "/",
because a sign-in round trip is exactly where an open redirect would
live.

Verified against the live provider: /api/auth/sso/login?prompt=none
answers 302 to Authentik carrying prompt=none, state and nonce, and a
visitor with no session anywhere lands on the landing page with the
attempt marked spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 17:09:46 +02:00
Daniel
7cd2fa5cf4 feat: AI Mode gives the same answer twice, and a typo no longer empties the library
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 26s
Tests / e2e (push) Failing after 28s
Measured first, by the ped-ai session, fifteen runs of five prompts with
the gateway cache bypassed. Retrieval was already deterministic:
identical shortlist and identical scores every time, and the citation
checker stripped none of the 45 markers written — invented citations are
not the problem here. Generation was the whole variance. At temperature
0.3 the same sources and the same prompt gave answers differing by
15-70% of their text; one differential swung between a 35-word uncited
paraphrase and a 180-word cited list.

So temperature 0 and a seed. Temperature 0 alone was not enough — three
runs still differed — and temperature 0 with a fixed seed came back
byte-identical. The seed is derived from the question, normalised for
case and spacing, so two people asking the same thing get the same
answer and a different question is not pinned to the same sample.

An empty reply is asked once more before it becomes a 502. One in
fifteen came back empty from a healthy model in 4.9 seconds — not a
refusal, not an error, just nothing.

A short query that finds almost nothing is retried against the nearest
article title. "kawasaki criteria" finds fourteen sources; "kawasaki
critera" found none — the lexical ranker cannot match a token that is in
no index, and the embedding of a misspelling is not near the embedding
of the word. Trigrams do not care: that typo scores 0.36 against
"Kawasaki disease" with the next article at 0.11, and the gap is what
makes it safe to act on. pg_trgm is created at startup beside vector,
with a migration for the record.

And an answer drawn from the library must cite it. Not a hallucination
guard — nothing was stripped in fifteen runs — but one answer used the
sources and cited none of them, which leaves the learner an assertion
and nowhere to check it.

Also, article drafts are weighted towards mechanism, in the wording the
ped-ai rewriter is using, so the two lanes read alike: why the body does
what it does, with features and management explained through it rather
than listed. Figure lines and cross-references survive a refine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 16:24:48 +02:00
Daniel
3d7c619461 feat: 20 MB on an upload, and delete one where they are listed
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Failing after 29s
Tests / e2e (push) Failing after 36s
A source PDF is chunked, vectorised and then read by a model a chunk at
a time, so a 500 MB upload is not a big file — it is an hour of work
nobody asked for and a bucket that grows for ever. The cap is 20 MB, in
the config default, in backend/.env which was overriding it at
524288000, and in nginx, which was letting 500M through to be refused
by the application afterwards. A backup of .env is beside it.

And a document is deleted where the documents are listed. The endpoint
has always existed and removes the file, the vector collection and the
row — but reaching it meant opening the document first, which is a page
you go to in order to extract from it, not somewhere you visit to tidy
up. Delete, then Delete it or Keep, in the workbench list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 16:12:29 +02:00
Daniel
8836993ca1 feat: half the bedside, half the mechanism
Some checks failed
Tests / backend (push) Failing after 6s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 27s
"Prefer clinical application questions over pure recall when the content
allows" produced sets of vignettes — recognition without understanding.
Both generators now ask for an even split: about half clinical, a child
in front of you and what to do next, and about half mechanism, why the
body behaves as it does. Pathophysiology is what makes the clinical half
stick.

The question generator is also told outright that the patient is a child
and that the ages, doses and norms are the paediatric ones. Its opening
line said "pediatric medical education expert" and left the rest implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 16:05:57 +02:00
Daniel
99570fb433 feat: cards come from articles, and Nextcloud is gone
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 43s
Tests / e2e (push) Has been cancelled
Two removals the user asked for.

No deck from a PDF section. A card should be written from an article —
text a person has read, edited and published — not from whatever
happened to be on pages 40-58 of a source document. POST /flashcards/
and the generate_flashcard_deck task are gone, with the Create Cards
button on the document page. What remains: POST /articles/{id}/ai-cards,
and POST /flashcards/manual for writing a deck by hand.

And no Nextcloud. It was a per-person cloud integration for a corpus one
person loads: a settings panel asking every educator for an app
password, a second tab on the upload page, and three endpoints. The
upload page now has one way in, which is the one anybody used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 16:03:04 +02:00
Daniel
e2919e73c2 feat: no passwords here — sign-in belongs to the provider
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Failing after 31s
Tests / e2e (push) Failing after 42s
The account settings offered "Change password", and there is no password
to change: accounts live at PedsHub SSO, which is also where a passkey,
an address or a second factor is set. A form that writes a credential
nothing checks is worse than no form — it invites somebody to secure
their account by a route that does not secure it. The panel now keeps
the display name and points at the provider for the rest.

Retired with it: GET /auth/verify-email, POST /auth/resend-verification,
POST /auth/forgot-password, POST /auth/reset-password, the new_password
branch of PUT /auth/me, the two schemas behind them, and the three pages
— VerifyEmailPage, ForgotPasswordPage, ResetPasswordPage — with their
routes and the links into them. An account with no password cannot
forget one.

POST /auth/login stays, still refused while sso_only is set. It is the
way back in if the provider is ever unreachable, together with the
DEFAULT_ADMIN_EMAIL seed at startup, and removing it would leave no door
at all on a bad day. The test that walked five password doors now walks
that one and asserts the other four answer 404 rather than 403 — gone,
not guarded.

Verified live: all four endpoints 404, and the account panel shows a
name field and "Manage your account ↗" to
sso.pedshub.com/if/user/#/settings, with no password field anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:50:38 +02:00
Daniel
cdf4ab80b4 fix: the figures route handed out the answer side to anybody signed in
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Has been cancelled
GET /questions/detail/{id}/figures had no check at all, sitting next to
a detail route that has one. Any signed-in account could ask for any
question's figures by id and be handed its explanation images: the
paths, and the library record that now rides on them — whose titles run
to "Neonatal Herpes Simplex · Q874". No attempt required, and the answer
in the title. Found by walking today's surfaces as a real learner
account rather than reading the guards.

Same rule as the route beside it: the stem is readable in the bank, the
answer side belongs to whoever writes the question.

Also, refining an article no longer breaks the links into it. The refine
path replaces the whole section list and the model was never shown the
existing ids, so it invented fresh ones — silently breaking every
`[[95#id]]` pointing at a section, from another article, a question's
key point or a study plan's reading. The id travels in the heading now
and the prompt says to return it unchanged for any section kept. The
model is also told to leave existing `[[123|links]]` exactly as written
and never to invent one, because a guessed number points at nothing.

And the section strip is centred. Widening its box to 1600px let the
links spread but `flex: 1` on the strip — right for every other strip on
the site — filled the whole box with the links against its left edge:
measured at 1500px, they began 70px left of the page content while the
strip ran 180px past its right. Content-sized and centred now, still
scrolling when the links genuinely outrun the window. Diagnosis from the
ped-ai session; verified at 1500 and 1920 with no arrows at either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:29:29 +02:00
Daniel
deb8ee7830 fix: the high-yield view was the longest prose in the article
Some checks failed
Tests / backend (push) Failing after 6s
Tests / frontend (push) Failing after 41s
Tests / e2e (push) Failing after 40s
Measured across the 323 AI drafts in the bank before touching anything.
The prompt describes three readings of a topic and the second of them
was not happening:

  short     326 sections   1 per article   1,808 chars   4 highlighted
  long    2,112 sections   6.5 per article   721 chars
  clinical 1,060 sections                     531 chars

So the revision view — the thing meant to be tight lists of what a
candidate must know — came out as a single section two and a half times
longer than a full one, written as bullets that were paragraphs, and 4
of 326 carried the ==highlight== the reader has a renderer for. "Tight
lists, not prose" and "sparingly" are adjectives, and the model read
them as suggestions.

They are numbers now: 2 to 4 separate sections, each under 600
characters, bullets of at most 20 words, and one to three highlighted
facts per section with an example of what that looks like.

Two drafts written against the live model afterwards:

  Kawasaki disease        3 short sections, 292 chars avg, 3/3 highlighted
  Neonatal hypoglycaemia  3 short sections, 227 chars avg, 3/3 highlighted

and the reader draws nine key-point marks on the first of them.

Also, refining no longer loses a section's variant. The existing draft
went to the model as a flat list of `## Heading`, so it had to guess all
over again which sections were the bedside and which were the revision
view — a refine could quietly move one into the other. The variant
travels in the heading now and the prompt says to keep it.

Left alone deliberately: the 16,000-token ceiling (no truncation
failure in the logs to justify moving it) and temperature 0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:19:37 +02:00
Daniel
963ca04cf8 feat: no account creation here — the provider makes accounts
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 30s
Tests / e2e (push) Failing after 33s
Gone: /auth/register, /auth/signup-policy, POST /admin/users, the
RegisterPage, the register half of the landing modal, the Register
button, the "Sign up" link, the "Create an account" hero button, and the
UserCreate schema. /register redirects to /login for anybody holding a
bookmark. A first admin on a fresh install still comes from
DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out.

And no flash of the old way in. Both sign-in surfaces defaulted to "no
provider" and drew the email form while /auth/sso/config was in flight,
then swapped it — so a reload showed a form that does not exist, briefly,
every time. They render nothing until the answer arrives. The landing
modal is now one button, "Sign in with PedsHub SSO", with no sentence
under it: the button already says where you are going.

Also, the section strip takes the width it has. It sat inside the 1200px
measure that keeps an article readable, so on a wide desktop the last
entries fell off the end and a scroll arrow appeared beside acres of
empty space. Verified at 1280, 1600 and 1920: ten links, no arrows.

And "Make a deck" comes out of the strip and the phone menu — that was
an over-reach on my part. The landing CTA keeps it, pointing at
app.pedshub.com/#resources, which is what was actually asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:37:10 +02:00
Daniel
e858f4c166 fix: a schema promising an owner where the column now says NULL
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 33s
Opening any shared deck answered 500 — ResponseValidationError, "Input
should be a valid integer" for user_id. The ownership migration made
those columns nullable and the response models still declared
`user_id: int`, so the first read of a deck after it was a crash rather
than a page. A learner hit it on Cards.

FlashcardDeckResponse, DocumentResponse and QuizResponse now allow None,
with a test that walks the three and fails if any of them promises an
owner again. The grant-input schemas were left alone on purpose: their
user_id names the person a grant is for, and a grant with nobody in it
is not a thing.

Also gone: send_login_code_email, forty-eight lines of email template
for a feature that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:17:55 +02:00
Daniel
1f2770257f docs: a Help page for the people who use the site, a Handbook for whoever runs it
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 27s
They were one page, and it served neither. Somebody wanting to know what
"Studying for" does had to scroll past what the tutor is prompted with
and what re-embedding breaks.

/help — every signed-in account. Signing in through PedsHub SSO, what
"Studying for" scopes, Qbank against Sessions against Collections, study
and exam mode, where performance comes from, Reading and Cards and study
plans, "Make a deck", and how to report a bad question. An educator also
gets a section of their own: what a moderator has, what a grant gives
and what it does not, that nothing in the bank belongs to anybody, how a
draft becomes an article, how a plan is built, and what a moderator
cannot do. A learner never sees that half.

/handbook — administrators only now, with the same FAQ plus the rest.
The Settings card that pointed at it is admin-only to match, and
RequireAuth learned an admin door, which it did not have.

Help is in the account menu rather than the footer: a question you have
while working is answered from where you are.

And the repo docs describe the site that exists. CLAUDE.md lost the LMS
section — courses, modules, lessons, enrolments, all removed months ago
and still documented — and gained the permission model and the sign-in
flow. ADMIN.md's role table said moderators create courses; it now says
what the three roles actually reach, where roles come from, and that the
bank has no owners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:12:47 +02:00
Daniel
12453ba473 fix: a role is not set here while the provider holds it
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 34s
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.

Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.

The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:04:16 +02:00
Daniel
cf2f42975e feat: no invite codes and no email sign-in codes — that is the provider's job
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 33s
Both were this app doing an identity provider's work. Sign-in and
sign-up happen at sso.pedshub.com now: it takes the address, sends the
code, checks it, and knows about second factors — none of which belongs
here, and two of which were never done here at all.

Gone: services/invites.py, services/login_codes.py,
routers/login_code.py, the two models, the three admin invite routes,
the invite_only flag and its switch, the invite field on both sign-up
forms, and the code half of the sign-in page — which was the primary way
in and is now a button that says "Sign in with PedsHub SSO". The
password form stays for a site with no provider configured.

Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and
login_codes (empty). The dump beside it has both.

585 tests, and the contract snapshot is 320 routes — five fewer, all
five named in the diff so the removal is reviewable rather than
discovered later by a client.

Also: "Make a deck" in the signed-in menu and on the landing page, going
to the scribe's My Resources at app.pedshub.com/#resources. Same
sign-in on both sides; the arrow says it leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:56:48 +02:00
Daniel
b6cfcaa1e9 feat: the bank belongs to a role, not to a person
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00
Daniel
282b6a25f7 fix: no password sign-up offered on a site that signs in through a provider
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 27s
Tests / e2e (push) Failing after 32s
/register drew the whole form under SSO-only — name, email, password
twice, invite code, Sign Up — and only the POST refused it. That is
asking somebody for four fields and a password they will never use
before telling them the door does not exist.

/auth/signup-policy is the question the form already asks, so it answers
it: sso_only, the provider's name, and registration_open false whenever
single sign-on is the only way in. The page says one line instead. The
Register tab on the landing modal and the Register button in the header
go with it — and the modal falls back to Sign in if it was opened
straight into Register.

The sign-in page needed nothing: it already hid its "Sign up" link on
registration_open false, which is now also true under SSO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 06:02:01 +02:00
Daniel
c9e0655d6e fix: shut every password door under SSO-only, and keep the token out of the URL
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 29s
Tests / e2e (push) Failing after 27s
Two findings from a security pass on the SSO path, both real.

sso_only gated login and the login codes and nothing else. Register,
forgot-password, reset-password, resend-verification and setting a
password through PUT /auth/me all went through — so a site running
single sign-on could still mint a password account nobody vetted, and if
the flag were ever turned off, there it would be. One helper, five doors,
403 with a reason at each.

And the access token travelled in a query string. The SSO redirect is a
page load, so the browser asked nginx for /sso-callback?token=<a live
bearer token, good for a day> and nginx logs the request line — every
sign-in wrote one into the frontend container's access log, the
browser's history, and the Referer of whatever loaded next. It carries a
one-time code now: a random 32 bytes parked in Redis for sixty seconds,
traded at POST /auth/sso/exchange for the token, and deleted as it is
read, so a code replayed from any of those places buys nothing.

Also the OIDC state cookie, which is what stops an authorization
response being replayed at you: same_site lax (strict drops it on the
provider's top-level GET and fails every sign-in) and secure whenever
APP_URL is https.

And one cross, not two. The header's menu button is already a cross
while a drawer is open, so the drawer's own close button was a second
control an inch below it for the same job — gone from the article, the
player and the review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:55:37 +02:00
Daniel
6137bf65b8 fix: the SSO login button 500s — the redirect was never awaited
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Failing after 29s
Authlib's Starlette client is the async one, so `authorize_redirect`
hands back a coroutine. `sso_login` was a sync `def` that returned it,
and FastAPI tried to serialise a coroutine as a response body:
ValueError: 'coroutine' object is not iterable. Every click of the SSO
button was a 500.

It has presumably always been broken. Nothing caught it because nothing
had a provider configured to click the button with — /auth/sso/config
answered sso_enabled:false, so the button was never drawn. It surfaced
within a minute of an Authentik being pointed at the app.

Verified against the live provider: /api/auth/sso/login now answers 302
to sso.pedshub.com/application/o/authorize/ with response_type, scope,
state and nonce.

A shape test guards it — the endpoint is a coroutine function and the
redirect is awaited — because the alternative is standing up an OIDC
provider inside a unit test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:30:34 +02:00
Daniel
7f36e07af0 feat: SSO reads roles from the provider's groups, and needs a verified address
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 33s
Tests / e2e (push) Failing after 34s
Everybody arriving through SSO landed as a learner and was promoted by
hand. That is fine for three people and wrong for three hundred: the
list of who may edit the bank then lives in two places and only one of
them is maintained. OIDC_ROLE_CLAIM names the claim that carries somebody's
groups ("groups" for most providers, "roles" for Entra app roles), and
OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS say which values mean what.
All three shapes a provider might send are read — a list, a
space-separated string, a comma-separated one — and matched case-blind.

Applied on every sign-in, not only at creation, because a group somebody
can be added to and never removed from is not a list anybody can rely
on. The one demotion it refuses is the last administrator: a mistyped
group name should not lock everybody out of the settings page,
including the person who could fix the group name. It is logged when it
happens.

Blank OIDC_ROLE_CLAIM is the old behaviour exactly: role "user", set
once, never touched.

And an address the provider will not vouch for is not an identity. This
matches on email, so an explicit email_verified:false would hand an
existing account to whoever typed its address at a careless provider.
Refused now — only on an explicit false, since a provider that omits the
claim is not asserting anything either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:20:52 +02:00
Daniel
cc4463d42c fix: /questions/detail hands out the answer to anybody signed in
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 35s
Tests / e2e (push) Failing after 31s
Found by walking the permission model as a real learner account rather
than reading the guards. The bank listing has always nulled the answer
side for whoever does not write the question; this route, added for the
full-page editor, returned the row whole behind nothing but
get_current_user. Any signed-in account could ask for
/questions/detail/3869 and be handed the correct option, the
explanation, the per-option reasoning and the key points for a question
it had never sat — 2,924 questions, one URL, no attempt required.

Same rule as the listing now, via the same may_edit_question: the stem
still reads, the answer side comes back null, and explanation figures —
answer-side by definition — are filtered out with it. Nulled rather than
refused, because the stem is legitimately readable.

Three tests: a reader gets the stem and none of the answer, whoever
writes it gets all of it, and an author counts as writing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:12:01 +02:00
Daniel
094bab20cd feat: an opened explanation figure shows what the library knows
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 38s
Tests / e2e (push) Failing after 43s
Two voices, and they belong in different places. The thumbnail in an
explanation carries what the *question* says about the figure — words an
educator chose for this question. Opening it now adds the catalogue
entry underneath: what the image was filed as, the description written
when it was filed, where it came from, and any marks an educator drew on
it, behind a switch.

It rides on explanation rows only. Those are already withheld by the
server until answers are revealed, so the record reaches somebody who
has finished the question and gets nowhere near somebody who has not —
which is the whole reason the caption came off the figure in the first
place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:05:59 +02:00
Daniel
954b13e7b3 fix: a speech model is added with its voices, and Orpheus is sent where it works
An Orpheus id (groq-orpheus-english) did not start with "local-", so
generate_tts_audio sent it down the OpenAI path and every call failed. It
also could be added with no voice, and the "local-%" filters in /tts/voices
and the default lookup hid any non-local voice from learners even when it
was added and marked default.

services/tts_voices.py is the one table of which voices belong to which
model (Kokoro, Orpheus English/Arabic, Fish), the same table the scribe app
keeps. Anything the table knows, or anything local-*, goes through the
LiteLLM gateway with the options its family needs (Orpheus: wav). Adding a
bare model id creates one row per voice with friendly names, so an
administrator adds "groq-orpheus-english" and six voices appear to test one
by one; a voice from another family is refused, naming the ones that work.
Learners are offered every active voice, each saying which model serves it,
and /tts/speak answers with the media type the model actually returned.
Kitten and Supertonic tables go — those models left the gateway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 05:04:37 +02:00
Daniel
734ff194f2 fix: a question's figure says what the question says, not what the library says
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 31s
Tests / e2e (push) Failing after 32s
The caption under a figure in a session was the image library's own
description, and a library description is written to catalogue an image:
"an X-ray of a child's pelvis and hips, showing abnormalities in the
right hip joint" is a fine catalogue entry and a complete giveaway under
a stem about a limping five-year-old. One line in figure_json fell back
to it, and every one of the 343 figures in the bank was inheriting one —
338 of them on stems, read before the question was answered. Not one
figure had a caption of its own.

So a figure carries what the question says about it and nothing else.
The caption and the label are set on the question, when the image is
attached; most question figures want neither, and now show neither. The
catalogue title went with it — nothing rendered it, and it gives the
same thing away over the wire.

The second route in was the viewer: an image written into a stem as
markdown opens a panel that fetches what the library knows and prints
it. Inside an attempt it no longer asks. In the library, in an article,
in review of the image itself, the description is still the
description.

Also: no PMID links. A reference list says what was read; it is not a
set of doors out of the article. The number is there to look up, as
text.

And a drawer's own button is a cross. It kept the ☰ that opened it while
covering the screen, which reads as a second menu rather than the way
out of the one in front of you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:26:59 +02:00
Daniel
dbfdff90a1 fix: Reading is published, Editorial has its own address, and repeat works
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Failing after 25s
Tests / e2e (push) Failing after 30s
Reading shows nobody drafts, not even an admin. An educator's unfinished
work sat among the published shelf with a tag on it, which made Reading
and Editorial two views of one list and left you unsure which you were
looking at. The list is published-only for everybody now, and the tag
and its style are gone with it — the badge stays on an article's own
page, where a draft can still be opened.

And Editorial has its own URL. /editorial/articles/:id renders the same
page, but the crumb reads "Editorial" and goes back to the queue.
Opening an article from the queue used to land on Reading's address, so
the only way out was the top of the published library — you lost your
place in the queue to look at one draft. Drafting from the reading page
lands there too, because a new draft is editorial work from the moment
it exists.

References from PubMed are fields, not a sentence. Every other
reference on an article is {title, author, pages} and the reader reads
those keys, so the flat line the PubMed path wrote drew as six blank
rows under a References heading: the DKA draft cited six real papers
and appeared to cite none. A paper now fills journal, year and PMID
instead of pages, and the PMID is a link to the record. Rows written
before this pull themselves apart on the way out rather than being
rewritten in the database, so the drafts that already exist heal
themselves.

Repeat session has never worked. The dialog asked the bank for mode
"study" — the name of the route it lands on — and the bank has "timed"
and "learning", so every repeat came back 422 and the dialog reported
its own house message, "Could not build that session", because the
detail was a list rather than a string. Both fixed: the right mode, and
a server that says something is quoted rather than swallowed.

And the objective named in "your performance analysis for Pediatrics
Boards" opens the objective picker. It was a link to the account page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 04:16:46 +02:00
Daniel
2bc1b9fbd8 fix: a deck keeps the article's filing, and the card fills the window
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 29s
Four things a deck got wrong.

The category. Every deck written from an article is filed where the
article is filed — and the list drew all of them "Uncategorized",
because the router defines its own FlashcardDeckResponse that shadows
the one in schemas/, and that one has no category_id. So the field was
set on the row, returned by nothing, and an educator refiled by hand
what the system had already filed correctly. The shared schema was
imported by no module at all, so it is gone rather than left as a
second definition to read past next time.

The size. Fifteen cards is the per-chunk default, and an article is one
chunk however long it is — a ten-section piece and a two-paragraph stub
both asked for fifteen. Now roughly a card per 150 words, floored at 12
so a short article still makes a deck and capped at 30 so one call stays
inside the model's output.

The card. Set at list-item size inside a frame that fills the window, so
a two-line question sat in the middle of an acre of white. The face
scales with the window and stops at a comfortable measure; the back is
set smaller than the front, as prose rather than a headline.

And the contract snapshot, which still owed the jobs endpoint from the
last commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:58:59 +02:00
Daniel
4aa1352da7 feat: jobs move to the workbench, with their logs and a way to clear them
Some checks failed
Tests / backend (push) Failing after 11s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 31s
**The badge is off the navbar.** It sat in the header of every page, for
everybody, polling every thirty seconds — for a number that means something to
the handful of people who start an extraction and nothing at all to a learner
sitting a session. Extraction is workbench business and it lives there now:
Settings → Tools → Jobs, and a link from the workbench itself, which is where
one is started.

To answer the question it raised: a job ages off the list after a day, and the
steps behind it after an hour. Which is to say it disappears when Redis forgets
it, on its own, with nothing to tell you it had.

**So there is now a way to clear one.** "Forget" takes a job off your list, with
a confirm beside it. It stops nothing that is running — the button is not
offered for a running job — and deletes nothing the job produced; it clears a
line somebody has read and dealt with so the ones they have not are not buried
under it. Only from your own list: the id alone is not authority over anybody
else's, and the keys behind it are shared.

**And a way to read one.** The details panel is called "Log" now, because that
is what it is — every step the job took, in order, and the reason it stopped if
it stopped. That reason has been recorded all along and shown nowhere.

Also, on the deck: the way out is a back link above the title like every other
page rather than a small grey button in the bar of card controls, and the four
buttons that are the whole interaction are full size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 03:41:22 +02:00
Daniel
6305d82e95 fix: card generation, the reader's own styles, and Tools that owned other people's work
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 25s
Tests / e2e (push) Failing after 28s
**Card generation was failing every time.** The model configured for the
flashcard task — and for extraction and keyword — was `ds-deepseek-v4-flash`,
which the AI proxy no longer serves. All three now point at the v4.1 model that
does exist, and the dead rows are gone.

The reason was reachable only by reading the job record: the panel said "Card
generation failed." and nothing else, while the proxy's actual answer sat in a
field nothing displayed. A failed job now says which model is missing and where
to change it.

**The reader had no styles of its own.** Its appearance lived in
ArticlesPage.css, imported by the reading page — so an article rendered
correctly there and as unstyled boxes anywhere else it was used. The split pane
inside AI Mode is where that finally showed: section headings drew as bare
bordered rectangles. The reader imports what it needs now.

**Settings → Tools listed the question workbench and the taxonomy**, which are
question work and belong beside the questions. Both are on the question
manager's own bar now, the taxonomy's way back leads to Questions rather than
to a settings page nobody was on, and the workbench's does too.

Also: air under AI Mode's composer, which sat flush against the footer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:54:44 +02:00
Daniel
febb14490c feat: ground AI drafts in the library and PubMed, and mend the card system
Some checks failed
Tests / backend (push) Failing after 15s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 26s
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

It found four things in its first hour:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AI Mode, from the reports alongside it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration m3d4e5f6a7b8.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration k1b2c3d4e5f6.

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

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

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

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

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

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

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

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

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

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

Migration j0a1b2c3d4e5.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

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