Repository navigation
SAT Question Pipeline
How questions get into the platform, and how the adaptive engine decides what to serve.
Questions come from the College Board SAT Suite Question Bank — the official practice-question
export. Each exported PDF block carries: Question ID, Assessment, Test, Domain, Skill,
Difficulty (Easy/Medium/Hard), the question text, four choices, the correct letter, and a rationale.
Three assessments, six banks (all static JSON under SAT-Questions/, fetched directly by the
browser):
| Assessment | English bank | Math bank |
|---|---|---|
| SAT | SAT-English-Questions.json |
SAT-Math-Questions.json |
| PSAT/NMSQT & PSAT 10 | PSAT-English-Questions.json |
PSAT-Math-Questions.json |
| PSAT 8/9 | PSAT89-English-Questions.json |
PSAT89-Math-Questions.json |
Each record: { ID, Question, A, B, C, D, Answer, Rationale, Difficulty, Test, Assessment, Domain, Skill, image?, choiceSprite? }.
Both subjects are shown to students as images cropped from the source PDF, not as the extracted
text: image is the question area, and choiceSprite packs the four answer choices into one PNG:
"choiceSprite": { "src": "…/choices/<ID>.png", "size": [W, H],
"A": [y, w, h], "B": [y, w, h], "C": [y, w, h], "D": [y, w, h] }| Math | English | |
|---|---|---|
| Question image | math-images/<ID>.png |
english-images/<ID>.png (+ imageSize: [W, H]) |
| Choice sprite | math-images/choices/<ID>.png |
english-images/choices/<ID>.png |
| Text fields | OCR — often wrong; not shown | exact text layer — used as alt text and as the fallback if a question has no crop |
Images keep what text extraction loses: tables, charts, underlined sentences the question refers to, superscripts (13C), and italics.
The R&W PDFs have a real text layer, so this uses exact text positions from PyMuPDF — no OCR. It rebuilds each English bank from scratch (text, metadata, and images in one pass):
| Bank | PDF (in SAT-Questions/ or SAT-Questions/.SAT-Question-Bank/, gitignored) |
|---|---|
SAT-English-Questions.json |
SAT-English-Questions.pdf |
PSAT-English-Questions.json |
PSAT-10-English-Questions.pdf |
PSAT89-English-Questions.json |
PSAT-8-9-English-Questions.pdf |
- A question starts on each page headed
Question ID: <id>and runs to the next one. - The metadata table is read by column position, so wrapped cells (
Standard English/Conventions,Text Structure and/Purpose) come out whole.Cross-textis normalised toCross-Text. - Headings (
Question,Answer,Correct Answer: X,Rationale) are recognised by their font, which differs from body text; theA.–D.markers are body lines at the left margin betweenAnswerandCorrect Answer. (Continuation lines are indented, so an italic name like A. mexicanus wrapping onto a new line isn't mistaken for a marker.) - Crops are rendered at 150 DPI, trimmed to their ink, and stitched across page breaks. Plain text is saved as 4-level greyscale; crops with shading or charts keep 16 greys (32 colours if coloured).
- Text is extracted with
TEXT_INHIBIT_SPACES(otherwise the PDF's kerning splits words:repor ted). Rationale paragraphs are separated by a blank line (\n\n). - Questions it can't parse are left out and listed at the end of the run. As of the September 2026
export that's 4 questions: three copies of one question whose PDF is missing the
D.marker (e3bbf2bf,1a97fb49,7ffce84a) and one page printed at a different scale (fb17d540).
pip install pymupdf pillow numpy
python extract_english.py # all three banks (~5 min, ~150 MB of images)
python extract_english.py --bank sat --ids f1bfbed3,de55ec71 --debug out/ # re-do a few, merged in
python extract_english.py --text-only # text/metadata only, keep existing image fieldsIt replaces the old extract_questions.py (pdfplumber, line-based), which is kept for reference but
should not be used — see English bank rebuild.
The math choices are cropped into choiceSprites the same way; OCR is used only to locate the
Answer / Correct Answer landmarks. See the script's docstring.
The Math PDFs have no text layer — every page is vector paths. So:
- Each page is rasterized at 200 DPI with PyMuPDF (
fitz) and run through Tesseract OCR (pytesseract,--psm 3). - Same block/metadata/choice parsing as the English script, but with OCR-tolerant regexes (colons
read as pipes, stray spaces in keywords, lowercase embedded choice letters like
c./d.). - Domain and Skill strings are fuzzy-matched (
difflib.get_close_matches) to a canonical list per domain, so OCR noise still lands on the right label. -
Student-Produced-Response (free-response, numeric answer, no ABCD) questions are detected and
skipped — listed in
math_spr.txt. - A merged-choices post-process splits
"60 c. 700"intoC: 700when OCR runs choices together. - Flags:
image-only-question,visual-keyword,merged-choices,incomplete-choices,missing-metadata,embedded-image. Image-needing flags go tomath_flagged.txtwith page numbers.
Runs after the two above, using SAT-Math-Questions.json + math_flagged.txt. Per flagged question:
- If the PDF page has an embedded raster image, save the largest one.
- Otherwise (pure vector chart), use OCR word bounding boxes to find the
Answerheading, then crop from near the top of the page down to just above it — capturing the question text and any figure. Falls back to the top 55% of the page.
Saves math-images/<ID>.png and adds "image": … to the JSON in place.
Backfills Domain/Skill on questions the parsers couldn't tag, by keyword-matching the combined
question + rationale text against per-domain pattern sets. Overwrites the file in place.
Targeted one-offs applied to specific banks: patch_ocr_choices.py, patch_merged_choices.py,
patch_math_flags.py, patch_assessment.py. Not part of the normal flow — re-run only when
re-extracting.
pip install pymupdf pytesseract pillow numpy # + a Tesseract binary for Math
python extract_english.py
python extract_math_questions.py <math.pdf> SAT-Math-Questions.json --assessment SAT
python extract_math_images.py --pdf <math.pdf> --json SAT-Math-Questions.json --flagged math_flagged.txt
python classify_domains.py SAT-Math-Questions.json
python extract_math_choices.pyjs/SATmain.js / js/SATmathmain.js:
- Resolve
assessmentTypefrom the selected class (daily-progress?class_id=), defaultsat. -
fetchthe matching bank. - Filter out
question_reports-suspended IDs (GET /api/questions/suspended). - Bucket by domain into
questionTypes[0..3]. - On a class switch, drop the cache (
questionTypes = null) and reload — a different class may use a different bank.
Given a { domainIdx, skill, difficulty } from /next, pickQuestion tries exact skill+difficulty,
then any difficulty for that skill, then difficulty only, then anything in the domain pool.
Rendering (English). buildQuestion shows image in the question card and one sprite slice per
choice (js/choiceSprite.js, shared with math); a question without them falls back to its text.
The English quiz column is widened to 1040px (.quiz-area--wide). Crops are displayed at 0.8 CSS px
per crop px (9pt source text ≈ 15px); when the question's widest crop doesn't fit, every crop of that
question shrinks by the same factor (--crop-scale, recomputed on resize) so the passage and the
choices stay the same size. On a phone-width screen the text becomes very small.
GET /api/sat/next (and /api/sat-math/next). Constants: ACCURACY_THRESHOLD = 0.70,
MIN_ATTEMPTS = 5, difficulties ['Easy','Medium','Hard'].
-
Allowed domains — from the one class in
?class_id=(sat_english_domains/sat_math_domainsonclasses, CSV of 0–3). Noclass_id, orNULLcolumn ⇒ all four. If the resolved set is empty, return a random allowed domain at Easy. -
Recency window — a SQL
ROW_NUMBER() OVER (PARTITION BY domain_idx, skill, difficulty ORDER BY submitted_at DESC)keeps the last 25 attempts per(domain, skill, difficulty)for thisuser_key. Global — not filtered byclass_id; a student's skill is the same person in every class. -
Accuracy map — aggregate those rows into
{ "d|skill|diff": { attempts, correct, accuracy } }. -
Candidates:
- Every allowed domain never seen →
{ domain, skill:'', difficulty:'Easy', weight: 1.0 }. - Every seen
(domain, skill)in an allowed domain:-
Target difficulty by walking up the tiers: start Easy; advance to Medium only if the Easy
tier has
attempts ≥ 5andaccuracy > 0.70; same bar Medium→Hard; stop at the first tier that fails the bar. -
Weight =
max(0.05, 1 − accuracy_at_target). 0% → 1.0, 75% → 0.25, 100% → 0.05 (a floor, so mastered areas still resurface).
-
Target difficulty by walking up the tiers: start Easy; advance to Medium only if the Easy
tier has
- Every allowed domain never seen →
-
Weighted random pick — a random number in
[0, Σweights)walked through the candidate list. Returns{ domainIdx, skill, difficulty }.
Conventions answers from before the skill split. Rows with domain_idx = 3 and skill = ''
(recorded before the September 2026 rebuild) are fed into the window as history for both
Conventions skills (Boundaries, Form, Structure, and Sense), and the '' combination itself is
dropped. A student who had reached Hard in Conventions therefore starts both skills at Hard; as they
answer real Boundaries / Form questions, those push the old rows out of each 25-attempt window.
The mapping lives in LEGACY_CONVENTIONS (server/englishBankMigration.js).
The engine never sees question content — it works purely from the attempt log. Difficulty is
therefore requested; the client falls back through pickQuestion if the bank has no exact match.
English (0–3): Information and Ideas · Craft and Structure · Expression of Ideas · Standard English Conventions.
Math (0–3): Algebra · Advanced Math · Problem-Solving and Data Analysis · Geometry and Trigonometry.
The English banks were rebuilt with extract_english.py, fixing several long-standing problems in the
old extract_questions.py output:
-
No Standard English Conventions skills. Its line-based metadata parser broke on the wrapped
Standard English/Conventionscell, leaving Domain and Skill empty on every Conventions question (the client special-casedDomain === ''). Now:BoundariesandForm, Structure, and Sense. The same wrap producedText Structure and(for …and Purpose) and oneWords in ContextMedium. -
SAT and PSAT 10 banks were swapped. College Board gives the same question a different ID in
each assessment's export;
SAT-English-Questions.jsonheld the PSAT 10 export's IDs and vice versa. The content overlaps ~95%, which is why it went unnoticed. IDs travel with their content. - Truncated choices where an italic species name (A. mexicanus) was read as a choice marker, and ~160 / 156 / 105 questions missing from the SAT / PSAT 10 / PSAT 8/9 banks.
All 4,814 questions present in both the old and new banks kept the same correct answer.
Data migration (server/englishBankMigration.js, run at startup from server/db.js):
- Renames
Text Structure and→Text Structure and Purpose(and the two other truncated names) insat_scores. Runs on every startup — a no-op once clean — so a score posted from a stale tab after a deploy is fixed on the next restart. - Once per subject (recorded in
schema_migrationsasenglish-bank-rebuild-2026-09andmath-image-choices-2026-09): deletes that subject'squestion_reportsandsuppressed_questions. Nearly all reports were about garbled OCR/extracted text, which students no longer see now that both subjects are served as images. - Leaves Conventions rows with
skill = ''alone (see Adaptive serving).
node tools/preview-english-migration.js <scores.db> reports what it would change without writing.
Against the production data at the time: 110 rows renamed, 1 English and 16 math reports cleared,
973 legacy Conventions rows (105 students).