pdf-quiz-generator/backend/scripts/backfill_question_figures.py
Daniel bb72aa5f60 feat: AMBOSS exam chrome, review as a study session, and no silent submissions
The exam player takes the window. The shell was sized against the header with
a number that did not include the navbar's own 32px of margin, so the block bar
— the one thing on the screen that must always be reachable — sat below the
fold and had to be scrolled to. Exam mode now hides the site chrome entirely
and is the viewport, which makes the arithmetic honest and matches what a board
looks like: item and block in a box at the left, the two arrows in the middle,
the tools at the right, the question-status rail down the side, and the clock,
Pause and End Block along the bottom.

Shortcuts is gone from the bar, and the labs open into the column beside the
question in both modes rather than a box over it.

Nothing is handed in behind the learner's back. The clock reaching zero stops
the block and says so; closing Time's Up submits, and the player stays put
showing the answers, which is the review. The server no longer settles an
expired attempt at all — listing sessions used to mark any paper whose clock
had run out, so opening a page could score a block the learner had walked away
from, and the first they knew of it was a result.

Reviewing an attempt is now the player with the answers in, not a dropdown and
a card. Same rail, same layout, same labs, same way out — and on a phone the
same burger opens the same question list, from one shared rule about which
routes are a session.

Also: the rule-out toggle sits beside its option instead of pinned to the far
edge of the card, so an option box is as wide as its own words; the voice
picker leaves the player, since a reader's voice is a setting and not a
decision to retake every session; figures carry no invented "Figure 1" — a
label is what prose refers to, and the backfill knew of no prose, so 346 of
them said only that an image was an image; and the landing page shows the two
modes happening rather than promising six things in a sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 07:37:58 +02:00

103 lines
4.5 KiB
Python

"""Turn each question's image paths into labelled figure records.
Every figure is already a row in the image bank; what was missing was the link
that says *this question, this role, this label*. Without it a question could
hold one stem image and one explanation image, neither of which the prose could
refer to by name.
Titles are improved at the same time. A figure called
`page_339_img_0.png` tells you where it came from and nothing about what it
shows, so the provenance moves into the caption and the tags — where it is still
searchable — and the title becomes something a person can read.
docker compose exec backend python -m scripts.backfill_question_figures
docker compose exec backend python -m scripts.backfill_question_figures --apply
"""
import re
import sys
from sqlalchemy import text as sa_text
from app.database import SessionLocal
from app.models.media import MediaAsset
from app.models.question_media import QuestionMedia
ROLES = (("image_path", "stem"), ("explanation_image_path", "explanation"))
def readable_title(asset: MediaAsset, question_id: int, role: str, category: str | None) -> str:
"""A name a person can use, with the filename kept as provenance."""
where = "Stem figure" if role == "stem" else "Explanation figure"
subject = category or f"question #{question_id}"
return f"{where}{subject}"[:300]
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
rows = db.execute(sa_text("""
SELECT q.id, q.image_path, q.explanation_image_path, c.name
FROM questions q
LEFT JOIN question_categories c ON c.id = q.question_category_id
WHERE (q.image_path IS NOT NULL AND q.image_path <> '')
OR (q.explanation_image_path IS NOT NULL AND q.explanation_image_path <> '')
""")).fetchall()
assets = {a.path: a for a in db.query(MediaAsset).all()}
existing = {(link.question_id, link.media_id, link.role)
for link in db.query(QuestionMedia).all()}
planned, missing = [], []
for question_id, stem_path, expl_path, category in rows:
for column, role in ROLES:
path = stem_path if column == "image_path" else expl_path
if not path:
continue
asset = assets.get(path)
if asset is None:
missing.append((question_id, path))
continue
if (question_id, asset.id, role) in existing:
continue
planned.append((question_id, asset, role, category))
print(f" questions with a figure : {len(rows)}")
print(f" figure links to create : {len(planned)}")
if missing:
print(f" paths with no image record: {len(missing)} (left alone)")
if not apply_changes:
for question_id, asset, role, category in planned[:6]:
print(f" q#{question_id:<6} {role:<12} {asset.path}")
print(f" title -> {readable_title(asset, question_id, role, category)}")
print("\n Re-run with --apply.")
return 0
made = retitled = 0
for question_id, asset, role, category in planned:
position = db.query(QuestionMedia).filter_by(
question_id=question_id, role=role).count()
# No label. A label is what the prose refers to — "as in Figure 2"
# — and a backfill knows of no prose that refers to anything. The
# numbers this used to invent were printed under every image on the
# site, telling a learner that an image was an image.
db.add(QuestionMedia(question_id=question_id, media_id=asset.id, role=role,
position=position))
made += 1
# Keep the filename as provenance in the caption; it is the only
# record of which page of which PDF this came from.
if asset.title and re.match(r"^page_\d+_img", asset.title):
asset.caption = (asset.caption or "") + f" (from {asset.path})"
asset.title = readable_title(asset, question_id, role, category)
retitled += 1
if made % 200 == 0:
db.commit()
db.commit()
print(f"\n figure links created : {made}")
print(f" images retitled : {retitled}")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())