**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.
**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.
**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.
It found four things in its first hour:
- **A fresh deploy could not start.** `create_all()` ran before
`CREATE EXTENSION vector`, so any database that had never had pgvector
installed died on the first table with a vector column. Invisible here
because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
Media in the library is nobody's to read by default, and nothing made an
exception for a drawing an article actually shows — so every illustration
added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
nginx's address for every request, so ten bad passwords from anybody locked
out everybody, and no log line could say who. nginx now takes the real
address from the proxy and overwrites the header on the way in; uvicorn runs
with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
820px in the stylesheet. Between them the menu button claimed the contents
drawer and then toggled a class on a rail that was still in the layout: the
contents did not open and the site menu did not either. The button was dead
on every tablet.
And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.
The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.
CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Asked for: no tutor on a question outside a session. The tutor is handed the
correct answer and told it may explain it, so it is answer-side content — and
once that rule is written down, the same rule catches two bigger holes:
- `GET /questions/bank` returned `correct_answer`, `explanation`,
`option_explanations`, `key_points` and `attending_tip` for every question in
the bank, to any signed-in learner. It is the question manager's listing, but
nothing stopped anyone calling it: the whole answer key, one request away
from the questions it answers. Stems are still listed to everyone; the answer
side now goes only to whoever writes that question.
- The explanation image behind a question was readable by the same rule, with
no attempt behind it.
"Whoever writes it" is one function now — `may_edit_question` — and it means
moderation, authorship, or an editorial grant that reaches where the question
is filed. Everyone else earns the answer by sitting the question, which is what
an attempt is. The bank browse, the search, the session and the review are all
unchanged; the frontend already sends `attempt_id` everywhere it shows an
answer.
The question manager was reachable by a learner with no grant, and would now
load as a bank of stems with every answer field blanked — a broken page rather
than a door that is not theirs. It says so instead.
Also, while looking at where cards surface: the answer review showed neither
the topic reading nor the cards written against a question, though the player
has shown both under the answer for a while — and the review is the one place
a learner goes through everything they got wrong. The list form of that
component fetched its cards and then dropped them on the floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.
Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.
`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.
The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.
Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Four things that share a spine, so they arrive together.
**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.
Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.
**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.
**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.
The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.
Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Access lived in three screens over two tables: category grants in the
question manager, media-library grants in the image bank, and nothing at
all for articles. Nobody could see what one person actually held.
/access is one surface over the same tables. A person on the left,
everything they have on the right. A granted branch shows its children
as covered rather than as separately tickable — a checkbox that changes
nothing is where a permissions screen starts lying — and the count of
categories a grant actually reaches is stated, not implied.
"Everything" is the moderator role, and the page says so instead of
inventing a wildcard grant that would silently mean the same thing and
be impossible to audit. While it is on, the branches below are hidden,
because they no longer apply. Nobody can change their own access.
The gap this closes: an educator granted a branch could edit its
questions but not the articles filed under it — articles were
moderator-or-author only. An article is filed under a category, so a
grant over that branch now covers its reading too. No new table: the
inheritance that category grants already had does the work.
Backend 242/242, frontend 284/284.
Also: the split-view test now focuses the link rather than hovering it.
Hover starts a 350ms timer; focus reveals at once, because the component
does not make a keyboard reader wait. That takes the wall clock out of a
test about the split view. Earlier failures were it losing CPU to the
backend suite running alongside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The linking was the gap
The marker system was built weeks ago — resolves by id, survives a rename, shows
a preview on hover — and not one of 333 articles used it. Every article was
written in isolation, so a piece on croup named stridor and epiglottitis and
offered no way to reach either. `scripts/link_articles.py` reads what is written
and links it: 3,718 cross-references across 307 articles, by id, so a later
rename cannot break them.
Conservative on purpose, because a wrong link is worse than a missing one: only
the first mention in a section, whole words, longest title first so "Otitis media
with effusion" beats "Otitis media", never inside an existing link, marker,
heading, code span or table, and never an article to itself.
That exposed a second thing: the reading view had its own Markdown pipeline with
its own cross-reference regex, and it only understood the old slug form. It would
have printed every one of those 3,718 links as literal brackets. Article prose
now goes through the same renderer as the rest of the site.
Short and Clinical looked empty
Both are usually a single section, and everything starts collapsed, so the tab
showed one heading over blank space. A view of one section is not a contents
page; it opens.
Removed
Quiz reminders — emailed nudges to retake anything under 75%, with a scheduler
that existed solely to send them: the model, the service, the scheduler, the
email, the table. Article comments. The dashboard's in-progress list and its
stat cards, both of which the analysis page now answers better.
One mistake worth recording: the first pass at removing the reminder cleanup used
a regex that took 109 lines with it, including an unrelated endpoint. The test
suite caught it (`/attempts/quiz/{id}/in-progress` returning 404 instead of 403),
and the file was restored and edited by exact match instead.
208 backend, 249 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
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
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
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.
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.
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.
- 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.
- 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>
- 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>
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>
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>
- FastAPI backend with JWT auth, roles (admin/moderator/user)
- PDF upload (up to 500MB) with streaming, PyMuPDF text extraction
- ChromaDB vectorization per page with metadata
- LiteLLM AI question extraction from PDF (not generation)
- Image extraction from PDF pages, graceful fallback
- Quiz modes: timed (countdown timer) + learning (answers shown inline)
- Page-by-page question navigation with dot navigator
- TTS endpoint using LiteLLM (Google Vertex / OpenAI voices)
- Admin dashboard: AI model management per task, user role management
- Moderator role: upload PDFs, create sections, generate quizzes
- Spaced repetition reminders via SMTP email (SM-2 intervals)
- APScheduler daily reminder jobs
- Celery + Redis for background PDF processing
- React frontend with all pages
- Docker Compose deployment (nginx + backend + celery + redis)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>