pdf-quiz-generator/backend/scripts/backfill_question_figures.py
Daniel 0e6c18d886 feat: figures as records, question-centred dashboard, fewer hints mid-quiz
Figures
A question could carry exactly one stem image and one explanation image, each a
bare path with no title, no legend, and no way for the prose to refer to it.
`question_media` makes a figure a row: it points at an image already in the bank,
carries a role, a label the text can name ("Figure 1"), a caption and an order,
and there can be as many as the question needs. The same radiograph can serve two
questions without being stored twice.

The 346 existing paths were backfilled into figure records and retitled —
`page_339_img_0.png` says where a file came from and nothing about what it shows,
so the filename moved into the caption where it is still searchable, and the
title became something a person can read.

On the editor question: no new platform needed. Milkdown is already installed —
ProseMirror-based, MIT, GFM tables, code blocks, LaTeX — and already used for
articles, courses and the quick question modal. Only the question *page* still
has plain textareas, and that swap is written down rather than rushed, because
the stem carries manual-highlight offsets and a WYSIWYG rewrite would move them.

Fewer hints during a quiz
The category trail and the difficulty pill were shown beside every stem. Being
told a question is filed under Neonatology, or that it is "hard", narrows the
answer before the stem has been read. Both now wait until the answer is in,
where the trail becomes a way to more of the same topic.

The dashboard is about questions
Quizzes and attempts describe how the material happens to be packaged. What a
learner is working through is questions: how many of the bank they have seen,
how many they have answered correctly, and their average. The old per-quiz
performance card — which needed two attempts before it showed anything — is
gone, superseded by the session analysis. The greeting sits above "continue your
study" rather than below it, where it read as a heading for the wrong section.

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:18:49 +02:00

99 lines
4.2 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()
db.add(QuestionMedia(question_id=question_id, media_id=asset.id, role=role,
label=f"Figure {position + 1}", 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())