feat: every figure in the image bank, bigger editing boxes, footer links

The figures were never in the bank
3,702 extracted figures have always been in object storage and attached to
questions by path, but were never rows in `media_assets` — so nothing could
browse, tag, search or reuse them. They are all in a "Question figures" library
now, pointing at the same keys the questions use: nothing copied, nothing moved.

Each is tagged by what it is doing, which answers the question that prompted
this. 341 are in use. 94 are tagged `detached` — the ones the stem/answer review
took off a question because the figure illustrated the answer — and they are in
the bank precisely so that decision can be looked at rather than taken on trust;
every one is also still recoverable from its question's version history. 3,267
were extracted from the PDFs and never used by any question at all.

No image file was deleted at any point. All 3,702 are in MinIO, 854 MB, and
spot-checked readable.

Bigger boxes, and Markdown where prose lives
A stem is a clinical vignette and an explanation is a teaching paragraph; 120px
meant scrolling a box to reread what you had just written. Stem 340px,
explanation 280px, option explanations 130px. Option explanations render
Markdown and maths like every other piece of prose on the site, and the stem,
explanation and each option explanation now have a Preview showing what the
learner sees — the difference between writing a table and hoping you wrote one.

Footer links
The save bar is where the eye already is when a question is finished, so the ways
onward sit beside it: back to where you came from, then the bank, the manager,
the image bank, the taxonomy and the reading library.

244 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 02:32:06 +02:00
parent 07eae7937b
commit 16d799a273
3 changed files with 199 additions and 6 deletions

View file

@ -0,0 +1,149 @@
"""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:
caption = f"Figure from question #{question[0]}"
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())

View file

@ -40,7 +40,9 @@
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text);
}
.qe-field textarea { min-height: 120px; resize: vertical; line-height: 1.6; }
.qe-field textarea { min-height: 200px; resize: vertical; line-height: 1.65; }
.qe-field textarea.qe-stem { min-height: 340px; }
.qe-field textarea.qe-explanation { min-height: 280px; }
/* ── Options ──────────────────────────────────────────────────────── */
.qe-option { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; }
@ -56,7 +58,7 @@
width: 100%; padding: 9px 11px; font-size: 0.9rem; font-family: inherit;
border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text);
}
.qe-option-main textarea { min-height: 56px; resize: vertical; font-size: 0.84rem; }
.qe-option-main textarea { min-height: 130px; resize: vertical; font-size: 0.86rem; line-height: 1.6; }
.qe-option-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.qe-correct-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.78rem; color: var(--text-muted); cursor: pointer; }
@ -100,6 +102,26 @@
.qe-save { margin-left: auto; min-width: 140px; }
.qe-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 10px 0 0; }
/* What the learner will actually see. Written as Markdown, so the author should
not have to guess whether the table they typed is a table. */
.qe-preview { margin: 8px 0 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
.qe-preview > summary {
cursor: pointer; padding: 7px 11px; font-size: 0.76rem; font-weight: 700;
letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-muted);
}
.qe-preview > summary:hover { color: var(--primary); }
.qe-preview[open] > summary { border-bottom: 1px solid var(--border); }
.qe-preview .rich-text { padding: 12px 14px; font-size: 0.9rem; }
/* Footer links. The save bar is where the eye already is when a question is
finished, so the ways onward belong beside it rather than back at the top. */
.qe-bar-links { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; padding-bottom: 10px; }
.qe-bar-links a {
font-size: 0.78rem; color: var(--text-muted); text-decoration: none;
}
.qe-bar-links a:hover { color: var(--primary); text-decoration: underline; }
.qe-bar-links .qe-bar-sep { color: var(--text-subtle); font-size: 0.7rem; }
@media (max-width: 900px) {
.qe-grid { grid-template-columns: 1fr; }
.qe-aside { position: static; }

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
import CategoryDrilldown from '../components/CategoryDrilldown'
import RichText from '../components/RichText'
import ImagePicker from '../components/ImagePicker'
import { uploadUrl } from '../utils/uploads'
import './QuestionEditPage.css'
@ -203,9 +204,15 @@ export default function QuestionEditPage({ mode = 'edit' }) {
<div className="qe-card-body">
<label className="qe-field">
<span>Stem</span>
<textarea value={form.question_text} aria-label="Question text"
<textarea className="qe-stem" value={form.question_text} aria-label="Question text"
onChange={e => setField('question_text', e.target.value)} />
</label>
{form.question_text && (
<details className="qe-preview">
<summary>Preview</summary>
<RichText value={form.question_text} />
</details>
)}
<label className="qe-field">
<span>Type</span>
<select value={form.question_type} aria-label="Question type"
@ -228,12 +235,18 @@ export default function QuestionEditPage({ mode = 'edit' }) {
<div className="qe-option-main">
<input value={option} aria-label={`Option ${LETTERS[index]}`}
onChange={e => setOption(index, e.target.value)} />
<textarea value={form.option_explanations[option] || ''}
placeholder="Why this option is right or wrong (optional)"
<textarea className="qe-option-why" value={form.option_explanations[option] || ''}
placeholder="Why this option is right or wrong — Markdown and $maths$ supported"
aria-label={`Explanation for option ${LETTERS[index]}`}
onChange={e => setForm(f => ({
...f, option_explanations: { ...f.option_explanations, [option]: e.target.value },
}))} />
{form.option_explanations[option] && (
<details className="qe-preview">
<summary>Preview</summary>
<RichText value={form.option_explanations[option]} />
</details>
)}
<div className="qe-option-tools">
<label className="qe-correct-toggle">
<input type="radio" name="correct" checked={!!option && option === form.correct_answer}
@ -262,7 +275,7 @@ export default function QuestionEditPage({ mode = 'edit' }) {
<div className="qe-card-body">
<label className="qe-field">
<span>Overall explanation</span>
<textarea value={form.explanation} aria-label="Explanation"
<textarea className="qe-explanation" value={form.explanation} aria-label="Explanation"
onChange={e => setField('explanation', e.target.value)} />
</label>
</div>
@ -400,6 +413,15 @@ export default function QuestionEditPage({ mode = 'edit' }) {
</div>
<div className="qe-bar">
<div className="qe-bar-links">
<Link to={backTo}> {backLabel}</Link>
<span className="qe-bar-sep" aria-hidden="true">·</span>
<Link to="/question-bank">Question bank</Link>
<Link to="/questions/manage">Question manager</Link>
<Link to="/media">Image bank</Link>
<Link to="/categories">Taxonomy</Link>
<Link to="/articles">Reading</Link>
</div>
<div className="qe-bar-inner">
<span className="qe-bar-status" role="status" aria-live="polite">
{status || (isCreate ? 'Not saved yet' : `Question #${id}`)}