Commit graph

20 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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