pdf-quiz-generator/backend/scripts/index_question_images.py
Daniel e311a6b5ad fix: choosing is answering; figures say only what an educator wrote
Study mode held a choice as a draft and waited for "Submit response" — a
second press to confirm something already decided, on every question.
Clicking an option marks it now, green or red, with the explanation.
Free text is the exception and keeps Enter, because typing is not
choosing.

Figures carried a generated caption: "Figure from question #3360 (from
images/doc_23/page_704_img_0.jpeg)". That describes the database, not
the picture, and showed a learner an internal file path. 341 of them are
cleared, the indexer no longer writes them, and an unlabelled figure now
says nothing rather than "Figure 1". A screen reader still gets the
label and caption when there are any, and the position when there are
not.

Suspend, Restart and Edit are gone from above the question. Three
buttons over a question nobody was looking away from to press them; Exit
is in the bar at the bottom with the session's own controls, and
restarting and editing belong to the session list and the editor.

And iOS Safari's zoom-on-focus is fixed once rather than per field.
Safari zooms the whole page in when a control smaller than 16px takes
focus and never zooms back out, leaving the layout scaled and broken. It
was being remembered at each individual field, which meant it was
forgotten at most of them — a dozen were still under 16px. One rule for
every control on a coarse pointer.

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

153 lines
6.2 KiB
Python

"""Register the extracted question figures in the image bank.
The figures have always been in object storage and attached to questions by
path, but they were never rows in `media_assets` — so nothing could browse them,
tag them, search them by what they show, or offer them when attaching an image
to another question. This puts every one of them in the bank.
Nothing is copied or moved: an asset row points at the key the question already
uses, so a question's picture and its library entry are the same file.
Each figure is described from the question it belongs to, which is the only
description that exists until someone writes a better one. Figures no question
currently uses are still listed — several were detached during the stem/answer
triage and are exactly what somebody reviewing that decision needs to look at.
docker compose exec backend python -m scripts.index_question_images
docker compose exec backend python -m scripts.index_question_images --apply
"""
import sys
from collections import defaultdict
from sqlalchemy import text as sa_text
from app.database import SessionLocal
from app.models.media import MediaAsset, MediaLibrary, MediaTagLink
from app.services import storage_service
LIBRARY = "Question figures"
PREFIX = "images/"
def _library(db):
library = db.query(MediaLibrary).filter(MediaLibrary.name == LIBRARY).first()
if library is None:
library = MediaLibrary(name=LIBRARY,
description="Figures extracted from the source exam PDFs.")
db.add(library)
db.flush()
return library
def _stored_keys() -> list[str]:
keys = []
client = storage_service._s3()
from app.config import settings
for page in client.get_paginator("list_objects_v2").paginate(
Bucket=settings.S3_BUCKET, Prefix=PREFIX):
keys.extend(obj["Key"] for obj in page.get("Contents", []))
return keys
def _usage(db) -> tuple[dict, dict]:
"""Which question each figure belongs to now, and which it used to."""
current, historical = {}, {}
for qid, path, category in db.execute(sa_text("""
SELECT q.id, q.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 <> ''
""")).fetchall():
current[path] = (qid, category)
for qid, path, category in db.execute(sa_text("""
SELECT v.question_id, v.snapshot->>'image_path', c.name
FROM question_versions v
LEFT JOIN questions q ON q.id = v.question_id
LEFT JOIN question_categories c ON c.id = q.question_category_id
WHERE v.snapshot->>'image_path' IS NOT NULL AND v.snapshot->>'image_path' <> ''
""")).fetchall():
historical.setdefault(path, (qid, category))
return current, historical
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
keys = _stored_keys()
known = {row[0] for row in db.query(MediaAsset.path).all()}
current, historical = _usage(db)
todo = [key for key in keys if key not in known]
attached = sum(1 for k in todo if k in current)
detached = sum(1 for k in todo if k not in current and k in historical)
unused = len(todo) - attached - detached
print(f" figures in storage : {len(keys)}")
print(f" already in the bank : {len(keys) - len(todo)}")
print(f" to add : {len(todo)}")
print(f" attached to a question : {attached}")
print(f" detached during triage : {detached}")
print(f" never used by a question: {unused}")
if not apply_changes:
print("\n Re-run with --apply to add them.")
return 0
library = _library(db)
tag_cache: dict[str, int] = {}
def tag_id(name: str) -> int:
if name not in tag_cache:
row = db.execute(sa_text(
"SELECT id FROM question_tags WHERE lower(name) = lower(:n) ORDER BY id LIMIT 1"
), {"n": name}).first()
tag_cache[name] = row[0] if row else db.execute(sa_text(
"INSERT INTO question_tags (name, type) VALUES (:n, 'keyword') RETURNING id"
), {"n": name}).scalar()
return tag_cache[name]
added = 0
for index, key in enumerate(todo, start=1):
question = current.get(key)
was = historical.get(key)
source = question or was
if question:
# No caption unless an educator writes one. A generated one
# ("Figure from question #3360") describes the database, not
# the picture, and was shown to learners as though it were a
# caption — file path and all.
caption = None
state = "in use"
elif was:
caption = (f"Detached from question #{was[0]} during the stem/answer review — "
f"kept so the decision can be checked")
state = "detached"
else:
caption = "Extracted from a source PDF; no question uses it"
state = "unused"
asset = MediaAsset(
path=key, title=key.rsplit("/", 1)[-1],
caption=caption, alt_text=caption, kind="image",
library_id=library.id, storage="s3",
)
db.add(asset)
db.flush()
# Tagged by where it came from and what it is doing, so the bank can
# answer "show me what the triage detached" without a database query.
for name in filter(None, [state, (source[1] if source else None)]):
db.add(MediaTagLink(media_id=asset.id, tag_id=tag_id(name)))
added += 1
if index % 250 == 0:
db.commit()
print(f"{index}/{len(todo)}", flush=True)
db.commit()
print(f"\n added to the bank: {added}")
print(" Searchable by caption; tagged 'in use', 'detached' or 'unused'.")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())