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
103 lines
4.5 KiB
Python
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())
|