"""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())