Commit graph

84 commits

Author SHA1 Message Date
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
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
50ddcafd82 feat: an image bank you can actually use
The media API has existed since the image-libraries work with nothing to call
it, so images could be uploaded but never described, tagged, or found again. Two
screens close that.

/media browses the bank by library, searches by what an image shows, and edits
title, caption, alt text, tags and library in place. The id sits on every
thumbnail rather than appearing on hover, because the id is what a question
refers to and you should not have to go looking for it. An image with no caption
says so — captions are what the search index is built from, so an undescribed
image is one nobody will find, and that is worth saying on the card rather than
in documentation.

The question editor asked for a filename typed from memory, which meant keeping
a second tab open or guessing. It now opens a picker over the same bank, showing
caption and tags on every result: two chest films are identical at thumbnail
size and the caption is the only thing that tells them apart. An image uploaded
from the picker goes straight onto the question — coming here to add a picture
and then having to find it again is a step that exists for nobody's benefit. The
"Browse image bank" link pointed at /images, a route that does not exist.

Permissions follow the API rather than reimplementing it: library creation and
deletion stay with moderators, describing an image does not, and an upload goes
into the library being browsed because a non-moderator must name one.

177 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
2026-09-10 12:04:06 +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
df15a14983 docs: record the taxonomy work as done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:51:44 +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
e0dc3a5da5 docs: capture the UI feedback from 2026-09-10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG
2026-09-10 04:02:50 +02:00
Daniel
0b58b903d2 docs: mark version history and exam-scoped tags done; settle the link design
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:48:52 +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
17af66adce docs: sync backlog with what shipped today
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 20:11:20 +02:00
Daniel
145c1802c6 docs: mark recommendations, grants and runner header as delivered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:48:17 +02:00
Daniel
741ed9ba30 docs: mark quiz action menu and question manager as delivered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
2026-09-09 18:29:07 +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
6bb33ba97c docs: record deployed milestone 4 and release state 2026-09-07 17:48:08 +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
c605178cbc docs: record accepted quiz runner review 2026-09-07 04:22:03 +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
2cdfe71707 docs: record revamp execution authorization and defaults 2026-09-07 01:00:50 +02:00
Daniel
bdc2066e72 docs: track Orthobullets-inspired quiz revamp scope 2026-09-07 00:51:43 +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
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
04e1855452 Document Alembic workflow and advisory-lock startup fix 2026-04-14 05:44:48 +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