Measured first, by the ped-ai session, fifteen runs of five prompts with
the gateway cache bypassed. Retrieval was already deterministic:
identical shortlist and identical scores every time, and the citation
checker stripped none of the 45 markers written — invented citations are
not the problem here. Generation was the whole variance. At temperature
0.3 the same sources and the same prompt gave answers differing by
15-70% of their text; one differential swung between a 35-word uncited
paraphrase and a 180-word cited list.
So temperature 0 and a seed. Temperature 0 alone was not enough — three
runs still differed — and temperature 0 with a fixed seed came back
byte-identical. The seed is derived from the question, normalised for
case and spacing, so two people asking the same thing get the same
answer and a different question is not pinned to the same sample.
An empty reply is asked once more before it becomes a 502. One in
fifteen came back empty from a healthy model in 4.9 seconds — not a
refusal, not an error, just nothing.
A short query that finds almost nothing is retried against the nearest
article title. "kawasaki criteria" finds fourteen sources; "kawasaki
critera" found none — the lexical ranker cannot match a token that is in
no index, and the embedding of a misspelling is not near the embedding
of the word. Trigrams do not care: that typo scores 0.36 against
"Kawasaki disease" with the next article at 0.11, and the gap is what
makes it safe to act on. pg_trgm is created at startup beside vector,
with a migration for the record.
And an answer drawn from the library must cite it. Not a hallucination
guard — nothing was stripped in fifteen runs — but one answer used the
sources and cited none of them, which leaves the learner an assertion
and nowhere to check it.
Also, article drafts are weighted towards mechanism, in the wording the
ped-ai rewriter is using, so the two lanes read alike: why the body does
what it does, with features and management explained through it rather
than listed. Figure lines and cross-references survive a refine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two removals the user asked for.
No deck from a PDF section. A card should be written from an article —
text a person has read, edited and published — not from whatever
happened to be on pages 40-58 of a source document. POST /flashcards/
and the generate_flashcard_deck task are gone, with the Create Cards
button on the document page. What remains: POST /articles/{id}/ai-cards,
and POST /flashcards/manual for writing a deck by hand.
And no Nextcloud. It was a per-person cloud integration for a corpus one
person loads: a settings panel asking every educator for an app
password, a second tab on the upload page, and three endpoints. The
upload page now has one way in, which is the one anybody used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
GET /questions/detail/{id}/figures had no check at all, sitting next to
a detail route that has one. Any signed-in account could ask for any
question's figures by id and be handed its explanation images: the
paths, and the library record that now rides on them — whose titles run
to "Neonatal Herpes Simplex · Q874". No attempt required, and the answer
in the title. Found by walking today's surfaces as a real learner
account rather than reading the guards.
Same rule as the route beside it: the stem is readable in the bank, the
answer side belongs to whoever writes the question.
Also, refining an article no longer breaks the links into it. The refine
path replaces the whole section list and the model was never shown the
existing ids, so it invented fresh ones — silently breaking every
`[[95#id]]` pointing at a section, from another article, a question's
key point or a study plan's reading. The id travels in the heading now
and the prompt says to return it unchanged for any section kept. The
model is also told to leave existing `[[123|links]]` exactly as written
and never to invent one, because a guessed number points at nothing.
And the section strip is centred. Widening its box to 1600px let the
links spread but `flex: 1` on the strip — right for every other strip on
the site — filled the whole box with the links against its left edge:
measured at 1500px, they began 70px left of the page content while the
strip ran 180px past its right. Content-sized and centred now, still
scrolling when the links genuinely outrun the window. Diagnosis from the
ped-ai session; verified at 1500 and 1920 with no arrows at either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Measured across the 323 AI drafts in the bank before touching anything.
The prompt describes three readings of a topic and the second of them
was not happening:
short 326 sections 1 per article 1,808 chars 4 highlighted
long 2,112 sections 6.5 per article 721 chars
clinical 1,060 sections 531 chars
So the revision view — the thing meant to be tight lists of what a
candidate must know — came out as a single section two and a half times
longer than a full one, written as bullets that were paragraphs, and 4
of 326 carried the ==highlight== the reader has a renderer for. "Tight
lists, not prose" and "sparingly" are adjectives, and the model read
them as suggestions.
They are numbers now: 2 to 4 separate sections, each under 600
characters, bullets of at most 20 words, and one to three highlighted
facts per section with an example of what that looks like.
Two drafts written against the live model afterwards:
Kawasaki disease 3 short sections, 292 chars avg, 3/3 highlighted
Neonatal hypoglycaemia 3 short sections, 227 chars avg, 3/3 highlighted
and the reader draws nine key-point marks on the first of them.
Also, refining no longer loses a section's variant. The existing draft
went to the model as a flat list of `## Heading`, so it had to guess all
over again which sections were the bedside and which were the revision
view — a refine could quietly move one into the other. The variant
travels in the heading now and the prompt says to keep it.
Left alone deliberately: the 16,000-token ceiling (no truncation
failure in the logs to justify moving it) and temperature 0.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.
Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.
Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.
Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.
Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".
Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Reading shows nobody drafts, not even an admin. An educator's unfinished
work sat among the published shelf with a tag on it, which made Reading
and Editorial two views of one list and left you unsure which you were
looking at. The list is published-only for everybody now, and the tag
and its style are gone with it — the badge stays on an article's own
page, where a draft can still be opened.
And Editorial has its own URL. /editorial/articles/:id renders the same
page, but the crumb reads "Editorial" and goes back to the queue.
Opening an article from the queue used to land on Reading's address, so
the only way out was the top of the published library — you lost your
place in the queue to look at one draft. Drafting from the reading page
lands there too, because a new draft is editorial work from the moment
it exists.
References from PubMed are fields, not a sentence. Every other
reference on an article is {title, author, pages} and the reader reads
those keys, so the flat line the PubMed path wrote drew as six blank
rows under a References heading: the DKA draft cited six real papers
and appeared to cite none. A paper now fills journal, year and PMID
instead of pages, and the PMID is a link to the record. Rows written
before this pull themselves apart on the way out rather than being
rewritten in the database, so the drafts that already exist heal
themselves.
Repeat session has never worked. The dialog asked the bank for mode
"study" — the name of the route it lands on — and the bank has "timed"
and "learning", so every repeat came back 422 and the dialog reported
its own house message, "Could not build that session", because the
detail was a list rather than a string. Both fixed: the right mode, and
a server that says something is quoted rather than swallowed.
And the objective named in "your performance analysis for Pediatrics
Boards" opens the objective picker. It was a link to the account page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Four things a deck got wrong.
The category. Every deck written from an article is filed where the
article is filed — and the list drew all of them "Uncategorized",
because the router defines its own FlashcardDeckResponse that shadows
the one in schemas/, and that one has no category_id. So the field was
set on the row, returned by nothing, and an educator refiled by hand
what the system had already filed correctly. The shared schema was
imported by no module at all, so it is gone rather than left as a
second definition to read past next time.
The size. Fifteen cards is the per-chunk default, and an article is one
chunk however long it is — a ten-section piece and a two-paragraph stub
both asked for fifteen. Now roughly a card per 150 words, floored at 12
so a short article still makes a deck and capped at 30 so one call stays
inside the model's output.
The card. Set at list-item size inside a frame that fills the window, so
a two-line question sat in the middle of an acre of white. The face
scales with the window and stops at a comfortable measure; the back is
set smaller than the front, as prose rather than a headline.
And the contract snapshot, which still owed the jobs endpoint from the
last commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Card generation was failing every time.** The model configured for the
flashcard task — and for extraction and keyword — was `ds-deepseek-v4-flash`,
which the AI proxy no longer serves. All three now point at the v4.1 model that
does exist, and the dead rows are gone.
The reason was reachable only by reading the job record: the panel said "Card
generation failed." and nothing else, while the proxy's actual answer sat in a
field nothing displayed. A failed job now says which model is missing and where
to change it.
**The reader had no styles of its own.** Its appearance lived in
ArticlesPage.css, imported by the reading page — so an article rendered
correctly there and as unstyled boxes anywhere else it was used. The split pane
inside AI Mode is where that finally showed: section headings drew as bare
bordered rectangles. The reader imports what it needs now.
**Settings → Tools listed the question workbench and the taxonomy**, which are
question work and belong beside the questions. Both are on the question
manager's own bar now, the taxonomy's way back leads to Questions rather than
to a settings page nobody was on, and the workbench's does too.
Also: air under AI Mode's composer, which sat flush against the footer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.
- *The clinical library.* The indexed shelf the clinical assistant already
searches, over MCP on the internal network. Ported from ped-ai: sessions are
reused, a dead one is reopened once, and a library that cannot be reached
never fails the article — it just means the educator is writing without it,
and the progress line says so.
- *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the
two lessons that cost somebody an afternoon over there: PubMed ANDs every
term, so "bronchiolitis management in infants" can find nothing where
"bronchiolitis management" finds six — hence the query ladder — and three
esearch calls in a row will trip the rate limit, hence the spacing. The
reference list is written from the records rather than by the model, so every
line is a paper that exists with a PMID somebody can look up.
Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text
citations, in one draft.
**The card system, which turned out to be half-built:**
- There was no way to make a deck by hand, and no way to edit a card at all —
you could browse, view and delete. Both are there now, the editor taking
front, back and a picture.
- Filing, writing, sharing and deleting are all educator work now, behind one
named gate rather than four scattered checks. A learner studies.
- A deck generated from an article inherits that article's category instead of
landing in Uncategorized for somebody to file by hand.
- A link inside a card previewed instead of going. A card is a box a few lines
tall, often inside a flipping panel, and a hover card anchored in one is
clipped by it — so the link read as broken because clicking it did nothing.
Where there is no room to preview, the honest behaviour is to take you there.
**An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so
a drafted article was neither "generated, unread" nor anything else: the tile
counted it and there was nowhere to click. Drafts are stamped with the model
that wrote them, and there is now a plain Drafts queue that cannot be fallen
through.
**The sign-in code email** is laid out rather than written: the code is the
biggest thing on the screen, then which account it signs into, then a way back
to the page, then permission to ignore the whole thing.
Also: a back link out of a deck, in the same words as the rest of the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**"Draft with AI does nothing" was almost true.** The job worked — about forty
seconds, then a draft saved — but the panel closed onto a library that looked
unchanged, and the only progress it ever showed was the same "Drafting…" line.
The job now reports the article it made and the page goes straight into it, and
the wait is counted in seconds so it is visibly a wait rather than a hang.
**Editorial rows open the editor.** It is the queue of things to *do* to an
article; rows that opened the reader made an editor press Edit on every one.
**No Contents button on a phone.** The header's menu opens the contents, and a
second door in the prose is the same mistake the player had. An open drawer now
has a backdrop that closes it, and choosing a section closes it too — it sits
over the prose it just scrolled to.
**References wrap.** A source title is often a filename with underscores and no
spaces to break at, so the list ran off the side of the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Caching.** `/uploads` answered `private, no-store` for everything, so a page
of forty thumbnails re-fetched forty images every time it was drawn. A
derivative may now be kept for a week by the browser that asked for it —
`private`, never a shared cache, because a shared cache in front of
access-controlled images is how one learner is served another's figure. It is
safe to keep because it cannot change: `thumbs/256/<key>` is made once from an
immutable original. Originals still say no-store.
**The embedding model is env-only.** Every vector in the database came from it,
and vectors from different models are not comparable — change it and search
returns noise until 3,000 questions, 334 articles and every card have been
re-embedded. The settings page now shows it as text with Test and Regenerate
beside it, and the API refuses a change rather than ignoring one, naming
`LITELLM_EMBEDDING_MODEL` in the refusal.
**The figure audit retries and gives up.** Its second run met a proxy outage and
reported all 327 figures unreadable, having changed nothing but spent the time.
Three tries each with backoff now, and it aborts after twelve consecutive
failures: a run that says "everything is unreadable" has told you nothing.
**`.env.example` is complete.** It listed 23 of the 53 settings; it now lists
all of them, grouped, each with the default it falls back to and — where it
matters — what happens if it is wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The rule was in my head and in a commit message; it needed to be in the task.
The first run detached sixteen good figures — a tick on a leaf against a July
fever, fungal hyphae against a scaly rash, a recessed chin in a two-week-old's
notes, an ECG on a tachypnoeic neonate — because a judgement about relevance was
allowed to act on a photograph, and the connection between a clinical figure and
its question is often indirect.
Two changes. The prompt now separates the two kinds of figure and says how each
is judged: a clinical one — photograph, radiograph, ultrasound, ECG, fundoscopy,
otoscopy, microscopy — is refused only when it is anatomically impossible, and a
table, citation, chart or nomogram is refused whenever it is about another
subject. And the model must now say which kind it is looking at, so the code can
enforce it: "no" on anything clinical becomes "unsure", which flags it for a
person instead of detaching it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
An HPV vaccination question was carrying a table of craniofacial reconstruction
by age. Extraction takes figures off the page a question was printed on, which
is usually right and occasionally catastrophically wrong — and the mistake is
invisible to a text review, because nothing about the stem or the filename says
the picture is about something else. My earlier pass read stems and answers and
could not have caught it.
So this looks. Every figure goes to a vision model with its question and is
asked whether it belongs: strict about "no", generous about "unsure", because
detaching a figure a question needs is worse than leaving a stray one attached
for somebody to notice. A clear mismatch is detached and the image stays in the
bank with a note saying what happened, so nothing is lost and the decision can
be checked.
Ran over all 341: **30 detached, 2 unsure, 309 kept.** Among the thirty — the
rule of nines on a hepatitis B newborn, a molluscum table on a collapsed
athlete, a textbook citation page on a drowning, and the craniofacial table
that started this.
It also describes each figure while it is looking at it, and keeps the
description. 435 assets said "Figure from question #1206", which is a filename
with extra steps and is why the image bank could not be searched; 335 now say
what they show, and none of the placeholders remain.
The model is named in the task rather than read from the admin's `tool`
setting: this is a one-off audit, and it should not quietly become the thing
that decides what the tool model is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
A 2,000px radiograph written into an article rendered at whatever width it
happened to be — a wall of greyscale in the middle of a sentence, four
megabytes to draw it, and no way to look at it properly. Every image in prose
is now a 256px thumbnail with the author's label under it, and a click gives it
the screen.
The viewer puts what is known about the figure beside it: its title, what it
shows, and where it came from. `media_assets` gains `source` and `source_url`
for that — a citation belongs to the file, because the same figure used in
three articles is cited the same way in all three, and a licence that turns out
to be wrong is one row to fix rather than three paragraphs to find. Asked for
when the figure is opened, not when the page is drawn.
And `overlay`: the regions an educator has marked, as vector shapes in
normalised coordinates on the unit square, so one drawing is correct in a
thumbnail, in the viewer and on a projector. Off until the learner turns it on
— marks shown before they have looked answer the question for them. Vectors
rather than a second burnt-in picture, for four reasons written down in
docs/image-overlays.md. The tool that draws them is next; this is the storage,
the contract and the reader's half.
On a narrow screen the description stacks above the image rather than beside
it, where it can be read before scrolling to the picture.
Also here: `classify_question_difficulty`, which labelled all 2,924 questions
in batches of twenty-five against a written rubric — 622 easy, 1,634 medium,
668 hard, no failed batches. The column had been NULL on every row since it
existed. Migration l2c3d4e5f6a7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"Questions filed there later are not added" was the honest description of what
the previous commit built, and it was the wrong thing to build. "The Cardiology
article covers the Cardiology questions" is a standing statement about the
material, not a snapshot of who happened to be filed where on the afternoon
somebody pressed a button — and a copy stops being true the first time a
question is added, silently, with nothing on any screen to say so.
So the claim is now stored, and it is what writes the links:
* `question_article_links` is still the **only** table anything reads. No count,
no QBank button, no mirror panel on a question, no AI Mode boost learns a
second question to ask.
* `article_topic_claims` records *why* some of those rows exist, and is the one
place that makes them — when the claim is staked, when a question is filed
into the category (single, bulk, or on create), and on a half-hourly sweep
that catches whatever bypassed both.
A link made this way is an ordinary row and can still be deleted by hand; a
sweep puts it back, which is the honest consequence of a standing claim.
Dropping the claim is how you stop it, and the panel now lists what an article
follows with two ways out — stop following and keep the links, or stop and
remove them.
Migration k1b2c3d4e5f6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Two things, both about the AI draft nobody could tell was working.
The prompt never mentioned that a section belongs to one of three readings, so
every generated article was one long view and the other two tabs stayed empty
— on 300-odd articles. It now describes what each view is for, asks for the
high-yield one as tight lists with `==key points==` marked, says to omit the
clinical view for a topic with no bedside, and says explicitly not to
categorise or link the article, because those are an educator's judgements.
And an unknown or missing variant is read as "long", which is what every
section written before this already is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Three things landed together; the message names all of them, because a commit
that mentions one is a commit nobody finds the other two in.
**Figures.** Thirty-four JPEG 2000 files — 21 on questions, the rest unattached
in the media library — are WebP now, with `questions.image_path`,
`questions.explanation_image_path` and `media_assets.path` repointed together.
Serving already converted them on the way out, so nothing was broken; this
removes the step and makes what is stored the same thing that is served. The
originals stay: they are the only copy of what came out of the PDF, they cost a
few megabytes between them, and a conversion nobody can undo is not one to run
against a live bank. Paths are found by what the columns say rather than by
listing a bucket, because three tables record them and updating two would be
worse than none.
**The openai SDK is gone.** Ten call sites — one more than the map said, the
Celery article drafter — every one of them a POST with a JSON body, and not one
reading usage, cost, tool calls or logprobs. Every other call to the same proxy
was already plain httpx: embeddings, the ChromaDB embedding function, speech
both ways, model discovery, the vision probe. So this deletes an abstraction
rather than swapping one for another, and leaves one HTTP client instead of
two. `chat()` and `achat()` return the message content; a `ProxyError` carries
the status and the first 500 characters of the body, which is where the proxy
explains itself.
Behaviour is preserved deliberately, including a 600-second fallback timeout
for the four call sites that were running on the SDK's ten-minute default.
Lowering that is a real change and belongs in its own commit.
Proved against the live proxy on both services rather than only against mocks:
a completion, an async completion, a real 400 the vision probe still classifies
as a refusal, 407 models read from the catalogue, and a word read off an image.
**Voice.** A chosen voice is honoured whatever serves it. The prefix check only
accepted a locally served one, so a site adding a hosted voice would offer it
in Settings, save the learner's choice, and then quietly read every question in
the default voice. The list has always come from the database — adding a voice
is a row in Settings → AI models, never a code change.
And the sign-in page stops offering a locked door: `signup-policy` reports
whether registration is open at all, and the Sign up link goes when it is not.
The switch existed and the only way to discover it was to fill the form in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Retrieval could not say "nothing". `hybrid_ids` fuses two rankers by reciprocal
rank and throws the distances away, and it returns the union — so the shortlist
was never empty, the "nothing matches" branch never fired, and a question about
photosynthesis came back with six paediatric sources and an instruction to
answer only from them.
So the fix is not more scenarios in the prompt. It is one calibrated number,
and three short prompts chosen by it in code. Asking a model to work out which
situation it is in is the part that does not work, and it is also the part that
makes prompts long.
Measured against this corpus with the bodies now embedded — eight clearly
on-topic questions and eight clearly off-topic:
off-topic 0.339 – 0.499 the French revolution … photosynthesis
on-topic 0.586 – 0.740 what causes croup … posterior urethral valves
The thresholds sit in the gap. They are deliberately not the retrieval floor:
that one decides what is worth putting in a list, where a weak hit costs a
reader a glance. These decide whether an answer claims to come from the
library, and a wrong claim costs them their trust in every other answer.
Above 0.55 the answer is sourced and cited, as before. Between 0.50 and 0.55 it
says nothing covers this directly, names what the closest material is, and
marks which parts came from where. Below, it says so in one line and then helps
anyway from general knowledge, citing nothing — refusing outright reads as a
broken assistant rather than a careful one, and the shortlist is not handed to
a model that has just been told the library does not cover the question.
An unmeasurable closeness is not a low one. No vector database or a downed
encoder returns None, and retrieval still found its rows by other means, so
those are still cited; dropping every citation because the ruler is missing
would be the worse failure.
Also: only published articles are indexed now. A draft is unfinished by
definition and has no business in a search result or in that shortlist. The
index follows publication both ways, and the fifteen-minute sweeper drops rows
whose article has been deleted or unpublished — an article that is never edited
again would otherwise keep its rows for good.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Prepared sessions.** Most of this existed: unanswered first, weakest topic
next, wrong-before-right after that, all scaled by what share of the real paper
each topic carries. What it could not do was change with time, say anything
about itself, or be reached without filling in a form.
Evidence now decays on a thirty-day half-life. Exponential rather than a fixed
window because memory has a slope, not a cliff — under a window, 29 days counts
fully and 31 counts for nothing — and because it is memoryless, so an answer's
weight does not shift when unrelated questions are answered, which is what lets
the preview stay a valid forecast. Spring is worth an eighth of last week. Two
things decay: a question's recall probability, drifting towards even rather
than past it, so an old right answer becomes eligible rather than wrong; and a
topic's accuracy, against a prior of two "no idea" answers, which fixes "right
once, known forever".
Strict unanswered-first meant that on a bank of 2,900 nothing was ever
recycled — spaced repetition existed and was unreachable. Review now takes up
to two fifths of a session. And the damping that spread the picks across topics
was applied only to seen material, so a learner with no history was handed the
heaviest domain entire instead of a spread; that was live.
The plan is the product. It is computed, shown, and then the session is built
from that plan's own ids and the plan returned with it, so the two cannot
differ; every figure in it is a tally over the chosen questions rather than a
forecast. No model touches the ranking — a learner asking "why these twenty"
has to get the same answer twice.
**Vision.** The proxy's own `/model/info` says which models can see, so nothing
is hard-coded: 77 report yes, 11 no, and 328 say nothing at all, which means
absent rather than incapable — so those are asked once with an 8px PNG and the
refusal cached. The deployment's main model turns out not to see, and questions
carry figures the learner is looking at, so the tutor was answering about an
image it had never been shown. It routes to a configured tool model now, folds
the description back in as text saying plainly where it came from, and caches
on the bytes because the same figure is re-sent every turn.
Also fixed on the way: `article` was missing from the admin's task list, so
article drafting always ran on the fallback model whatever an administrator
chose; and `.jpx` stem images were sent as JPEG because `mimetypes` guesses
that from the name, so the provider rejected them two hops later.
An administrator must pick a tool model in Settings → AI models. Until then the
tutor says a figure exists that nothing could read, rather than describing one
it cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
**Linking.** A question could be tied to an article only from the article, by
typing the question's number into a box — so opening a question you had just
linked showed no sign of the link, and there was no control to add one. Both
ends now search: find the article by title from the question, find the question
by stem from the article, pick which section of the article the link lands on,
and see what is already linked. One shared finder, so the two ends of one
relationship cannot describe it differently. `GET /questions/{id}/articles`
mirrors the endpoint that already existed the other way, and `GET
/articles/linked` is retired — it answered this question by shipping the whole
prose of every linked article to the quiz player for a list of titles.
"Practise this topic" is a reader's control and no longer appears on an editing
screen.
**The player.** The rail was a bordered card floating in the page with a
scrollbar of its own, so a session had two scrollbars side by side and a
collapse handle tucked inside the card's padding. It is a column now: flush,
full height, its own background rather than its own border, the handle on the
boundary it moves, and a progress bar under the count. The bar at the foot is
the bottom edge of the window — three flush segments, no gaps, no pills —
because Exit as a small grey pill beside a large blue Next made leaving look
like the accident.
Study mode no longer asks whether you are sure. Leaving suspends: every answer
is saved, nothing is graded, and it is waiting where you left it — so the
dialog asked permission for something reversible, under a name for something
that does not happen. An exam still asks once, because a block has a clock, and
it now says what it is: "Leave this block?", not "End Session".
Options are lettered. The explanations already are — a stem extracted from a
board PDF says "Preferred Response: E" — so numbering them 1 to 5 left the
reader translating between two labellings of the same five lines. The tutor is
told the same letters, and the answer key is marked against its own option and
declared authoritative, so a model that would have answered differently cannot
tell a student the marked answer is wrong.
"Preferred response" and "Source page 518" are gone: the first labelled a block
that is obviously the answer, the second named a page of a book the learner
does not have. The clocks moved out of a grey strip across the explanation,
where they read as part of the answer, to the foot of the rail with everything
else about the session.
**AI Mode.** Sources are headed and counted at the end, where evidence belongs,
with the practise button after them rather than above. That button appears only
when there is something to build from and says what it will build — it used to
sit under "how can I help you today?" offering to make a session out of
nothing. A cited question opens in place: `/questions/:id` is the editor, so
following one dropped a learner into a form for changing the question they had
just been told about. And a session built from a chat is named like every other
session, rather than after the chat — asking "hi" produced "hi — practice".
Also: two test questions with raw `<p> </p>` in their stems were live in
the bank; retired. And 36 article summaries were written as a table of contents
with the colons filed off — "Peanut allergy prevention and management: LEAP
guidelines by risk tier, risk stratification, and anaphylaxis treatment" — every
noun phrase sounding informative and none of them saying anything. Rewritten as
claims, with the rule added to the prompt that produced them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
claims to hold them
The scaffolding is down. 203 subject, 2,275 disease and 4,281 keyword
tags, and 25,356 links, deleted — backed up first to a 1.9MB JSON of
replayable rows, because "we can always put it back" should be true
rather than said. The 16 system rows stay: categories point at them.
With them go the things that only existed to feed them — the
classify_questions task, its snapshot helpers, POST /tags/classify and
its status poll — and the three Taxonomy tabs that would now always read
zero. A tab showing 0 forever teaches people the page is broken.
The organ-system filter in the session builder moved onto categories with
the rest, including everything beneath a matched topic, so it groups the
way the analysis does.
Registration: `settings:registration_enabled` was set to false, and there
was no switch anywhere on the site to set it back. The API had always
accepted it; the Site policy page had never shown it. So the site could
be closed to new members with the admin looking at three switches, all
correct, and no way to see the one that was actually refusing them. It is
now the first switch on that page, and says plainly that the ones below
it have nothing to act on while it is off. The SSO-only flag was hidden
the same way and is shown when SSO is configured.
Deleting a topic no longer silently unfiles its questions. It asks where
they go, and says how many are waiting, unless the topic is empty — the
same rule promotion now follows. Its extra category links move too,
minus any that would duplicate a pair the destination already has.
Back links: Trash, Extraction jobs, Taxonomy and the Handbook had none at
all, and Access pointed at the wrong section. They are one component now,
each returning one step to the section it was opened from. Editorial has
its own entry in the section bar, so its Tools card is gone rather than
being a second door to the same room.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
The phone had a dot grid dropped under the top bar — a different thing
in a different place doing the rail's job worse. It is a drawer holding
the same rail the desktop has, with the site's own menu on the other
tab, because the alternative is a second hamburger elsewhere for the
same purpose. The dot grid and its styles are gone.
And the extraction pipeline was run end to end against a three-question
PDF rather than reasoned about. It works: three questions, stems,
options, correct answers and explanations, landing in a draft batch and
not in the bank. But the run found a real bug on the way.
A document's text is read from the search index, not from the file. When
that index is missing — never processed, or lost to a restart — every
page is skipped and the job fails with "the AI could not find questions
with correct answers in this page range". That is the wrong diagnosis,
and it sends people to change the model, the prompt and the page range,
none of which is the problem. The two failures are now counted apart and
named apart: no stored text says so and says to re-process; a model that
found nothing says that instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
Extraction wrote straight into `questions`, so a machine's first attempt
took a permanent id the moment it was produced. Ids come from a sequence
and are never reissued: every rejected draft burned one, and every draft
that needed fixing was sitting in the bank while it was being fixed.
A run now lands in a batch of drafts with their own table and their own
sequence. They are read, corrected and decided there, and `accept` is
the only place a Question is created — a copy rather than a translation,
because every field a draft holds is a field a question has, so nothing
is lost at the moment of acceptance.
Accepting is all or nothing, and everything is checked before anything
is created: a call that reports failure must not leave questions behind
from the drafts it got through first. My own test caught that — the
first question existed before the second draft was refused.
Readiness is reported for every draft rather than only on the attempt to
accept it, so a reviewer sees what needs work before opening anything.
A decided draft keeps its row and records what it became, so a batch
reads as a history of what was decided rather than emptying as it is
worked through. An acceptance cannot be undone from here: the question
exists, and deciding twice would make a second one.
No embeddings for drafts. A vector is for finding a question in the
bank, and a draft is not in the bank.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
"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
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
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
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.
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.
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.
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>
- Fix: flashcard task used wrong Redis key prefix (job: vs extraction:)
causing progress to stay on "pending" after completion
- Flashcards are now user-scoped: each user sees only their own decks
- Soft-delete decks: DELETE moves to trash, ?permanent=true to destroy
- Trash tab on flashcards page: restore or permanently delete decks
- Delete individual cards with inline confirmation in browse view
- Card edit/delete now allowed for deck owner (not just moderators)
- ExtractionProgress label prop: shows "Generating Flashcards" not
"Extracting Questions" for flashcard jobs
- Added deleted_at column to flashcard_decks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New feature: generate flashcards from PDF sections using AI, completely
separate from the existing quiz system.
Backend:
- FlashcardDeck + Flashcard models with cascade deletes
- flashcard_tag_links table for tag classification (reuses question_tags)
- /api/flashcards/ router: CRUD for decks, browse/search cards, tag filtering
- generate_flashcard_deck Celery task with chunked processing + progress
- FLASHCARD_PROMPT in extraction_modes.py (15 cards per chunk)
- "flashcard" added to admin model task types
Frontend:
- FlashcardsPage: deck grid + card browser with search/filter
- FlashcardStudyPage: flip cards, mark known/review, keyboard nav,
shuffle, progress bar, completion screen
- DocumentDetailPage: "Create Flashcards" button alongside "Extract Quiz"
- Navbar: Flashcards link
- AdminPage: flashcard in model task dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- teach.py: use _proxy_model() + pass api_key/api_base from settings (fixes LiteLLM provider error for openrouter/bedrock models)
- teach.py: accept model_id in ChatRequest so frontend can select model
- main.py: remove titan-embed-v2 from general seed, auto-delete legacy entry on startup
- main.py: kill stale idle-in-transaction DB connections at startup to prevent DDL lock hangs
- main.py: set lock_timeout=10s on DDL connection as fast-fail safety net
- TeachChat.jsx: fetch /teach/models, show selector dropdown in header when >1 model available
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Embedding:
- Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env
- Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation)
- Passes dimensions=1024 to proxy; Redis setting overrides env var
- Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim)
- Test button in admin UI to verify model works
- Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names)
Polly:
- Global enable/disable toggle in Admin → More settings (stored in Redis)
- /tts/voices filters out polly/* when disabled
- /tts/speak rejects polly requests when disabled
Job cancellation:
- POST /quizzes/job/{job_id}/cancel endpoint
- Cancel button on JobsPage for running jobs
- Celery task checks Redis status at each chunk boundary and exits cleanly
- Fixes DB lock on restart caused by cancelled jobs leaving open transactions
Admin UI:
- Settings tab renamed to "More" (heading: More Settings)
- Model row overflow fixed (minWidth: 0 + ellipsis on model_id)
- Embedding model search shows all proxy models (no auto-filter by "embed")
- Navbar correctly excludes cancelled/failed jobs from "extracting" count
README:
- Added Rebuild & Restart section with commands
- Updated embedding model reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>