Commit graph

254 commits

Author SHA1 Message Date
Daniel
cc1c981b9e feat: time's up is said, not just done; and it is acknowledged before the screen changes
The clock reaching zero submitted the attempt and replaced the exam with
an analysis, with no word about why. "It submitted itself" is the one
thing a learner must not have to infer. The answers go in immediately —
that part must not wait for anybody — and the screen holds on "Time's
up" until it is acknowledged, then goes.

The last question's control says "End block" in exam mode. It opens the
same dialog it always did, which names how many are still unanswered and
that they count as incorrect, so the warning arrives before anything is
handed in rather than after.

Four tests for the time accounting the previous commit changed, covering
what it is actually for: an exam closed with eight minutes left still
has eight minutes however long ago it was started; opening and closing
spends only the time it is open for and does eventually reach zero; an
exam closed at zero is settled on the next look, for when the tab goes
before the submit lands; and one with time on it is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:34:44 +02:00
Daniel
6860750770 fix: the exam clock runs only while somebody is sitting the exam
It ran on a wall clock. An hour away from the tab spent an hour of the
exam on questions that were never shown, and every per-question figure
was a fiction — which is the number the whole analysis is built on.

Three things stop it now. The tab being hidden, which catches switching
away. An explicit pause. And, for the commonest case the other two miss
— the tab left open on the exam while the person is in another room —
an idle watch: three minutes with no mousemove, key, wheel, touch or
scroll and it asks "Still there?", with the clock already stopped by the
time the question appears. A stray pointer movement does not answer it;
somebody has to say they are there.

Three minutes, not one, and scrolling counts as activity: reading a long
vignette is minutes without a click, and interrupting genuine reading to
ask whether you are reading is worse than occasionally crediting a
minute nobody was there for.

The server was the other half. seconds_remaining computed from
started_at and total_time, so a paused client made no difference to what
the server thought was left. It reads the saved time_left now, which is
what the player decrements only while the exam is on screen, falling
back to the wall clock for progress saved before this existed.

And a five-minute warning, said once. An exam that ends without notice
is a scramble; one that nags is a distraction.

Reverts the exam-exit-submits rule from earlier in this branch, which
was built on the opposite premise and would have charged wall-clock time
and then graded an exam whose clock should simply have stopped. Leaving
suspends, in both modes, and the overview no longer promises a clock
that does not stop for a break.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:30:18 +02:00
Daniel
c353373231 fix: an exam that is still running is not marked
Opening the analysis of a live attempt graded it whatever the mode. In
an exam that is a way to answer, look at whether it was right, and go
back and change it — the exam defeated rather than analysed. It reports
progress now: how many are answered, how long it is taking, and each row
as answered or not. No score, no percentage, and the donut counts how
far through it is instead of how much of it is right.

Study mode still grades live, because study mode marks each answer as it
is given; there is nothing here it has not already said.

Recommendations are withheld too, which is stricter than AMBOSS — they
show a dash for correct and then list the topics to go back to, which
says which questions were wrong by another route. A recommendation is a
verdict.

The withholding stops the moment the exam is over, submitted or expired:
settle_if_expired grades through the same function a manual submit does
and sets completed_at, and everything opens from there.

Tested on both sides, because this is an integrity rule and would come
back quietly the next time the live-analysis path was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 23:17:43 +02:00
Daniel
c9abc4f310 feat: extracted questions are staged, and only acceptance takes an id
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
2026-09-11 22:57:08 +02:00
Daniel
0fa5598aad feat: study plans built to the board's content outline
Two shapes, because a plan is asked to do two different things. Papers
are rehearsal: each block is drawn to the ABP's published weights, so
sitting one says something about how you would do on the day. Domains
are study: the board's twenty-four content areas in its own order and
carrying its own titles, each given the share of the plan the board
gives it on the exam.

Both were written, then run against the real bank, which found two bugs
a unit test on a clean fixture would not have. Domains 19 and 20 —
nephrology and genitourinary — both map to our "Nephrology & Urology",
so a question sat in two pools and was dealt twice; the deal now keeps a
record of what has gone. And chunking every question a domain has into
blocks of forty gave preventive care six blocks and the plan a hundred
and sixty, which is not a plan: blocks are shared out by weight, with at
least one per domain so nothing the board examines is left out.

Built on the live bank alongside what was already there: Boards: Full
Papers (12 × 40) and Boards: By Content Domain (27 blocks, 1069
questions). Nothing existing was touched.

Psychosocial Issues and Child Abuse and Neglect — 6% of the paper
between them — had no category of ours at all, so they could contribute
nothing. Both now exist, with sub-topics named from the board's own
subdomains, and all 24 domains map to categories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:40:17 +02:00
Daniel
76848c5947 fix: the reading browser counted the wrong thing, and the hover card fled
The number beside a folder in Topic reading was a question count while
the browser lists articles, so "Hyperinflammatory Sepsis 4" meant four
questions and opened onto no reading at all. It counts what it opens
now, rolled up over the subtree, and a branch with nothing to read in it
is not offered — a folder with a number on it is a promise.

The hover card could not be reached. Its body was pointer-events: none,
on the idea that a hint should not sit between the reader and the link —
but the card is offset below the link and never covered it, while the
pointer travelling down to Split view crossed a body it could not enter,
so no mouseenter fired and the hide timer closed it on the way. The card
takes the pointer now, with a bridge across the gap.

And clicking the words opens the card rather than the article. A
cross-reference is read mid-sentence, and navigating away to find out
whether it was worth following is the thing that breaks the thread; the
card's two controls — beside what you are reading, or a tab for later —
are how you go. That also gives touch a route, where hover has none.
Modified and middle clicks are still the browser's.

The listing sent content and sections for all 331 articles, 214KB of
prose a list never renders. It sends what a list needs, which is 21KB.

The footer sat wherever the content stopped, so a page still loading put
it halfway up the screen with background below it. The shell is a column
the height of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:24:04 +02:00
Daniel
fc4df94a6a feat: the tutor is an administrator's to allow, and a handbook explains the rest
The tutor is handed the correct answer and the explanation and told it
may reveal them, which is why it has never been offered during a running
exam — require_question_access already refuses that, whatever anyone
sets. What was missing is the other half: an administrator can now
withhold it from study sessions too.

Enforced on the server rather than by hiding a button, because hiding a
button does not stop a request. Reviewing a finished attempt is not
"during" and is unaffected; the answers are shown by then anyway. If
Redis is unreachable the tutor stays on — nothing is revealed that study
mode does not already show, so the permissive direction is the safe one
here.

GET /teach/prompt renders the instructions against a stand-in question,
so an educator answering "why did the tutor say that?" can read them
rather than infer them.

And a handbook at /handbook, for anyone who maintains questions or
articles whatever access they hold. It answers the things that were only
in the code: that a question links to an article three different ways —
a further-reading row, a key point carrying an article and section, and
a [[id|label]] marker in prose keyed by id so renaming does not break it
— what the tutor is told, why a blueprint shapes a paper, why deleting a
question hides it, and why changing the embedding model invalidates
every vector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:13:38 +02:00
Daniel
5c4823d9a4 feat: exams are a group you can build, with the board's own blueprint
Four gaps, one change.

Articles could not belong to an exam at all — an article reached one only
by inference through its category, which cannot say that the same article
belongs to a basic-science step and a clinical one showing different
views in each. article_exam_links says whether it is in the group;
Exam.article_views already decided what is shown once you are there.

POST /exams/ wrote name, slug, sort order and active, and silently
dropped family, description and article views, so a new objective landed
in "Other" showing everything whatever was asked for. It writes what it
is given now, and PATCH can change it afterwards.

Membership was one link row at a time, which nobody would do for three
thousand questions. POST /exams/{id}/assign takes whole topics with
everything beneath them — questions and articles both — and is
idempotent, so widening a selection and running it again adds only what
is new.

And the point of all of it: a real paper is not a uniform draw. The ABP
publishes that 12% of a general paediatrics exam is preventive care and
2% is rheumatology; forty questions drawn evenly is forty coin flips.
exam_blueprints holds a board's published outline — its own numbering,
its headings, its weights — and blueprint_category_links maps it onto
our taxonomy rather than bending the tree to fit, because their outline
is arranged for examining and ours for studying.

The sampler uses largest-remainder, so twenty-four percentages still come
to forty questions, and a domain that cannot supply its share gives the
shortfall back to be spread over those that can — the paper keeps its
length and loses only accuracy, and the working is returned so the
shortfall is visible rather than silent.

Seeded from the ABP General Pediatrics Content Outline (Oct 2024):
structure and published weights only, no exam material. 120 lines, 22 of
24 domains mapped; Psychosocial Issues and Child Abuse and Neglect have
no category of ours and are reported rather than hidden.

Creating an objective is now an administrator's rather than a
moderator's: it appears in everyone's picker and scopes the whole bank,
which is site configuration, and it sits with the other site switches a
moderator cannot reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:27:48 +02:00
Daniel
d1de9589ad feat: questions are soft-deleted, and the trash holds them
Question ids come from a sequence and are never reissued, and fourteen
tables point at them — attempts, quiz membership, exam membership,
media, article links, notes, favourites, feedback. Deleting the row took
all of that with it, so "restore" could only ever have meant typing the
text in again as a different question.

DELETE now sets deleted_at. The question leaves the bank, the builder,
search and every share path at once, because the exclusion lives in
general_question_predicate rather than at each call site. Restoring puts
back the same id, so everything that pointed at it still does. Erasing
for real requires the trash first and a moderator, and the confirmation
says what goes with it.

The trash page holds questions instead of tests. A test is a selection
you can remake in a minute; nobody wanted those back.

Used and withdrawn invite codes can be removed — an unused one is still
withdrawn rather than deleted, so it stays visible as having been issued
and stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 20:12:58 +02:00
Daniel
2c5d3c67b4 feat: one settings page, and comments removed
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
2026-09-11 19:40:24 +02:00
Daniel
5f61422daf feat: a share dialog that shows what is being shared
A copy button tells you nothing about what you are about to send. The
dialog names the session, counts its questions and shows the stem it
opens on, then offers the link with Copy and the places people actually
send one — email, WhatsApp, Telegram.

Sharing is the administrator's to allow. GET /quizzes/share-policy is
asked before the dialog offers to make a link, so a switch that has been
thrown reads as "not offered" rather than as a button that fails when
pressed. A link already issued keeps working either way.

Removes the second, lesser share block that sat inside the save panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:24:45 +02:00
Daniel
2267f53b55 feat: the player is a box; sharing and sign-up are the administrator's
The quiz player is a box the height of the window. The question used to
scroll the whole page, which took the session rail and the navigation off
screen exactly when you wanted them; now each column scrolls on its own
and the bar — Exit session, Previous, Next, Review — stays put.

Two site-wide switches, together under Settings → Site policy because
both are the administrator's and both apply to everyone:

  * Sharing can be turned off. That stops new links being made; one
    already handed to somebody keeps working, since revoking it would
    break something a learner has already given away.
  * Sign-up can be made invite-only, with single-use codes carrying a
    note of who each is for and, afterwards, who it let in. A spent code
    is kept rather than deleted — that record is the point of invite-only.
    The alphabet has no O/0 or I/1/l, because these get read aloud.

The registration form asks for a code only when the site needs one, via
an unauthenticated policy endpoint — it has to know before there is an
account to ask with. It never says whether a given code is valid before
the account exists, which would make it somewhere to guess them. The
first account is always allowed, or a new install would lock itself out
before an administrator existed to issue a code.

Flags fall back to their defaults when Redis is down, in the safe
direction each way: sharing keeps working, sign-up does not silently
open.

Found on the way: the registration form's three labels named nothing —
no `for`, no wrapping — so a screen reader announced unlabelled boxes.

Backend 261/261, frontend 328/328.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 19:17:35 +02:00
Daniel
392a2cc483 feat: feedback replaces comments; Qbank is a landing page; one question page
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
2026-09-11 18:50:57 +02:00
Daniel
14a75303a7 fix: every cross-reference in the corpus was a dead link
`[[403|urethritis]]` became `<ArticleLink slug="403">`, which built the
href `/articles/s/403` — the slug route — and asked the preview endpoint
to resolve "403" as a slug. Neither exists, so the hover card never
appeared and the link 404'd. Every one of the 2,150 links is written by
id, because an id survives a rename and a slug does not, so this was the
whole library and not one article.

resolve_slug now takes an id as well as a current or historical slug,
and the link addresses the article directly when it is written by id.

Also: a view of one section no longer prints a heading repeating the tab
above it. "Short" over a heading reading "In short" says the same word
twice, and hid the only content behind a chevron. No collapse control
over a single section, and no contents list of one entry.

And a horizontal-overflow guard that only half worked: `overflow-x:
hidden` was on body but not html, so the browser could still propagate
the overflow to the viewport and scroll the whole page sideways — which
is how the navbar came to be clipped mid-word. The exam name now
truncates with an ellipsis instead of clipping.

Backend 242/242, frontend 316/316.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:25:31 +02:00
Daniel
942027c502 fix: 'No therapy is required' is an answer, not a placeholder
The first version of the check flagged it alongside 'No protocols are
available'. The difference is whether the sentence is about the medicine
or about the source. Two false positives down to none; the two real ones
are both Pediatric Dentistry, whose source was a disclaimer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:58:02 +02:00
Daniel
d78a8b7ee8 chore: mdm_pass check reports sections that say nothing
A section that promises an answer and then says 'No presentation details
are available' is worse than a missing one. Reported rather than
refused: a one-line prognosis is often the whole honest answer, so the
length alone cannot decide it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:57:39 +02:00
Daniel
9b7ab72038 fix: a refusal that said nothing had changed
Comparing marker and number *sets* reported 'lost -, added -' whenever a
token merely appeared a different number of times — printed on the line
explaining why the article was refused, which read as a contradiction.
It counts repeats now: 'cross-references changed (duplicated 396x1)',
which is what actually happened to Cystic Fibrosis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:47:39 +02:00
Daniel
a9969d8d90 fix: my own gate was forcing sections to open on "4."
The clinical view of an article was often one numbered list running from
the presenting complaint to discharge. Splitting it into Clinical paths
/ Diagnosis / Management necessarily divides that list — and because the
validator required every numeric token to survive, the restructure kept
the original numbering. Sections opened on "2." and "4.".

The "3." of a list item is a position, not a fact. It is stripped before
numbers are compared, so a restructure is free to renumber; and import
renumbers every section's lists from 1 regardless, per indentation
level, so a nested list counts independently of its parent. A blank line
between items is a loose list, not a new one, and does not reset it.

25 sections across 16 of the 54 already applied were repaired in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:33:27 +02:00
Daniel
efa0af2244 fix: the imperative detector only ever saw sentence starts
'When delay is suspected, obtain a detailed history' is as much an
instruction to the reader as 'Obtain a detailed history', and the first
pass could not see it — so the 819 I reported cleared was the count of
one kind. 71 more were buried mid-sentence, after a comma or a
conjunction. The detector now finds both, and export can be limited to
particular variants so a pass does not collide with one already running
over another view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:16:05 +02:00
Daniel
db2bbf0638 feat: mdm_pass — give the clinical view the shape of a decision
The Short and Long views have a structure; the Clinical view has none.
413 sections across 323 articles carry 242 different titles, most of
them one free-form block called 'Management' holding everything from the
presenting complaint to discharge advice.

The shape is medical decision making: Clinical paths, Diagnosis,
Management, and Prognosis and outcome where it adds something.

This pass moves text between sections, so nothing can be checked section
by section the way the prose pass was. Every check is over the whole
article's clinical view at once — the same cross-references, the same
numbers, the same overall length — plus the shape itself: known titles,
in order, none missing, none twice. Content may be reordered and resplit
freely; it may not appear or vanish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 13:11:30 +02:00
Daniel
765a477d4b feat: figures a question can actually have — managed, labelled, previewed
question_media replaced the two filename columns months ago: any number
of figures per question, each with a role, a label the prose can refer
to, a caption and an order. Only the editor's own endpoint ever read
them. The editor showed the two legacy text fields, and the player and
the answer review rendered the legacy paths — so the model existed and
nothing used it.

- FigureManager in the question editor: add from the image bank, name,
  caption, reorder, remove, per role. A figure with no caption is called
  out, because a caption is how anyone finds it again. The image id is
  shown, since that is what the link survives a rename by.
- FigureStrip on the player and the review. Explanation figures are
  labelled thumbnails that open full size and page between them — a
  stack of full-width radiographs between two paragraphs pushes the
  explanation off the screen, and "as in Figure 2" needs Figure 2 to be
  named where it sits. A stem figure stays full size: it is the question.
- question_figures.py is the single place rows become what a page
  renders, so the three views cannot disagree.
- Explanation figures are withheld until answers are revealed, the same
  rule the explanation itself follows.

The legacy paths still render where a question was never backfilled, so
nothing that worked before stops working.

Backend 242/242, frontend 290/290.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:59:43 +02:00
Daniel
994733581e feat: access as one tree — branches, libraries, and an honest "everything"
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
2026-09-11 12:53:56 +02:00
Daniel
9d407ca1d9 feat: settings as places with addresses; repair nested cross-references
Settings was one 600px column holding the account form, a theme picker,
a Nextcloud integration, a document list and an admin grid, in that
order, with no way to link to any of it. It is now a section list beside
one panel, with the section in the URL — so "change your password" is a
link and Back works. On a phone the list becomes a scrolling strip
rather than a second level of navigation.

- The exam objective moves in. It scopes the bank, the filters and now
  the knowledge profile, which makes it a setting; it was only reachable
  from a dropdown in the header.
- The notifications panel is gone. Its one control switched quiz
  reminders, and the reminder scheduler was removed earlier today — it
  was a toggle wired to nothing.
- Form fields are 16px on touch so iOS does not zoom the page in on
  focus and refuse to zoom back out; nav rows are 44px targets.

Also fixed, found in an agent's report rather than by looking:

  37 cross-references across 25 articles are nested and broken —
  `[[363|[[245|gastroesophageal reflux]] disease]]`, which renders as
  literal brackets and resolves to nothing. The first linker pass linked
  the longest title, then let a shorter one cut into the result. The
  current pass cannot do this (a finished marker is stashed), but the
  damage was already in the database and strip_owned could not see it:
  its label group stops at the first "]". link_articles now unwraps the
  inner marker, keeping the outer — the longer, more specific title.

And the ArticleSplitView flake: the preview card appears on a 350ms
timer and the query allowed 2s, which the full parallel run exceeded
often enough to fail a different case each time. Tried fake timers
first; they fight waitFor. A longer allowance is the honest fix — the
test is about the split view, not about how fast the box is. Four
consecutive clean runs.

Frontend 274/274.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:46:13 +02:00
Daniel
950c591ec5 docs: how the recommendations and adaptive session work, plus two fixes
Answers the two questions owed: the readiness shrinkage, the three
groupings, the priority ranking, and the four steps of adaptive
selection — including where it is weaker than it looks.

Writing it up surfaced two defects, both fixed here:

  * adaptive_select took the first 2,000 candidate rows. The bank is
    2,948, so about a third of it could never be selected, and which
    third depended on database order. The cap is gone; two integer
    columns per question is not a size worth protecting against.
  * category lookup was a linear scan through every candidate for every
    recorded answer — O(answers x candidates), the slowest part of
    building a session. It is a dict now.

Left alone and documented instead, because changing them changes which
questions a learner is given and that is not a silent decision: adaptive
ordering uses raw category accuracy rather than the shrunk readiness the
recommendations page uses, and difficulty is a filter rather than
something the session moves along.

Backend 231/231.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:37:25 +02:00
Daniel
b06da68f6b feat: knowledge profile grouped by Articles, Systems or Disciplines
The same answers asked three ways, as AMBOSS does it: which reading to
go back to, which organ system is weak, which discipline is weak. It was
Systems/Subtopics, where "Systems" meant top-level categories — which
are disciplines, not systems — and "Subtopics" meant every category
below them.

  * Articles (the default): rows are the published article behind a
    category, so the row links straight to the reading.
  * Systems: the 16 organ systems. No question is tagged with a system
    directly — it carries a symptom keyword filed under one — so
    membership rolls up through the keyword's parent.
  * Disciplines: top-level categories, which is what the old "systems"
    grouping actually was.

Only 1,502 of 2,948 questions carry a system tag, so the Systems tab
says so rather than showing half the bank as if it were the whole of it,
and relevance there is measured against what the grouping can see.

"Practise this topic" now practises the row you are looking at, on its
own axis. That needed system_ids on the builder — matched as "any tag
beneath this system", where the existing tag_ids is "every one of these
tags", so the two cannot be conflated.

Backend 228/228, frontend 266/266.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 12:33:05 +02:00
Daniel
9bc202d967 feat: prose_pass — move article voice to third person, with a gate that refuses fact drift
348 sections carry 819 imperatives ('Obtain a CBC', 'Counsel parents').
Reference prose states what is done. This script exports those sections,
takes rewrites back, and refuses any that lost a cross-reference, changed
a number, or landed outside 0.7-1.4x length — so a voice pass cannot
quietly become a content pass.

Measured while building it: first person is zero in the corpus. An
earlier count of 136 was matching 'US' the country as the pronoun 'us'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 05:14:56 +02:00
Daniel
595b619515 feat: cross-links follow a rule instead of linking every mention
3,560 links became 2,191, and the distribution is the point: Seizures
was linked 119 times and is now 48, across the 50 articles that mention
it — about one per view, which is what a first mention means.

  1. First mention per view, not per section. Short, Long and Clinical
     are read separately so each earns one; nine sections did not earn
     nine. Metabolic Acidosis: 39 links to 19.
  2. Lists are jump lists, prose is not. A differential or causes list
     keeps a link on every distinct condition — that is the one place a
     reader wants ten in a row.
  3. The 23 titles mentioned in more than 5% of articles link from lists
     only. "Seizures may occur" is not a topic anyone breaks off reading
     to visit; it is the vocabulary of paediatrics.
  4. A finished marker is now stashed, so a shorter title cannot re-cut
     one already made.
  6. A link whose label is not the target's title was written by hand
     and is never touched, which is what makes --apply re-runnable: it
     strips only its own work and reapplies the rule.

Correcting myself: I first measured hub terms on text that was already
linked, where the word pattern cannot see a mention wrapped in a marker.
That undercounted by four times and made the corpus look far less
repetitive than it is. The threshold is measured on clean prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 05:10:24 +02:00
Daniel
8f73e0f75b fix: one analysis layout, and an unsat session is the same page at zero
Two bugs, one visible cause. `.an-page`, `.an-rail` and four more classes
were defined in both AnalysisPage.css and AnalysisSessionPage.css with
different values — one a 280px grid, the other 260px. Once the two pages
shared a rail both stylesheets loaded together, the later won, and the
content column collapsed to rail width: "General Pediatrics" wrapped one
letter per line and the table headers floated away from their rows.

AnalysisShell now owns the frame and the session list for both views.
The page stylesheets style their content and nothing else.

And a session nobody has sat is no longer a bespoke "nothing here" panel.
GET /attempts/quiz/{id}/analysis answers with the same shape at zero —
0%, 0/20, every row "skipped" — so it is visibly the same page the
learner will see filled in, with a line saying why the figures are zero
and Start below. A part-finished session says how many are outstanding
and offers Resume. Once an attempt exists the quiz address returns the
real analysis, so both ways in reach the same page.

Mobile: below 1000px the rail becomes a band above the content that
starts closed — on a phone the first thing on screen should be the
analysis asked for. Search field is 16px on touch so iOS does not zoom
the page in and refuse to zoom back out; rail rows are 44px targets.

Backend 226/226, frontend 265/265.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 05:08:56 +02:00
Daniel
a677b4be23 feat: an exam that runs out is a result; delete and reset go all the way down
An unsuspended exam keeps running. When its clock runs out it is
submitted with what was answered and the score counts — a learner who
ran out of time sat an exam, which is a result and not an accident to
hide. Previously it was graded, flagged expired=1, excluded from every
statistic, and the client was told the opposite ("submit manually").

- attempt_expiry.settle_if_expired: one path, used by resume and by the
  sessions list, so an exam left open elsewhere shows its score rather
  than "in progress" forever. Suspended attempts hold their clock and
  never expire.
- resume returns {expired_submitted, attempt_id}; the client opens the
  analysis. The suspend dialog and the leave warning now say what
  actually happens.
- delete: saved progress and device lock cleared; a study-plan block
  whose only completed attempt is deleted goes back to unfinished.
- POST /attempts/reset-all: typed RESET, removes attempts, answers,
  in-progress state, plan progress, reading marks, saved questions and
  question notes; leaves the account, authored content and AI chats.
  Settings → Your data, with the counts reported afterwards.

Also fixed on the way: the first version of the sessions-list change
mutated the dict it was iterating; the test only passed because it had
one attempt. Now two.

Backend 223/223, frontend 258/258.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:47:11 +02:00
Daniel
ac92793600 feat: study-plan blocks as modules, sessions that know their block
From the three recordings and the AMBOSS screenshots.

Study plans
- Blocks of about 40, split evenly: 202 questions is six blocks of
  33-34, not five of 50 and one of 2. Reseeded (no progress or reading
  existed yet); the seeder now splits the same way.
- A block has its own page, laid out as a course module: the plan's
  blocks down the left, this block's reading then its session in the
  middle, back / previous / next along the bottom. Study or exam mode
  is chosen there, before the session exists; afterwards the mode is
  shown, not offered. The plan page is the table of contents and links
  into blocks rather than starting anything.
- Progress on a block comes from the same /quizzes/sessions row the
  Sessions page shows, so the two cannot disagree.

Sessions <-> plans
- A session started from a block carries its place in the plan: the
  session list and the analysis both return `plan` (plan, block,
  position, previous and next block). The analysis shows a strip with
  the way back to the block and on to the next one.
- Submitting a session marks its block complete. Nothing ever set
  completed_at before — every block read as unfinished forever.

Recommendations
- Framed by the learner's chosen study objective: answers and bank
  material linked to a different exam are left out, and the page is
  titled for the exam. Unlinked material stays in, as elsewhere.

Backend 216/216, frontend 257/258 (the one failure is
ArticleSplitView, which is timing-flaky under the full run and is
unrelated to this change; being checked separately).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:31:21 +02:00
Daniel
ebb9e701ee fix: one sessions list, at /sessions, with plan material out of it
The page at /quizzes showed the same fifteen rows twice — once under a
"Sessions" tab as a list, once under a "Library" tab as cards — with
nothing distinguishing them. The navbar carried the duplication too,
with "Sessions" and "History" both pointing at the same page.

Board Review I-XII already exist as study plans. The Library tab was
showing the bulk quizzes those plans were built from, so the same twelve
titles appeared in both systems. Those quizzes are now origin='plan':
still real, still the parent of their questions via source_quiz_id, but
no longer offered as something to pick off a list. Once a learner has
actually sat one it is history, so the session list keeps it.

- QuizzesPage deleted; /sessions is the only listing
- /quizzes/* redirects to /sessions/*, preserving path and query
- submitting a session lands on its analysis, not the old score page
- the answer review drops its score hero, which the analysis owns and
  stated differently; a course quiz keeps its card, having no analysis
- delete-attempt moves to the analysis page, where the session lives

Backend 208/208, frontend 246/246.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:10:25 +02:00
Daniel
1f77d421c7 feat: cross-link the articles, and strip what the analysis page replaced
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
2026-09-11 03:44:44 +02:00
Daniel
6abe46d1ea fix: edit an image over the grid, drop the role from figure titles, no iOS zoom
Editing expanded the card in place, which stretched its column to the height of
a form and shoved every neighbour out of line. It opens over the grid now, with
the image beside the fields so you can see what you are describing.

Figure titles read "Stem figure — Occult Fracture". The role is already obvious
from where the figure sits, and the link is the `question_media` row rather than
the words in the title, so the title is now just the subject. All 440 are named:
346 lost the prefix, and 94 that were still filenames took the subject of the
question they came from — including detached ones, whose caption records which
question they came off. No link was touched; the id is the link, and all 346
remain.

iOS Safari zooms the page when a focused field's text is under 16px and does not
zoom back out, which leaves a reader stuck at 1.4x with no way back. Every
control is 16px on a coarse pointer — one rule, applied once, rather than
remembered per component.

249 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:28:09 +02:00
Daniel
5add9f23dd fix: a skipped question is not a wrong answer
Performance by category counted every row in an attempt, and an attempt holds a
row for each question including the ones never answered. A 360-question sitting
that was opened and abandoned therefore landed as 360 wrong answers, which is
why Emergency Medicine read 0% of 400 and Gastroenterology 1.1% of 277 — figures
that describe a sitting nobody worked through, not a learner who cannot do
emergency medicine.

Accuracy now counts only questions that were actually answered, and the note
under the heading says so. Coverage is a separate question from accuracy and
conflating them made both useless.

Also: the category performance block is gone from the dashboard, where it
duplicated the one on Analysis; and the nav says Sessions rather than Quizzes,
with History beside it — "quiz" describes the packaging, a learner sits a
session, and the two entries answer different questions: what can I sit, and
what have I sat.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 03:21:13 +02:00
Daniel
0e6c18d886 feat: figures as records, question-centred dashboard, fewer hints mid-quiz
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
2026-09-11 03:18:49 +02:00
Daniel
6bb5767871 feat: session analysis after a quiz, and a merged TODO for the rest
The results page showed a score and a wall of explanations. What a learner needs
afterwards is where the time went and what to go back to, so
/analysis/session/:attemptId gives them: a rail of recent sessions, the four
figures they act on — correct, completed, time per question, total time — a
donut, the weakest topics, and a paginated table of every question with its
status, difficulty, time and how peers did on it.

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

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

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

208 backend, 249 frontend green.

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

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

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

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

208 backend, 249 frontend green.

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

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

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

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

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

208 backend tests green.

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

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

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

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

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

247 frontend tests green.

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

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

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

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

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

244 frontend tests green.

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

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

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

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

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

244 frontend tests green.

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

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

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

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

208 backend, 243 frontend green.

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

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

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

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

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

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

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

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

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

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

208 backend, 243 frontend green.

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

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

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

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

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

203 backend, 234 frontend green.

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

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

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

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

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

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

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

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

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

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

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

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

203 backend, 223 frontend green.

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

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

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

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

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

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

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

182 backend, 223 frontend green.

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

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

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

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

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

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

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

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

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

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

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

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

166 backend, 188 frontend green.

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

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

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

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

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

154 backend, 163 frontend green.

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

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

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

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

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

146 backend, 152 frontend green.

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

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

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

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

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

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

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

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

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

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

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

Tests: 131 backend green.

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

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

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

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

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

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

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

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

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

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

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

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

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

Applied after a backup. Tests: 123 backend green.

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

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

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

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

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

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

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

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

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

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

Both scripts are idempotent and were applied after table backups.

Tests: 123 backend green.

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

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

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

Tests: 136 frontend green, build clean.

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

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

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

Tests: 123 backend green.

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

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

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

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

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

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

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

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

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

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

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

Tests: 113 backend green.

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

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

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

Tests: 113 backend green.

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

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

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

Tests: 113 backend green.

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

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

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

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

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

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

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

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

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

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

Tests: 101 backend green.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 23:45:33 +02:00
Daniel
16dc431066 feat: analysis recommendations, category grants, compact question header
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
2026-09-09 18:47:32 +02:00
Daniel
bc9dc290f8 feat: AMBOSS-style session management, question manager, showcase articles
Quiz management area (AMBOSS parity, verified against next.amboss.com):
- GET /quizzes/sessions returns one management row per accessible quiz —
  attempt state, live answered/total from Redis, last score and activity —
  so the page no longer fans out per-quiz requests.
- QuizzesPage rebuilt as a session list grouped by day with a progress bar,
  a state-aware primary action (Start / Resume / Review) and an action menu
  matching AMBOSS: Analysis, Repeat, Rename, Share, Edit, Category, Delete.
  Rename and delete confirm inline; no browser popups.
- Sessions / Library / Categories tabs replace the flat card grid.
- QuizPage honours ?restart=1 so Repeat always begins a fresh attempt.

Question manager (new moderator page at /questions/manage):
- GET /questions/manage/summary counts editorial gaps; /questions/bank gains
  a `needs` filter (category / explanation / difficulty / private) so the
  health tiles double as one-click filters.
- POST /questions/bulk applies category, difficulty, sharing or delete to up
  to 500 checked questions in one call, moderator-only.
- Question edit/create modals extracted to components/QuestionEditors.jsx and
  shared by the question bank and the manager instead of being duplicated.

Showcase articles:
- scripts/seed_showcase_articles.py seeds eight short starter articles across
  the main pediatric systems, each filed under a real category, with stable
  hex section IDs and links to bank questions from the same category.

Mobile: dedicated stylesheets for both pages — rows stack, the action menu
becomes a bottom sheet and the bulk bar docks to the bottom edge.

Tests: 9 new backend tests (session feed states, ordering, Redis-outage
degradation, visibility; bulk actions, gap filters, moderator gate) and 9 new
frontend tests. Full suites green: 72 backend, 106 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:27:53 +02:00
Daniel
73ef007e0a feat: facet filters, personal libraries, adaptive shortcuts, restart, rename support
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.
2026-09-09 04:31:40 +02:00
Daniel
236547e646 feat: sample smart-links quiz, bank feedback polish, performance hierarchy
Sample quiz demonstrates option explanations, key points with article links and linked cards. Bank study modal shows per-option explanations and key points. Performance shows main categories with an expandable hierarchy. AMBOSS-style picker polish (chevrons, search box, switch, auto title). Mobile spacing fixes. 97 frontend tests pass.
2026-09-09 03:31:54 +02:00
Daniel
91ec6a501c feat: key points smart links, difficulty tags, adaptive sessions, educator-only question management
Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
2026-09-09 02:26:45 +02:00
Daniel
da8a717489 feat: PREP as tags, stats toggle, comments redesign, quizzes CTA
PREP provenance becomes keyword tags tied to the source quizzes; PREP question categories retired. Response statistics get a persistent hide/show toggle. Comments redesigned with avatars, badges and a cleaner compose box. Create Custom Test is now a prominent card on the Quizzes page only. Hierarchy conversion gains pediatric sub-specialties and disease children. 96 frontend tests pass.
2026-09-09 01:30:20 +02:00
Daniel
88912f83c7 feat: system category hierarchy from tags, category editor overlay
Subject tags map to 26 canonical systems via a reversible, backed-up conversion script; questions keep old categories as extra links. Category add/edit now opens a proper dialog instead of stacking above the chips. 95 frontend tests pass.
2026-09-08 23:50:25 +02:00
Daniel
ff1aee6fad fix: resolve latest review findings
Category quiz creation counts extra-linked questions; primary category is validated on edit; statistics dedupe collapses case variants. 60 backend tests pass.
2026-09-08 19:53:25 +02:00
Daniel
179e51d143 feat: per-option explanations
Questions support an explanation per option, edited in the question dialog and shown in study feedback. Keys must match current options. Migration j3e4f5a6b708. 60 backend and 95 frontend tests pass.
2026-09-08 19:29:21 +02:00
Daniel
a1e9340004 fix: auto-start quizzes in their mode, drop quiz code and verbose stats note
Timed quizzes start as exams and learning quizzes as study without a second mode prompt; reopening resumes automatically. Removed quiz code display from in-progress list and the verbose statistics basis sentence. Lab rows keep logical age order per test. 93 frontend tests pass.
2026-09-08 19:07:27 +02:00
Daniel
0c03a6f286 feat: sample content seeder for demonstrations
Clearly-marked sample articles, cards, question links and lab deep links; idempotent. Seeded in production so every linking feature is visible end to end.
2026-09-08 18:29:34 +02:00
Daniel
f5a084e49a feat: Orthobullets-style lab panel with source deep links and card links
Lab references deep-link to article sections or external sources, show linked cards with study links, and educators can attach cards and article targets. Grouped panel layout. Migration i2d3e4f5a607. 57 backend and 93 frontend tests pass.
2026-09-08 16:20:55 +02:00
Daniel
01337c8c25 feat: performance by category dashboard
Accuracy per category from completed non-expired general-bank answers, counting each question in its primary and additional categories. 56 backend and 90 frontend tests pass.
2026-09-08 16:03:45 +02:00
Daniel
cdb1ab7468 feat: multi-subcategory questions, statistics hardening, seeded lab values
Questions can belong to additional subcategories (junction table, counts, builder/bank filters, edit UI chips); response statistics dedupe duplicate options, match case-insensitively and exclude obsolete answers; AI question classification is command-line only (UI trigger removed); lab reference seed script with cited public pediatric ranges. Migration h1c2d3e4f506. 55 backend and 88 frontend tests pass.
2026-09-08 14:43:22 +02:00
Daniel
d3663fd5fc fix: refine uses existing article content and validate AI section IDs
AI refine now sends the current body and sections to the model; invalid model section IDs are replaced with valid hex IDs. Job polling list raised to 200. 50 backend tests pass.
2026-09-07 17:44:15 +02:00
Daniel
0fa8d0a689 feat: shareable quiz links, moderated comments and AI authoring
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.
2026-09-07 17:10:08 +02:00
Daniel
6c85f8b4a9 fix: resolve article review findings
Escape raw HTML in article markdown, honor section deep links, filter card link listings by bank visibility and publication status, validate source sections. 44 backend and 77 frontend tests pass.
2026-09-07 16:44:07 +02:00
Daniel
02be9a6899 build: pin litellm 1.28.13 after 1.27.8 was yanked from PyPI 2026-09-07 16:18:35 +02:00
Daniel
325f7f400f feat: linked topic articles and card associations
Draft/published article library with stable section IDs, breadcrumbs, link remediation, question and card associations, manual card creation and side-by-side/mobile-drawer reading. Migration e8d4f1a27c93. Verified 42 deployed-image backend tests, 72 frontend tests/build and PostgreSQL migration round-trip.
2026-09-07 15:16:48 +02:00
Daniel
1ce3eec7cb feat: authorize tutor context and protect uploaded media
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.
2026-09-07 15:04:00 +02:00
Daniel
c630b0270d fix: preserve saved quiz progress across resume failures
Block Start after failed resume, retry saved state, re-read reused-attempt progress, surface cache failures and prevent leaving on failed save. Handle nullable review settings and register lab model with Alembic. Verified 25 deployed-image backend tests, 53 frontend tests/build and PostgreSQL schema round-trip.
2026-09-07 03:34:55 +02:00
Daniel
a3a6ef7995 feat: redesign quiz runner and add study tools
Add Orthobullets-inspired numbered-answer UI, explicit study response confirmation, response statistics, review navigation, safe calculator, keyboard controls and sourced educator lab references. Persist attempt mode to prevent query-flag exam disclosure. Combined deployed-image backend suite (22), frontend suite (48), build and synthetic desktop/mobile browser checks pass. PostgreSQL round-trip and independent review remain release gates; no production deployment.
2026-09-07 03:10:23 +02:00
Daniel
38f3fb8250 fix: remove Turnstile from quiz password login
Remove the challenge from standalone and landing login plus backend verification; retain registration/contact protection and existing password, email verification, SSO and login rate-limit checks. Verified 18 backend tests in deployed image and 17 frontend tests plus build.
2026-09-07 02:54:12 +02:00
Daniel
f696b99569 fix: close custom-test review gaps and verify grading
Handle ownerless question revocation, legacy hide sharing, private deletion, selected-set grading including skips, UI validation/reparent/delete safeguards and offline-safe hierarchy migration. Verified 14 backend tests in deployed image, 13 frontend tests/build and real disposable PostgreSQL migration round-trip. Related tutor/image privacy work remains before deployment.
2026-09-07 02:07:58 +02:00
Daniel
affd7177b5 feat: add category-based custom tests and permission-safe sharing 2026-09-07 01:17:42 +02:00
Daniel
3cf21d4438 chore: preserve pre-revamp local changes
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.
2026-09-07 00:50:35 +02:00
Daniel
9f97218f39 Add persistent MyNote for quizzes
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 17:51:23 +02:00
Daniel
fdebda993c Improve quiz TTS and Android release build
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s
2026-05-12 01:01:01 +02:00
Daniel
d59c8bed6f Remove coach modes and improve quiz audio 2026-05-10 01:24:06 +02:00
Daniel
2a0dd56f95 Add ai_answer extraction + broader ai_decide sampling + flashcard title edit
ai_decide now samples 4 points across the section (start, 1/3, 2/3, end)
instead of just the first 30 + last 20 pages. This gives accurate strategy
detection on large documents where the answer format might be deeper in.

New ai_answer extraction mode:
- Extracts questions from Q&A-format PDFs that have no answer key
- AI picks the correct option from each question's choices
- Generates explanation using document context + medical knowledge
- Useful for PDFs like practice tests where answers were never included
- Available manually and as an ai_decide strategy

Flashcard decks can now be renamed:
- PATCH /flashcards/{deck_id} updates title
- Inline edit on FlashcardsPage with responsive layout (input full-width,
  buttons wrap under it so Cancel never overflows the card)
- Title truncates with ellipsis when not editing

Note: generate mode (textbook -> MCQs) is unchanged per user request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 20:17:49 +02:00
Daniel
5398342e3d Suspend pauses timer; hide timer-expired attempts from history
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>
2026-04-19 19:27:46 +02:00
Daniel
56fdc57389 fix: gateway-agnostic URL handling for TTS and embeddings, docs cleanup
- Fix double /v1 in TTS audio/speech URL when LITELLM_API_BASE includes /v1
- Fix double /v1 in embedding service and vector service URLs
- Clean up docs: remove second-person language in deployment, frontend, migrations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 02:17:35 +02:00
Daniel
0f3a267fb6 Wire Alembic to live Postgres and fix startup DDL race
- 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.
2026-04-14 04:49:23 +02:00
Daniel
b39e09f393 Persist reminders_disabled in Postgres and harden reminder scheduler
- Add reminders_disabled boolean column to users (DB source of truth).
- Scheduler reads the DB column directly; Redis no longer used for
  opt-out checks.
- Scheduler now deactivates reminders when a user has no completed
  attempts left for a quiz (e.g., after deleting their attempts).
- Settings API: GET returns DB value for reminders_disabled; PUT
  persists that key to DB and keeps the rest of the blob in Redis.

Rollback point for Alembic wiring.
2026-04-14 04:31:03 +02:00
Daniel
d0518d0737 Add comprehensive structured logging with Loki + Grafana
Backend logging:
- Centralized JSON logging config with LOG_LEVEL env var
- Request logging middleware: user, method, path, status, duration, request_id
- Fixed all 9 silent except:pass blocks to log warnings with tracebacks
- Celery workers use same structured JSON format

Infrastructure:
- Loki 3.3.2 for log storage (30-day retention)
- Promtail 3.3.2 for Docker container log shipping
- Grafana 10.3.1 with auto-provisioned Loki datasource
- Grafana on port 3002 (admin/pedshub_grafana)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:53:54 +02:00
Daniel
d44ad883e9 Add SCORM data persistence across sessions
SCORM runtime data (bookmarks, scores, lesson position) now saved to
Redis via GET/PUT /courses/{id}/lessons/{id}/scorm/data endpoints.
Data loads before iframe renders and saves on Commit/Finish/Terminate.
90-day TTL on stored data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:53:53 +02:00
Daniel
db98c6ae69 Add certificate generation, SCORM support, QTI export/import
- Certificate: PDF generated on course completion, download from course page
- SCORM: upload ZIP packages as lesson type, served in iframe, manifest parsed
- QTI 2.1: export selected/all questions as XML, import QTI files into bank
- Uses fpdf2 for certificate PDF generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:30:09 +02:00
Daniel
699cbabfcb Add OIDC/SSO login, reminder fixes, user notification settings
- Add generic OIDC/SSO support (configurable via env vars)
- Admin can enable SSO-only mode (disables password login)
- SSO callback auto-creates and verifies users
- Login page shows SSO button when configured, hides password form in SSO-only mode
- Fix reminders: skip course quizzes and deleted quizzes
- Don't create reminders for course quiz attempts
- Add user reminder opt-out toggle in Settings > Notifications
- Scheduler checks user opt-out before sending emails

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 04:10:15 +02:00
Daniel
695392f022 Remove HIBP breached password warnings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 02:24:24 +02:00
Daniel
197181f6f9 Fix CLI argument handling for all commands
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:57:58 +02:00
Daniel
affd2fc116 Add admin guide, document CLI commands, consolidate account into settings
- Add list-docs, fix-stuck-docs, reprocess-doc CLI commands
- Create ADMIN.md with deployment, CLI, database, and troubleshooting docs
- Add HIBP warning to Settings page password change
- Remove separate Account nav link (already in Settings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:39:58 +02:00
Daniel
8137d5b20c Decouple course quizzes, replace passlib, add security hardening
- 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>
2026-04-06 21:31:05 +02:00
Daniel
bc1a88a954 Fix course editor: field mismatches, TinyMCE, AI without save
Backend fixes:
- Remove nonexistent fields: category, thumbnail_url, file_path, sort_order
- Map to actual model fields: thumbnail_path, local_file_path, position
- Add standalone AI content endpoint (no lesson save required)
- Add missing lesson fields to course detail response

Frontend fixes:
- Fix lesson_type (was "type"), live_session_url (was "live_url")
- Fix lesson save/delete URLs to match backend routes
- Add TinyMCE rich text editor for text lessons (self-hosted, GPL)
- AI Generate works before saving lesson (uses course-level endpoint)
- Proper input styling: larger padding, border-radius, consistent sizing
- Module title input properly styled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:49:43 +02:00
Daniel
594bbfaf26 Fix course router: remove nonexistent model fields
- Remove category, thumbnail_url references (not on Course model)
- Fix sort_order → position (matching actual column name)
- Fix CourseCreate schema: only title + description
- Fix tab labels: "My Created" → "Created by Me"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:26:41 +02:00
Daniel
a2a2da7da0 Add LMS, user quiz creation, manual questions, course system
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>
2026-04-05 02:07:04 +02:00
Daniel
be7efd73d2 Fix shared decks route ordering, fix corrupted middot character
- Move GET /shared before GET /{deck_id} so FastAPI matches it correctly
  (was being caught by /{deck_id} and failing int parse on "shared")
- Remove duplicate /shared endpoint left after move
- Fix corrupted middot character (U+FFFD) in My Decks card count display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:45:13 +02:00
Daniel
18d0a74348 Add deck sharing, star ratings, and shared deck browsing
- 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>
2026-04-04 23:37:40 +02:00
Daniel
800de50fc9 Flashcard ownership, trash, delete cards, fix job status
- 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>
2026-04-04 23:24:48 +02:00
Daniel
3e37bf2128 Add AI-powered flashcard generation system
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>
2026-04-04 22:59:21 +02:00
Daniel
2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- Fix tag filtering (sa_text import shadowing caused UnboundLocalError)
- Add TagBrowser component with per-section search
- Multi-category selection (OR within categories, AND with tags)
- AI image validation: has_figure field in extraction prompt
- Skip known branding images by MD5 hash + dimension filters
- Fix quiz timer auto-submit (wrong useEffect dependency)
- Fix QuizResponse schema: section_id nullable
- Fix Question.quiz_id → source_quiz_id attribute name
- Fix SQL injection in quizzes.py vector search
- Add PDF processing progress steps via Redis
- Add delete user from admin panel
- Admin page: no spinner flash on data refresh
- Upload progress: axios 1.x e.progress, remove manual Content-Type
- Duplicate model error: 409 with clear message
- Backend startup: retry DDL migration on lock timeout
- Replace all silent except:pass with warning logs
- Comprehensive multi-page documentation (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:26 +02:00
Daniel
10b9b1b7cf Rebrand to PedsHub, fix CSP for fonts and Turnstile
- index.html: title → PedsHub, icon → 🏥, add meta description
- nginx.conf: CSP extended for Google Fonts, Cloudflare Turnstile (script/connect/frame/img)
- Navbar, LoginPage, ForgotPassword, ResetPassword, email_service, main.py: PedQuiz → PedsHub, 🩺🏥

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:56:53 +02:00
Daniel
5403d78a3e Fix landing page routing, add logged-in nav integration
- App.jsx: simplify to single Routes block with Outlet layout pattern — fixes 404
- App.jsx: /home always accessible regardless of auth state
- LandingPage: show Dashboard/Quizzes/Question Bank nav links + Go to App CTA when logged in
- LandingPage: hero shows app navigation buttons when signed in
- LandingPage: fix apostrophe JSX syntax error in textarea placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:41:39 +02:00
Daniel
ee47fb07a2 Add PedsHub landing page with contact form and AI Scribe section
Frontend:
- LandingPage.jsx: hero, 6 feature cards, AI Scribe section (links peds.danvics.com), contact form
- Contact form: name/email/type toggle (question vs moderator app), Turnstile anti-bot (optional)
- App.jsx: /home route for unauthenticated users, auth pages reachable pre-login, logged-in /home → /

Backend:
- contact.py: POST /api/contact (public), stores in DB, emails admin; GET /submissions (admin)
- main.py: create contact_submissions table, register contact router
- main.py: add pedshub.com + www.pedshub.com to CORS origins
- config.py: TURNSTILE_SECRET_KEY, ADMIN_EMAIL settings

Env vars to set:
  VITE_TURNSTILE_SITE_KEY=<from cloudflare.com/turnstile>  (frontend)
  TURNSTILE_SECRET_KEY=<secret>  (backend .env)
  ADMIN_EMAIL=you@example.com  (backend .env)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:34:44 +02:00
Daniel
4cbaa529f0 Workers x4, follow-up suggestions, singleton lock, admin error details
- docker-compose.yml: --workers 4 on uvicorn
- main.py: Redis singleton lock so only one worker runs scheduler + backfill (prevents 4x cron jobs)
- teach.py: system prompt requests 3 follow-up suggestions ("> " prefix format)
- teach.py: parse and return suggestions[] separately from reply
- TeachChat.jsx: render clickable suggestion chips after last assistant message (click fills input)
- admin.py: restore full error details for admin endpoints (ElevenLabs, Polly, LiteLLM)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:27:03 +02:00
Daniel
4c48a5cf94 Security hardening, async TeachChat, rate limit UX, unthrottle
Security:
- admin.py: move api_key from URL query params to POST body (litellm/models, tts/voices) — prevents key logging
- admin.py: sanitize exception messages in voice discovery — log internally, return generic errors
- teach.py: log LLM errors server-side, show friendly message to user
- nextcloud.py: normalize path with posixpath.normpath to prevent ../ traversal
- auth.py: check_rate_limit now accepts user param — admins/moderators/unthrottled always exempt

Performance:
- teach.py: make /chat endpoint async, use litellm.acompletion() — no longer blocks a uvicorn thread per request

Features:
- users: add is_unthrottled column (DB migration in setup_pgvector)
- admin.py: PUT /users/{id}/unthrottle endpoint
- AdminPage.jsx: Unlimited/Throttle toggle button per user, shows "unlimited" badge
- Rate limit messages improved with user-friendly context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 01:09:42 +02:00
Daniel
7f1f14537b Security: fix SQL injection, add rate limiting, markdown in TeachChat
- teach.py, questions.py: replace f-string SQL with parameterized CAST(:vec AS vector) queries
- auth.py: add reusable check_rate_limit() Redis helper
- teach.py: rate limit /chat to 30 req/10min per user
- tts.py: rate limit /speak to 60 req/hr per user
- teach.py: stronger system prompt — no clarifying questions, use markdown, answer directly
- TeachChat.jsx: render assistant messages with ReactMarkdown (already in package.json)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:45:28 +02:00
Daniel
975a31fb01 TeachChat: model selector, proxy routing fix, titan seed cleanup
- 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>
2026-04-04 00:37:35 +02:00
Daniel
12d99d3609 Add switchable embedding model, Polly toggle, job cancellation, and UI fixes
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>
2026-04-03 20:44:11 +02:00
Daniel
c94640c890 CRITICAL: Remove cascade delete from Section→Quiz relationship
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>
2026-04-01 18:26:22 +02:00
Daniel
24fb00aef2 Fix jobs showing double: lpush was in both router AND Celery task
The job ID was pushed to extraction:user_jobs:{uid} twice:
1. quizzes.py router when dispatching the job
2. quiz_tasks.py Celery task when it starts running

Removed the push from the Celery task. Router handles it.
Also cleaned 13 duplicate entries from Redis.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:00:20 +02:00
Daniel
1343eb5801 Fix mobile touch, hide docs for users, timer save/restore, Nextcloud server-side
Mobile touch fix:
- Added touch-action: manipulation and -webkit-tap-highlight-color: transparent
  to .question-card .option CSS — prevents mobile browsers from interfering
  with tap events (double-tap zoom, highlight flash, delayed clicks)

Documents hidden for regular users:
- Dashboard: stats card hides Documents count for non-moderators
- Dashboard: document list section completely hidden for non-moderators
- Documents API not called for non-moderators (saves a request)

Timer improvements:
- Custom timer input on ModeSelectScreen — set any number of minutes
  for exam mode (overrides the quiz default, or add time to untimed quizzes)
- timeLeft saved to Redis progress every 1.5s (alongside answers, mode, voice)
- On resume: timer restarts from the saved remaining time
- Timer auto-submits when it reaches 0 (already worked, now also works on resume)
- Progress bar shows remaining time relative to what was left at resume

Nextcloud settings — server-side storage:
- GET /auth/me/settings — load user settings from Redis
- PUT /auth/me/settings — save settings to Redis (linked to user_id)
- SettingsPage loads Nextcloud config from server on mount
- Saves to BOTH server (cross-browser persistent) and localStorage (for UploadPage)
- Clear function wipes both server and localStorage
- Switching browsers and logging in restores Nextcloud settings

Also fixed: saved.attemptId → saved.attempt_id in ModeSelectScreen resume check

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:39:43 +02:00
Daniel
365aaf3df0 Fix option selection, submit failure, and slow load
Root causes:
1. joinedload(Quiz.questions) was SLOWER (129ms vs 75ms) with the custom
   secondary relationship — removed, back to lazy load
2. Redis progress could have stale attempt_id from a completed submission:
   GET /attempts/progress now validates the attempt exists and is not completed
   before returning; stale entries are auto-deleted
   POST /attempts/{id}/submit now clears Redis progress server-side on submit
3. Navbar jobs polling was running every 4s even with no active jobs,
   causing unnecessary requests — now polls every 30s when idle,
   every 3s when jobs are actively running

Also cleared 3 stale Redis progress entries that were pointing to
already-completed attempts (which caused silent submit failures).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:29:38 +02:00
Daniel
1f95c4d1ea Explanation: capture ALL content verbatim until next question
Updated extraction prompt to be explicit that explanation must include
every section between the correct answer line and the next question:
- Explanation paragraph
- Critique section (if present)
- Learning Points (if present)
- Content Specifications / American Board of Pediatrics specs
- Suggested Reading references
- Any other content

No summarizing, skipping, or shortening of any text.
Keep all section headers verbatim.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:16:43 +02:00
Daniel
77da7aea6c Restore original verbatim extraction prompt; switch to Claude Opus 4.6
Extraction prompt:
- Restored full original prompt with strict verbatim instructions
- Explanation field: must include full explanation paragraph + Critique
  verbatim + Content Specifications verbatim (no summarization)
- Correct answer: explicit step-by-step with example showing letter→full text
- Options: extract full text only, no letter prefix
- All 5 rules numbered and explicit

Model switch:
- Default extraction model changed from Claude Haiku 4.5 → Claude Opus 4.6
- Haiku tends to compress/summarize long explanation text
- Opus 4.6 follows verbatim instructions reliably and handles
  the structured PREP format (explanation + Critique + Content Specs)
  without losing content
- Haiku 4.5 still available in the Extraction Model dropdown for
  cost-sensitive use cases

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:57:28 +02:00
Daniel
b859d441eb Add extraction modes; fix resume; hide Nextcloud/Upload for users; delete PREP 2013 questions
Extraction modes (no restart needed — code ready for next Celery deploy):
- New QuizCreate.extraction_mode field: standard|questions_only|two_step|regex|ai_decide
- extraction_modes.py: independent implementations that don't touch standard path
  - questions_only: extract Q+options, correct_answer="PENDING" for manual fill
  - two_step: separate answer key section scan + phase1/2/3 matching
  - regex: AI detects answer pattern, generates regex, applies to full doc
  - ai_decide: AI reads samples from start+end and picks strategy
- DocumentDetailPage: Extraction Mode dropdown with description per mode
- quiz_tasks.py: routes to correct mode, standard path completely unchanged

Database:
- Deleted 11 orphaned questions from PREP 2013 extraction (quiz 12 was already deleted)
- 268 questions remaining (all PREP 2012)

UI fixes:
- Nextcloud section in Settings now only shown to moderators/admins
  (regular users can't upload PDFs so they don't need Nextcloud)
- Upload PDF already hidden in navbar for non-moderators (confirmed correct)
- Resume quiz: now async — study mode quiz data loaded BEFORE showing quiz
  so correct_answer is available immediately for feedback
- Resume saves and restores voice selection
- voice field added to ProgressSave schema and Redis storage
- Progress save dependency includes selectedVoice

Attempts:
- POST /attempts/start: reuses existing incomplete attempt by default (fresh=false)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:43:24 +02:00
Daniel
84c4917d91 In-progress quizzes panel, navigation warning, attempt reuse, progress via Redis
In-progress quizzes section (Quizzes page):
- Shows all incomplete (not submitted) attempts in an amber ⏸ panel
- Each shows quiz title, start date, question count
- Resume button → navigates to quiz (which loads saved answers from Redis)
- Delete button (ConfirmButton) → deletes attempt + clears reminders

Quiz navigation warning:
- beforeunload handler warns when closing browser tab/window mid-quiz
- Leave confirmation modal when navigating away via React Router
  (shows "Your progress is saved — resume later" + Stay/Leave buttons)

Quiz progress:
- Scroll on Next/Previous REMOVED per user request — page no longer scrolls
- Progress saved to Redis on every answer/navigation (1.5s debounce)
- Works across browsers, survives logout
- ModeSelectScreen loads saved progress from API and shows Resume button

Attempt management:
- POST /attempts/start now REUSES existing incomplete attempt (no more
  creating duplicate incomplete attempts on every quiz start)
  Pass fresh=true to force a new attempt
- GET /attempts/in-progress — list incomplete attempts for current user
- DELETE /attempts/{id} — delete attempt + reminders

Cleaned up 10 duplicate incomplete attempts from the database

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 05:16:14 +02:00
Daniel
92fdf55cd9 Revert extraction to simple standard mode; PREP 2013 will be separate option later
- Completely reverted quiz_tasks.py to simple standard extraction with chunking
  (no more two-phase detection that broke PREP 2012/2014)
- OCR normalization kept: 'Pref erred'→'Preferred', 'ltem'→'Item'
- The extraction prompt already handles both 'Correct Answer: X' and
  'Preferred Response: X' inline formats — no special detection needed
- PREP 2013 (separate answer key) will be implemented as a separate option
  user selects at extraction time, not automatic detection

Also in this commit:
- Fixed quiz delete 500 error (source_quiz_id attribute name)
- Added trash bin (soft delete, restore, permanent delete)
- Added hide/publish toggle per quiz (moderators see all, users see published only)
- Quiz progress saved to Redis — survives logout, works cross-browser
- Resume in-progress quiz from any browser
- ConfirmButton component replaces all window.confirm/prompt
- Delete own attempts endpoint
- TrashPage, AdminPage trash/jobs links in Settings

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 05:04:22 +02:00
Daniel
45add79db3 Fix quiz delete (500 error), add trash bin, quiz resume, hide/publish, remove JS dialogs
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>
2026-04-01 04:51:41 +02:00
Daniel
5a2fc7e187 Fix display: update n_chunks after chunk boundary filtering
The 'Chunk X/5' message was showing 5 even when only 2 chunks were
being processed (pages 1-50 and 51-55). n_chunks was not updated
after filtering chunks to stay before answer_section_start.
Cosmetic fix — the actual extraction was already correct.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:13:50 +02:00
Daniel
8a220bb12e Fix PREP 2013 extraction: OCR normalization, correct chunk boundary; category UI fix
Extraction fixes:
- OCR normalization: 'Pref erred' → 'Preferred', 'ltem' → 'Item' applied to boundary
  scan, Phase 1 questions, and Phase 2 answer key content before AI processing
- Chunk boundary: Phase 1 chunks now capped at (answer_section_start - 1) so no
  chunk bleeds into the answer section — (51, 100) becomes (51, 55) for PREP 2013
- Result: Phase 1 gets 2 clean chunks (1-50 and 51-55), Phase 2 gets pages 56-227

Category creation in DocumentDetailPage:
- Replaced window.prompt() with inline input form (more reliable, no browser quirks)
- Fixed option value type: String(c.id) ensures consistent string comparison with
  selectedQuestionCategoryId state (prevents type mismatch in controlled select)
- "+ New" button toggles inline form; Enter key or Add button submits

Deletion safety (confirmed):
- Deleting a quiz: questions detached to bank if exclusive, kept if shared — NEVER deleted
- Deleting a question category: questions uncategorized or moved — NEVER deleted

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:09:55 +02:00
Daniel
d07be64f59 Fix two-phase extraction boundary detection; README CLI docs; scroll fix
Two-phase extraction improvements:
- Auto-detect answer section boundary by scanning in 10-page steps for
  'Preferred Response:' — finds exact page where questions end and answers begin
  (PREP 2013 answers start at page ~68, not at the end of the file)
- Restrict Phase 1 question chunks to pages BEFORE the answer section
- Extract answer key from answer section in CHUNKS (50 pages each) to handle
  large answer sections — accumulates all item→letter mappings
- Previous version used last 40% which missed items 1-~135 for PREP 2013

README: full CLI extraction documentation:
- list-sections: find document and section IDs
- extract <section_id> [--bg] [--title] [--mode] [--user]
- jobs / jobs --user <email>
- Explanation of auto-format detection (inline vs separate answer key)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 03:36:12 +02:00
Daniel
e5e31f6eba Two-phase extraction for end-of-document answer keys (PREP 2013); scroll fix; bug audit
Two-phase extraction:
- Detects end-of-document answer key format by scanning last 40 pages for
  "Preferred Response:" (PREP 2013, 2014 etc use this vs PREP 2012 inline "Correct Answer:")
- Phase 1: Extract questions with item_number field, allow null correct_answer
- Phase 2: Extract answer key (item_number → letter) from last 40% of document
- Phase 3: Match questions to answers by item number, resolve letter → full option text
- Unmatched questions go to skipped list with reason shown in Jobs page
- Standard inline format (PREP 2012) unchanged

Updated extraction prompts:
- item_number field added to all extractions for cross-referencing
- Image content rule: "Item CXXXB" figure references must NOT be treated as new questions
- Recognises both "Correct Answer: X" and "Preferred Response: X"
- ANSWER_KEY_PROMPT: dedicated prompt for extracting answer key tables

Quiz navigation scroll:
- Clicking Next, Previous, or question number now scrolls the question card
  into view (smooth scroll to start of question-card div)

Code: extract_questions_no_answers(), extract_answer_key(), _call_model() added to ai_service.py

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 03:22:26 +02:00
Daniel
287d3a72f4 Fix question bank: select-all, bulk category, page size, category delete
Bugs fixed:
- 'Select all' now fetches ALL matching IDs from server (GET /questions/bank/ids)
  not just the currently loaded page — works for 268 questions
- Bulk category assign: fixed 'Remove category' value ('0' → 'remove'),
  now shows error message on failure instead of silent fail
- Search mode (Keyword/Semantic/Hybrid) moved to a clearly labelled dropdown,
  visually separated from category filter chips — eliminates confusion
- Category chips now have a 'Filter:' label to distinguish from other buttons

New features:
- Page size selector: 50 / 100 / 200 / All — loads the requested count in one shot
- Bulk actions moved to its own clearly labelled bar (only shown for moderators)
- Category delete with questions: prompts user to choose target category before deleting
  (uses new move_to param on DELETE /question-categories/{id})
- GET /questions/bank/ids endpoint for server-side select-all

Backend:
- DELETE /question-categories/{id}?move_to={id}: move questions to another category
  before deletion instead of always leaving them uncategorized

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:55:25 +02:00
Daniel
0bc336a7d0 Quiz delete: detach questions to bank instead of deleting them
When deleting a quiz:
- Questions shared with other quizzes stay linked to those quizzes
- Questions exclusive to this quiz are detached (source_quiz_id cleared)
  and remain in the Question Bank as orphaned questions, not deleted
- This preserves the question bank integrity

Also improves manage.py CLI with extract, list-sections, and jobs commands

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:36:25 +02:00
Daniel
871206d891 Cross-browser job tracking, hamburger fix, CLI extract, quiz delete keeps bank questions
Jobs (cross-browser/cross-session):
- POST /quizzes/ stores job_id in Redis under user key (extraction:user_jobs:{uid})
- GET /quizzes/jobs returns all recent jobs for current user from any browser/session
- Navbar JobsBadge polls /quizzes/jobs API every 4s (not localStorage)
- Shows all recent jobs with status badges; links to quiz when complete
- Badge visible even after extraction completes so you can always get back

Mobile navbar fix:
- .navbar .container height was overriding dropdown to 52px (clipping all links)
- Fixed by using .navbar-inner class for the header row only

CLI extract command:
  python manage.py list-sections [doc_id]   — list docs + sections with IDs
  python manage.py extract <section_id>     — inline blocking extraction
  python manage.py extract <section_id> --bg  — background via Celery
  python manage.py jobs                    — show all extraction jobs in Redis
  python manage.py jobs --user <email>     — filter by user

Quiz delete + question bank:
- When a quiz is deleted, questions that belong ONLY to that quiz are deleted
- Questions shared with other quizzes (via junction) are kept in the bank

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:30:47 +02:00
Daniel
96a1a259f0 Async extraction with live progress + chunked large PDFs
Extraction is now fully async via Celery — UI shows a live progress panel,
job continues even if page is closed. Large documents are processed in
50-page chunks to extract all questions (not just first ~50 pages).

Backend:
- app/tasks/quiz_tasks.py: new Celery task 'extract_quiz'
  - Writes step-by-step progress to Redis (extraction:steps:{job_id})
  - Splits large page ranges into 50-page chunks, processes each separately
  - Reports per-chunk results and running total
  - Falls back to synchronous if Celery/Redis unavailable
- POST /quizzes/ now returns {job_id, status:"pending"} immediately
- GET /quizzes/job/{job_id} polls progress: steps[], status, quiz_id on completion
- Celery task list updated to include quiz_tasks

Frontend (DocumentDetailPage):
- ExtractionProgress modal component: monospace step log, auto-scrolls, spinner
- Polls job status every 2 seconds via /quizzes/job/{job_id}
- "Open Quiz →" button appears when done
- "✕ closes — job continues in background" shown while running
- beforeunload warning when job is active (preventing accidental close)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 01:55:24 +02:00
Daniel
a5021a3241 UI overhaul, bug fixes, section delete, category at extraction
Themes / CSS:
- Warm Brown theme: parchment cream background, dark brown navbar, Georgia serif font
  beautiful and easy on eyes for reading medical content
- Default theme: slightly warmer bg, softer card shadows, 1200px container (less boxy)
- Navbar: sticky, height-fixed, overflow-x scroll on mobile (no wrapping), smaller text on mobile
- Mobile container: 14px padding for more space

Bug fixes:
- Login page: axios 401 interceptor no longer triggers window.location.href for auth endpoints
  (was causing immediate page reload before error message was visible)
- Dashboard quizzes stat now shows distinct quizzes attempted (not created by user)
- PostgreSQL email collation: ALTER users.email to COLLATE "C" in migration
  (prevents B-tree index corruption with en_US.utf8 locale causing user lookups to fail)
- get_current_user: verifies email on ALL protected endpoints, returns 403 with X-Unverified header
  Frontend auto-logs out and redirects to /login where resend option is shown

DocumentDetailPage:
- Delete Section button with confirmation on each section row
- Create question category inline from dropdown (+ New button → prompt)
- Question Bank Category dropdown now shows question count

Dashboard:
- total_quizzes stat counts distinct attempted quizzes (not user-created quizzes)

Backend:
- DELETE /documents/{id}/sections/{section_id} endpoint added

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 01:12:19 +02:00
Daniel
f2d15c3c57 Junction table for shared questions; verification blocking; semantic bank search; bug fixes
Question sharing (edits now propagate):
- quiz_question_links junction table: quizzes reference questions, not own copies
- Existing questions migrated to junction (idempotent ON CONFLICT DO NOTHING)
- from-bank and from-category create quiz by adding junction links (no copying)
- Editing a question via QuizEditPage or bank Edit modal updates the ONE record
  visible in all quizzes that reference it
- Delete from quiz: only deletes Question record if no other quiz references it
- quiz_service: fixed position counter (was using len() incorrectly, now enumerate)
- question.quiz_id renamed to source_quiz_id in Python (DB column still quiz_id)
- All attempts/quizzes/search code updated to use junction helper or source_quiz_id

Bug fixes:
- PostgreSQL en_US.utf8 collation B-tree index corruption on users.email
  Fixed by ALTERing column to COLLATE "C" in migration + immediate repair
- get_current_user now blocks unverified users on ALL endpoints (403 + X-Unverified header)
  api/client.js intercepts X-Unverified=true and auto-logs out to /login
- Search (quiz + bank): correct_answer and explanation now included in matching_questions
  so study modals work correctly
- Keyword search: order_by updated to use source_quiz_id
- Bulk category: changed from PATCH with body array to POST with Pydantic body
  (fixes null category_id removal)
- Question bank: edit button added, answer highlighting removed from list view
- QuestionBankPage: Keyword/Semantic/Hybrid search toggle
- Login: login error message stays visible (removed immediate reload)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:55:13 +02:00
Daniel
a7a5bdff62 Proper question bank system with question categories
Architecture:
- Questions are primary objects in a bank, tagged with question categories
- QuestionCategory is a separate taxonomy from QuizCategory (different concepts)
- Extraction → questions added to bank, optionally tagged to a question category
- Quizzes can be created from: individual question selection, question category, or PDF extraction

Backend:
- QuestionCategory model + question_categories table
- question_category_id column on questions table (nullable, SET NULL on delete)
- GET/POST/PATCH/DELETE /api/question-categories/
- POST /api/question-categories/{id}/create-quiz — create quiz from all questions in a category
- PATCH /api/questions/{id}/category — assign single question to category
- PATCH /api/questions/bulk-category — assign multiple questions at once
- GET /api/questions/bank?category_id=&uncategorized= — filter by category
- QuizCreate schema now accepts question_category_id for extraction
- quiz_service.create_quiz_from_section accepts question_category_id param

Frontend:
- DocumentDetailPage: Add to Bank Category dropdown in Quiz Settings (optional)
  Labels extracted questions with the selected category on creation
- QuestionBankPage: full rewrite
  - Category chips for filtering (All / Uncategorized / named categories)
  - Create category button inline
  - Checkbox multi-select with bulk category assignment
  - Create Quiz modal: choose from selected questions OR all from a category
  - Each question shows its category badge and quiz source
  - Study modal with instant answer feedback

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:34:39 +02:00
Daniel
9af63e67b5 Major: categories, question bank, security fixes, mobile layout, UX improvements
Security:
- Nginx: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, CSP, Referrer-Policy, Permissions-Policy headers
- Redis-backed login rate limiting (survives container restarts)
- Admin litellm/models endpoint: api_key moved from GET query param to POST body
- Nextcloud credentials moved from localStorage to sessionStorage (cleared on tab close)

UX / Layout:
- Login: unverified users see inline "Resend verification email" option
- QuizPage mobile: TTS Listen button on its own row below question text
- QuizPage mobile: Voice selector on its own row in header card (not squashed with timer)
- QuizEditPage: scroll position preserved after saving a question edit

Quiz Categories:
- New QuizCategory model + quiz_categories table
- category_id column added to quizzes table
- GET/POST/DELETE /api/categories endpoints
- Quizzes grouped by category in QuizzesPage; moderators can assign via 🏷 menu
- Uncategorized section shown when categories exist

Question Bank:
- GET /api/questions/bank — search all questions across quizzes
- POST /api/questions/from-bank — create new quiz from selected questions (copies, originals untouched)
- QuestionBankPage: search, checkbox select, study modal, create quiz form
- "Question Bank" link added to Navbar

Search:
- "View all N questions →" button expands to full question list
- Each question has a Study button opening in-place modal with study mode
- Summary view shows 2 questions per quiz with Study button

Extraction prompt:
- Stronger emphasis on correct_answer field with step-by-step letter → full text example
- Explicit instruction never to store just the letter

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:08:05 +02:00