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
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
**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
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
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
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
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
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
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.
So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.
The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Choosing what you are studying for has no way past it now but to answer.
It decides which questions exist, how relevance is weighted and what
readiness measures against, so an account that never answered it was
being shown the whole bank by accident rather than by choice.
What is guarded instead is asking a question that cannot be answered: if
the list of objectives fails to load, or there are none, nothing is shown
at all. A modal with no options in it is not a question, it is a locked
door.
GET /api/public/stats, unauthenticated, so the landing page can state what
there is rather than what someone typed into the markup months ago — a
number written into a page goes stale the week after and nothing breaks
to say so. Counts only, and only of published material: how much there
is, never what it is, so there is nothing here to walk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Extraction wrote straight into `questions`, so a machine's first attempt
took a permanent id the moment it was produced. Ids come from a sequence
and are never reissued: every rejected draft burned one, and every draft
that needed fixing was sitting in the bank while it was being fixed.
A run now lands in a batch of drafts with their own table and their own
sequence. They are read, corrected and decided there, and `accept` is
the only place a Question is created — a copy rather than a translation,
because every field a draft holds is a field a question has, so nothing
is lost at the moment of acceptance.
Accepting is all or nothing, and everything is checked before anything
is created: a call that reports failure must not leave questions behind
from the drafts it got through first. My own test caught that — the
first question existed before the second draft was refused.
Readiness is reported for every draft rather than only on the attempt to
accept it, so a reviewer sees what needs work before opening anything.
A decided draft keeps its row and records what it became, so a batch
reads as a history of what was decided rather than emptying as it is
worked through. An acceptance cannot be undone from here: the question
exists, and deciding twice would make a second one.
No embeddings for drafts. A vector is for finding a question in the
bank, and a draft is not in the bank.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Settings linked to a second dashboard with its own tab bar and its own
visual language. The admin sections are rendered in Settings now, under
headings that say who they are for — You, Content, The site — and each
has its own address, so People, AI models, Safety and Search are links.
/admin redirects into Settings for anyone who bookmarked it. AdminPage
takes a `section` prop and drops its tab row when embedded; it is loaded
lazily, so it is not in a learner's download.
Comments are gone: router, model, table and the half of the test file
that covered them. They were a discussion thread nobody was obliged to
answer, and feedback replaced them with a message addressed to whoever
maintains the question. The table was empty, so nothing was lost —
verified before dropping it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Comments are gone. A thread under every question was a discussion nobody
moderated, and what it was used for was telling an educator something was
wrong. That is now feedback: a private report, carrying the question id,
that someone is expected to act on.
* Give feedback sits in the question bar's new "more" menu, beside Save
and Share — occasional actions, folded away rather than each taking a
slot in a bar read on every question.
* An educator gets a badge of what is outstanding. Each row names the
question and opens its editor, where the report sits beside the field
it is about; reply, resolve, reopen or delete from there.
* Resolving keeps the report. A question with a history of the same
complaint should visibly have one; deleting is for the ones that were
never about the question.
* A granted educator sees only their own branch. The badge answers
quietly with zero for someone with no access, so the header can ask
without first working out who is asking.
The question bank is now the Qbank: create a session, and the last three
with Resume. Its facets, tag tree and create-a-quiz were a second copy of
the custom-session page; marking and folders belong in the player while
you are sitting a question. Import and export moved to the question
manager, which is the one place questions are managed, and which now has
a Preview that opens over the list instead of a page you have to come
back from.
Fixed while there: a session in progress analysed as 0/0 with an empty
table, because the analysis read attempt_answers — written on submit —
while the session list counted the saved progress. They read the same
thing now. The category trail is gone from the player: it named the
answer's own topic and led out of a session part-way through. An option's
reasoning opens on click and closes on the next one.
Backend 253/253, frontend 323/323.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Access lived in three screens over two tables: category grants in the
question manager, media-library grants in the image bank, and nothing at
all for articles. Nobody could see what one person actually held.
/access is one surface over the same tables. A person on the left,
everything they have on the right. A granted branch shows its children
as covered rather than as separately tickable — a checkbox that changes
nothing is where a permissions screen starts lying — and the count of
categories a grant actually reaches is stated, not implied.
"Everything" is the moderator role, and the page says so instead of
inventing a wildcard grant that would silently mean the same thing and
be impossible to audit. While it is on, the branches below are hidden,
because they no longer apply. Nobody can change their own access.
The gap this closes: an educator granted a branch could edit its
questions but not the articles filed under it — articles were
moderator-or-author only. An article is filed under a category, so a
grant over that branch now covers its reading too. No new table: the
inheritance that category grants already had does the work.
Backend 242/242, frontend 284/284.
Also: the split-view test now focuses the link rather than hovering it.
Hover starts a 350ms timer; focus reveals at once, because the component
does not make a keyboard reader wait. That takes the wall clock out of a
test about the split view. Earlier failures were it losing CPU to the
backend suite running alongside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The linking was the gap
The marker system was built weeks ago — resolves by id, survives a rename, shows
a preview on hover — and not one of 333 articles used it. Every article was
written in isolation, so a piece on croup named stridor and epiglottitis and
offered no way to reach either. `scripts/link_articles.py` reads what is written
and links it: 3,718 cross-references across 307 articles, by id, so a later
rename cannot break them.
Conservative on purpose, because a wrong link is worse than a missing one: only
the first mention in a section, whole words, longest title first so "Otitis media
with effusion" beats "Otitis media", never inside an existing link, marker,
heading, code span or table, and never an article to itself.
That exposed a second thing: the reading view had its own Markdown pipeline with
its own cross-reference regex, and it only understood the old slug form. It would
have printed every one of those 3,718 links as literal brackets. Article prose
now goes through the same renderer as the rest of the site.
Short and Clinical looked empty
Both are usually a single section, and everything starts collapsed, so the tab
showed one heading over blank space. A view of one section is not a contents
page; it opens.
Removed
Quiz reminders — emailed nudges to retake anything under 75%, with a scheduler
that existed solely to send them: the model, the service, the scheduler, the
email, the table. Article comments. The dashboard's in-progress list and its
stat cards, both of which the analysis page now answers better.
One mistake worth recording: the first pass at removing the reminder cleanup used
a regex that took 109 lines with it, including an unrelated endpoint. The test
suite caught it (`/attempts/quiz/{id}/in-progress` returning 404 instead of 403),
and the file was restored and edited by exact match instead.
208 backend, 249 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Figures
A question could carry exactly one stem image and one explanation image, each a
bare path with no title, no legend, and no way for the prose to refer to it.
`question_media` makes a figure a row: it points at an image already in the bank,
carries a role, a label the text can name ("Figure 1"), a caption and an order,
and there can be as many as the question needs. The same radiograph can serve two
questions without being stored twice.
The 346 existing paths were backfilled into figure records and retitled —
`page_339_img_0.png` says where a file came from and nothing about what it shows,
so the filename moved into the caption where it is still searchable, and the
title became something a person can read.
On the editor question: no new platform needed. Milkdown is already installed —
ProseMirror-based, MIT, GFM tables, code blocks, LaTeX — and already used for
articles, courses and the quick question modal. Only the question *page* still
has plain textareas, and that swap is written down rather than rushed, because
the stem carries manual-highlight offsets and a WYSIWYG rewrite would move them.
Fewer hints during a quiz
The category trail and the difficulty pill were shown beside every stem. Being
told a question is filed under Neonatology, or that it is "hard", narrows the
answer before the stem has been read. Both now wait until the answer is in,
where the trail becomes a way to more of the same topic.
The dashboard is about questions
Quizzes and attempts describe how the material happens to be packaged. What a
learner is working through is questions: how many of the bank they have seen,
how many they have answered correctly, and their average. The old per-quiz
performance card — which needed two attempts before it showed anything — is
gone, superseded by the session analysis. The greeting sits above "continue your
study" rather than below it, where it read as a heading for the wrong section.
208 backend, 249 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The design settled earlier, built as described: retrieval decides what may be
cited, and the server enforces it.
The model is handed a shortlist of at most fourteen sources from the learner's
own library and told to cite them by marker. Afterwards every citation it wrote
is checked against that shortlist and anything else is deleted before it is
stored or shown. A hallucinated citation is not unlikely here, it is impossible
— surviving is not a decision the model gets to make. A URL it invents is not a
citation either: only the marker form counts, so a plausible-looking link stays
in the prose citing nothing.
Retrieval reuses the hybrid search already in place, and each corpus keeps its
own visibility rules — the bank predicate and exam scope for questions, the
draft rule for articles, deck ownership for cards. A question source carries the
stem only: a chat that printed the answer would hand away the practice it exists
to prepare you for.
Curated links do the job they were built for. A retrieved row an educator tied
to another retrieved row is boosted, because two things somebody already linked
surfacing for one query is evidence rather than coincidence. Nothing is stored
for this; the boost lives only in that ordering, and the answer marks those
sources so the reader knows which claim rests on an educator's judgement rather
than on a ranking.
Citations are stored with the answer as filtered, so reopening a thread shows
the links it showed at the time rather than a fresh retrieval that may now rank
differently. In the page the markers become numbers and each number opens its
source; a section citation deep-links into that section.
Two smaller decisions worth naming: a question appears in the thread the moment
you send it and is handed back to the input if the answer fails, because typed
words are not something to lose on a 502; and someone else's thread returns 404
rather than 403, since whether it exists is not your business either.
182 backend, 206 frontend green — 16 of the backend tests are the citation
contract and the retrieval boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Five corpora were each searchable from their own page, which meant knowing which
of five pages held the thing you were looking for before you could look for it.
`GET /search` runs them together.
Visibility is never re-implemented here. Questions go through the same bank
predicate and exam scope as the question bank, articles through the same draft
rule, cards through deck ownership, images through library grants. A search page
with its own idea of who may see what is how private content leaks, so the tests
that matter are the boundary ones: a peer's search reaches neither another
user's unshared question nor their deck, and a draft is invisible to everyone
but the educator who wrote it.
A section hit is reported under its article, not beside it — ten sections of one
article are one result with ten places to start reading, not ten results burying
everything else. This is what the section index was backfilled for; each one
links straight to that section.
Results are grouped by kind rather than interleaved by score. A question and an
article are different kinds of answer, and a single ranked list makes you read
every row to work out which kind each one is. Snippets show the window around
the match rather than the opening of the document, because every document's
opening looks the same. A question found only by the semantic ranker says so.
The header box has two ways out: pick a suggestion and go straight to that
article, or press Enter and search everything. Suggestions are lexical and
prefix-first — a typeahead is finishing the word you are typing, and a semantic
neighbour of half a word is noise — and debounced 180ms so typing is not a
request per keystroke. One corpus failing is logged and returned as a gap in the
answer rather than a failed page.
154 backend, 163 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
Storage
Media now goes through `storage_service`, which has two backends: the container
volume, and S3/MinIO. A volume can only be mounted by one host, has no presigned
URLs and no lifecycle rules, none of which suits ~860 MB of media. Reads fall
back to the volume when an object is missing, so the existing uploads keep
working and files can migrate gradually rather than in one risky pass.
A row stores the object key, never a URL: a URL embeds the backend, so a row
holding `http://minio:9000/...` breaks the moment the backend changes.
MinIO publishes no host ports — the backend reaches it over the compose network,
and 9000/9001 are already taken on this host by other stacks.
Image libraries (migration d2e3f4a5b6c7)
An image belongs to a library, and a person is granted a library the way they are
granted a category, so access can be given to some images without giving away all
of them. Tags reuse the shared `question_tags` vocabulary rather than inventing a
media-only one. Uploads are type- and size-checked, stored through the service,
and embedded so an image can be found by what it shows.
Classification finished
The 316 questions the chooser had declined are now filed with `--force`, which
takes the nearest candidate from the same shortlist the chooser saw. 306 were
forced, 10 the chooser accepted on this pass. No question sits on a bare system
any more:
system only 2,730 -> 0
condition/subsystem 214 -> 1,782
full depth 4 -> 1,166
A forced match is a weaker signal than a chosen one, so expect more errors among
those 306 — but the original system stays as a cross-link, so nothing is lost and
they can be corrected by hand.
Tests: 8 new backend covering library scoping, edit confinement, shared-vocabulary
tags, storage indirection on upload, and type/size limits. 131 backend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgRcMaScVEL7TBLpnAoSV9
The PREP sets were loose admin-generated quizzes. They are now study plans: one
per year, split into blocks of 50 numbered "Block 1", "Block 2", plus a
"PREP Mixed" plan of 300 drawn at random across every year. 12 plans, 2,821
questions, applied to production.
Block membership is snapshotted rather than stored as a filter — a plan you are
part-way through must not reshuffle between visits. Re-running the seeder updates
years whose questions changed and leaves the mixed draw alone unless --reshuffle.
Starting a block reuses the learner's existing quiz for it; without that,
reopening a block would create a duplicate test each time and scatter the
attempts across them. Only questions the learner may see are included.
Tests: 113 backend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
Images were findable only by the filename someone typed. `media_assets` gives
them a title, caption, alt text, a category on the shared tree and tags, with a
weighted tsvector so they are searchable now (migration y7e8f9a0b1c2).
The embedding column is filled from the caption today. A vision-capable model can
fill it from the image itself later without another migration — and because
`embedding_model` stamps every vector, a text-embedded caption and a
vision-embedded image stay distinguishable instead of being silently mixed in one
index. Adding "media" to the embeddable kinds is all the retry task, the full
regeneration and the health report needed.
`media_tag_links.tag_id` carries no ORM-level foreign key: `question_tags` is
created by raw DDL rather than a model, so the constraint lives in the migration
where the table actually exists.
Tests: 113 backend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
Exams (migration v4b5c6d7e8f9)
"Pediatrics Boards" was a hardcoded checkbox that filtered nothing. Exams are now
rows: Pediatrics Boards and USMLE Step 2 CK ship seeded, and everything already
in the bank is linked to the boards. Membership is a link table, not a column,
because one paediatric cardiology question can count towards several exams.
The learner's choice lives on `users.active_exam_id`, so it follows them between
devices instead of sitting in one browser's storage. Choosing an exam scopes the
bank; a question with no exam links stays visible, since unlinked content is
unclassified rather than excluded. A switcher sits in the navbar.
AI mode — matching, never generating
Both entry points build a test from the educator-reviewed questions that already
exist, ranked against the request. Nothing is invented:
- POST /questions/builder/describe turns "what I want to study" into a test.
- POST /questions/builder/from-upload matches a document against the bank. The
file is read in memory and never stored — it is a search query, not a source
of questions, so there is nothing to retain or expire. 10 MB cap, 30 questions.
Handing a whole document to `websearch_to_tsquery` builds one enormous
conjunction that matches nothing, so text over 300 characters is reduced to its
most distinctive terms, OR-joined, before it reaches the lexical ranker.
Continue your study (migration w5c6d7e8f9a0)
A dashboard panel with the sessions in flight and the articles most recently
opened. `article_views` records one row per learner and article, written best
effort so a reading page never fails because a bookkeeping write did.
Tests: 5 new exam tests (active exams and counts, choice persisted and cleared,
unknown/inactive refused, bank scoping including unlinked questions, moderator-only
creation) and 7 for AI-mode matching (no questions created, invisible questions
excluded, no-match reported rather than an empty test, upload limits enforced).
Full suites green: 113 backend, 136 frontend, build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
Analysis / recommendations (AMBOSS parity, verified on next.amboss.com):
- GET /study-tools/recommendations ranks focus areas by the study time most
likely to raise the score. Readiness is the learner's accuracy in a category
shrunk toward their own overall accuracy in proportion to sample size, so two
unlucky answers do not read as a knowledge gap; it unlocks after 40 answers.
Relevance is the share of the bank a category holds. Counts roll up through
the category tree, so a system inherits its children's questions.
It is deliberately not called EPC and does not claim to predict an exam.
- New /analysis page: Performance and Recommendations tabs, readiness summary,
adaptive-session box, and expandable focus rows showing questions seen,
answered correctly, the linked article and a per-topic practice action.
Per-category educator grants:
- category_grants table (migration p8b9c0d1e253) plus utils/category_grants.py
resolving a grant to the category and all of its descendants.
- Question create, edit, delete, bulk and the manager summary now accept a
moderator OR an educator granted the affected categories, and refuse moves
that would push a question out of the holder's scope. Summary counts are
scoped to the grant.
- Moderator endpoints to list, add and revoke grants, plus /my-grants driving
the nav link and the manager's scope banner; grantable-users avoids handing
moderators the admin-only user list.
- GrantsPanel in the question manager: grant, list and revoke with inline
confirmation.
Question page:
- The category trail was a fixed 78px band that wrapped into several rows and
pushed the stem down the page, followed by three more stacked strips. It is
now one scrollable meta line (breadcrumb + difficulty + type) and a single
AMBOSS-style action bar (Mark / Listen / Listen through / Clear) between the
stem and the options. Difficulty is exposed on the runner payload.
Deploy fix: index.html shipped with no cache header, so browsers kept serving
the previous bundle references and a release looked like nothing had changed.
nginx now sends no-cache for HTML and immutable long-cache for hashed assets.
Tests: 16 new backend (recommendation shrinkage, roll-up, locking, grant scope
across create/edit/delete/bulk/summary, moderator gate) and 10 new frontend.
Full suites green: 88 backend, 116 frontend, build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
Create/bank pages use AMBOSS-style facets: Exams, Disciplines, Symptoms, Systems, Articles, Saved. Personal question libraries with add-to-library in study modal. Adaptive session shortcuts from performance (including weakest topics). Quiz restart with fresh attempt. Category counts computed with two grouped queries. Migration n7a8b9c0d142. 63 backend and 97 frontend tests pass.
Quiz share links replace the PIN copy with a public /share/{token} landing page; owners can enable/revoke without showing the full link. Moderated article/question comments with approval flow, bounds and rate limits. Educator AI article drafts/refine and private card generation with Celery job polling. Migrations f2a1c9d4e801 and g4b7e2f5a903. 50 backend and 85 frontend tests pass.
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
Recovery snapshot of the existing worktree before the Orthobullets-inspired revamp. Includes explanation images, classification snapshots, quiz visibility/resume fixes, quiz codes, TTS options and bot formatting. Secret heuristic and Python syntax checks passed; not a release or full behavioral validation.
Suspend now pauses the timer instead of letting it run out:
- 'Suspend & Leave' sends suspended=true with time_left to backend
- On resume, backend re-anchors started_at to now with held time_left
- Closing tab without suspending continues to run the timer (unchanged)
Timer-expired auto-submits are marked with expired=1 and excluded from:
- Attempt history (GET /attempts/history)
- Dashboard stats (quiz count, total attempts, average score)
- Attempt list (GET /attempts)
- DDL: ALTER TABLE quiz_attempts ADD COLUMN expired INTEGER DEFAULT 0
Course-quiz decoupling is preserved — these changes only touch
non-course quizzes (Quiz.course_id IS NULL).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- alembic.ini: remove hardcoded sqlite URL
- alembic/env.py: inject DATABASE_URL from container env
- main.py: serialize Base.metadata.create_all() + setup_pgvector() behind
a Postgres advisory lock (_run_startup_ddl). Previously all 4 uvicorn
workers ran the DDL in parallel and occasionally deadlocked each other
on ALTER TABLE ordering, killing one worker at startup.
- CLAUDE.md: add Database migrations (Alembic) section
DB was stamped at 9bac7bf02e38; no schema changes in this commit.
- Fully decouple course quizzes from main quiz system (hidden from
dashboard stats, history, search, results page)
- Course quiz results show "Back to Course" instead of retake/delete
- Add allow_review toggle for course creators to control answer review
- Show quiz title on course page, hide pool size from students
- Add course thumbnails to browse cards
- Replace passlib with bcrypt directly (compatible with existing hashes)
- Add HIBP breached password warnings on register/reset/change password
- Add CLI management tools (reset-password, set-role, stats, etc.)
- Fix quiz PATCH endpoint: ownership check instead of moderator-only
- Add max_length validation on course/module/lesson titles
- Fix score display bug on results page (0 of N when review disabled)
- Fix question count on course quiz start (show per-attempt, not pool)
- Improve suspend warning for timed course quizzes with max attempts
- Clean up validation error messages (show "Invalid email" not Pydantic dump)
- Add DDL migration for allow_review column
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LMS / Course System:
- Course → Module → Lesson hierarchy (any user can create)
- Lesson types: text, video (Vimeo/YouTube/local), document, quiz, live_session
- Video provider auto-detection from URL
- BBB API integration (create/join meetings) with env config
- Course enrollment with per-lesson progress tracking
- AI content generation/refinement for text lessons
- Draft/published/archived status workflow
- Subscription gate placeholder (requires_subscription flag)
- Thumbnail upload support
- Module/lesson reorder with up/down controls
User Quiz Creation:
- Any user can create quizzes from question bank (was moderator-only)
- User quizzes: is_published=0, is_shared=0 (private by default)
- Fixed section_id fallback: None instead of hardcoded 1
Manual Question Creation:
- POST /questions/create endpoint for manual MCQ entry
- CreateQuestionModal on QuestionBankPage with options, radio for correct answer
- Auto-generates embedding on creation
Frontend:
- CoursesPage: Browse/My Courses/Created tabs with search and pagination
- CourseDetailPage: Student view with module accordion, lesson viewer, progress
- CourseEditorPage: Full course builder with AI generate, question bank browser
- Courses link in Navbar
- Create Question button on Question Bank (available to all users)
Backend:
- 5 new tables: courses, course_modules, course_lessons, course_enrollments, course_lesson_progress
- Course model + schemas + router (22 endpoints)
- BBB_SERVER_URL + BBB_SECRET config
- Updated CLAUDE.md with LMS documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Users can share/unshare their flashcard decks (toggle button)
- Shared tab: paginated list of all shared decks sorted by avg rating
- Star rating system (1-5): click stars to rate, updates average live
- Each user can rate each shared deck once (upsert on re-rate)
- Admin can force-unshare any deck (remove from shared listing)
- Shared decks are study-able by anyone (read-only access)
- Owner name displayed on shared deck cards
- Load more button for shared decks (12 per page)
- New DB table: flashcard_deck_ratings (user_id+deck_id unique)
- New column: flashcard_decks.is_shared (default 0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix: flashcard task used wrong Redis key prefix (job: vs extraction:)
causing progress to stay on "pending" after completion
- Flashcards are now user-scoped: each user sees only their own decks
- Soft-delete decks: DELETE moves to trash, ?permanent=true to destroy
- Trash tab on flashcards page: restore or permanently delete decks
- Delete individual cards with inline confirmation in browse view
- Card edit/delete now allowed for deck owner (not just moderators)
- ExtractionProgress label prop: shows "Generating Flashcards" not
"Extracting Questions" for flashcard jobs
- Added deleted_at column to flashcard_decks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New feature: generate flashcards from PDF sections using AI, completely
separate from the existing quiz system.
Backend:
- FlashcardDeck + Flashcard models with cascade deletes
- flashcard_tag_links table for tag classification (reuses question_tags)
- /api/flashcards/ router: CRUD for decks, browse/search cards, tag filtering
- generate_flashcard_deck Celery task with chunked processing + progress
- FLASHCARD_PROMPT in extraction_modes.py (15 cards per chunk)
- "flashcard" added to admin model task types
Frontend:
- FlashcardsPage: deck grid + card browser with search/filter
- FlashcardStudyPage: flip cards, mark known/review, keyboard nav,
shuffle, progress bar, completion screen
- DocumentDetailPage: "Create Flashcards" button alongside "Extract Quiz"
- Navbar: Flashcards link
- AdminPage: flashcard in model task dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- teach.py: use _proxy_model() + pass api_key/api_base from settings (fixes LiteLLM provider error for openrouter/bedrock models)
- teach.py: accept model_id in ChatRequest so frontend can select model
- main.py: remove titan-embed-v2 from general seed, auto-delete legacy entry on startup
- main.py: kill stale idle-in-transaction DB connections at startup to prevent DDL lock hangs
- main.py: set lock_timeout=10s on DDL connection as fast-fail safety net
- TeachChat.jsx: fetch /teach/models, show selector dropdown in header when >1 model available
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Embedding:
- Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env
- Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation)
- Passes dimensions=1024 to proxy; Redis setting overrides env var
- Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim)
- Test button in admin UI to verify model works
- Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names)
Polly:
- Global enable/disable toggle in Admin → More settings (stored in Redis)
- /tts/voices filters out polly/* when disabled
- /tts/speak rejects polly requests when disabled
Job cancellation:
- POST /quizzes/job/{job_id}/cancel endpoint
- Cancel button on JobsPage for running jobs
- Celery task checks Redis status at each chunk boundary and exits cleanly
- Fixes DB lock on restart caused by cancelled jobs leaving open transactions
Admin UI:
- Settings tab renamed to "More" (heading: More Settings)
- Model row overflow fixed (minWidth: 0 + ellipsis on model_id)
- Embedding model search shows all proxy models (no auto-filter by "embed")
- Navbar correctly excludes cancelled/failed jobs from "extracting" count
README:
- Added Rebuild & Restart section with commands
- Updated embedding model reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause of lost PREP 2016 quiz: deleting a section (e.g. to recreate
with different page ranges) cascade-deleted ALL quizzes attached to it,
destroying the quiz AND all its questions permanently.
Fixes:
- Section.quizzes relationship: removed cascade="all, delete-orphan"
Deleting a section no longer touches quizzes at all
- Quiz.section_id FK: changed to ON DELETE SET NULL, nullable=True
If a section is deleted, quizzes keep working (section_id becomes null)
- DB migration: drops old FK constraint and recreates with SET NULL
Also answers the question about deleting a bank question in a quiz:
- quiz_question_links FK has ON DELETE CASCADE — deleting a question
silently removes it from all quizzes
- This needs a warning UI (not yet implemented — no delete button in bank)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Critical bug fix:
- DELETE /quizzes/{id} was returning 500 due to {\"quiz_id\": None} update using
the DB column name instead of Python attribute name (source_quiz_id).
Fixed with {QuestionModel.source_quiz_id: None, synchronize_session=False}
Extraction fix:
- Two-phase extraction was incorrectly triggering for PREP 2012/2014 format
documents that have 'Preferred Response:' in their explanation text.
Fix: check for inline 'Correct Answer:' first — if found, always use standard
extraction regardless of 'Preferred Response:' appearing elsewhere.
Quiz trash bin:
- DELETE /quizzes/{id} now soft-deletes (sets deleted_at)
- GET /quizzes/trash — list deleted quizzes (moderator)
- PATCH /quizzes/{id}/restore — restore from trash
- DELETE /quizzes/{id}/permanent — permanent delete (must be in trash first)
- TrashPage.jsx — accessible via Settings → Admin → Trash
Hide/publish quizzes:
- is_published column on quizzes (1=visible, 0=hidden)
- PATCH /quizzes/{id}/publish?published=false — hide from regular users
- Moderators see all quizzes; regular users only see published
- 👁/🙈 toggle button per quiz card (moderators only)
Quiz progress resume (cross-browser via Redis):
- POST /attempts/progress — save {answers, current_idx, mode} to Redis (7 days)
- GET /attempts/progress?quiz_id=N — retrieve saved progress
- DELETE /attempts/progress/{quiz_id} — clear on submit
- QuizPage auto-saves to Redis every 1.5s (debounced) while in progress
- ModeSelectScreen loads saved progress from server, shows Resume button
- Works across browsers, devices, and after logout
Delete attempt:
- DELETE /attempts/{id} — user can delete own attempt + clears reminders for that quiz
ConfirmButton component:
- Replaces all window.confirm() / window.prompt() across the app
- Double-click pattern: first click shows [Confirm] [Cancel] inline
- Applied to: QuizzesPage, DocumentDetailPage, QuizEditPage, QuestionBankPage
Category delete (QuestionBankPage):
- window.prompt() replaced with inline modal dialog with select dropdown
- User chooses where to move questions before deletion
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>