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:
parent
07eae7937b
commit
16d799a273
3 changed files with 199 additions and 6 deletions
149
backend/scripts/index_question_images.py
Normal file
149
backend/scripts/index_question_images.py
Normal 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())
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}`)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue