The linking was the gap
The marker system was built weeks ago — resolves by id, survives a rename, shows
a preview on hover — and not one of 333 articles used it. Every article was
written in isolation, so a piece on croup named stridor and epiglottitis and
offered no way to reach either. `scripts/link_articles.py` reads what is written
and links it: 3,718 cross-references across 307 articles, by id, so a later
rename cannot break them.
Conservative on purpose, because a wrong link is worse than a missing one: only
the first mention in a section, whole words, longest title first so "Otitis media
with effusion" beats "Otitis media", never inside an existing link, marker,
heading, code span or table, and never an article to itself.
That exposed a second thing: the reading view had its own Markdown pipeline with
its own cross-reference regex, and it only understood the old slug form. It would
have printed every one of those 3,718 links as literal brackets. Article prose
now goes through the same renderer as the rest of the site.
Short and Clinical looked empty
Both are usually a single section, and everything starts collapsed, so the tab
showed one heading over blank space. A view of one section is not a contents
page; it opens.
Removed
Quiz reminders — emailed nudges to retake anything under 75%, with a scheduler
that existed solely to send them: the model, the service, the scheduler, the
email, the table. Article comments. The dashboard's in-progress list and its
stat cards, both of which the analysis page now answers better.
One mistake worth recording: the first pass at removing the reminder cleanup used
a regex that took 109 lines with it, including an unrelated endpoint. The test
suite caught it (`/attempts/quiz/{id}/in-progress` returning 404 instead of 403),
and the file was restored and edited by exact match instead.
208 backend, 249 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
108 lines
4.2 KiB
Python
108 lines
4.2 KiB
Python
"""Drop the role from figure titles; the badge and the link already say it.
|
|
|
|
Titles read "Stem figure — Occult Fracture". The role is already visible from
|
|
where the figure sits on the question, and the link itself is the
|
|
`question_media` row, not the words in the title. What is useful in a bank of
|
|
440 is the subject, so that is all the title should be.
|
|
|
|
The id stays the identifier it always was, and no link is touched.
|
|
|
|
docker compose exec backend python -m scripts.retitle_figures
|
|
docker compose exec backend python -m scripts.retitle_figures --apply
|
|
"""
|
|
import re
|
|
import sys
|
|
|
|
from sqlalchemy import text as sa_text
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.media import MediaAsset
|
|
|
|
PREFIX = re.compile(r"^(stem|explanation) figure\s*—\s*", re.I)
|
|
|
|
|
|
def main():
|
|
apply_changes = "--apply" in sys.argv
|
|
db = SessionLocal()
|
|
try:
|
|
# Where a figure has no subject of its own, the question's category is
|
|
# the best name available; the filename is not a name at all.
|
|
subjects = {}
|
|
for media_id, name in db.execute(sa_text("""
|
|
SELECT qm.media_id, c.name
|
|
FROM question_media qm
|
|
JOIN questions q ON q.id = qm.question_id
|
|
LEFT JOIN question_categories c ON c.id = q.question_category_id
|
|
""")).fetchall():
|
|
if name and media_id not in subjects:
|
|
subjects[media_id] = name
|
|
|
|
# A detached figure has no link to take a subject from, but its caption
|
|
# records which question it came off. That is still the best name.
|
|
detached = {}
|
|
for asset in db.query(MediaAsset).all():
|
|
match = re.search(r"question #(\d+)", asset.caption or "")
|
|
if match and asset.id not in subjects:
|
|
detached[asset.id] = int(match.group(1))
|
|
if detached:
|
|
names = dict(db.execute(sa_text("""
|
|
SELECT q.id, c.name FROM questions q
|
|
LEFT JOIN question_categories c ON c.id = q.question_category_id
|
|
WHERE q.id = ANY(:ids)
|
|
"""), {"ids": list(detached.values())}).fetchall())
|
|
for media_id, question_id in detached.items():
|
|
if names.get(question_id):
|
|
subjects[media_id] = names[question_id]
|
|
|
|
changed = []
|
|
for asset in db.query(MediaAsset).all():
|
|
title = asset.title or ""
|
|
if PREFIX.search(title):
|
|
new = PREFIX.sub("", title).strip()
|
|
elif re.match(r"^page_\d+_img", title):
|
|
new = subjects.get(asset.id) or title
|
|
else:
|
|
continue
|
|
if new and new != title:
|
|
changed.append((asset, title, new))
|
|
|
|
print(f" titles to change: {len(changed)}")
|
|
for _asset, old, new in changed[:6]:
|
|
print(f" {old:<44} -> {new}")
|
|
if not apply_changes:
|
|
print("\n Re-run with --apply.")
|
|
return 0
|
|
|
|
for asset, _old, new in changed:
|
|
asset.title = new[:300]
|
|
db.flush()
|
|
|
|
# A legend is what a reader sees in an explanation, so twelve figures all
|
|
# called "Pediatric Surgery" is twelve legends that say nothing. Only the
|
|
# repeats are numbered; a subject used once stays clean.
|
|
from collections import defaultdict
|
|
|
|
by_title = defaultdict(list)
|
|
for asset in db.query(MediaAsset).order_by(MediaAsset.id).all():
|
|
by_title[asset.title].append(asset)
|
|
questions = dict(db.execute(sa_text(
|
|
"SELECT media_id, MIN(question_id) FROM question_media GROUP BY media_id")).fetchall())
|
|
numbered = 0
|
|
for title, assets in by_title.items():
|
|
if len(assets) < 2:
|
|
continue
|
|
for asset in assets:
|
|
question_id = questions.get(asset.id)
|
|
asset.title = (f"{title} · Q{question_id}" if question_id
|
|
else f"{title} · #{asset.id}")[:300]
|
|
numbered += 1
|
|
db.commit()
|
|
print(f" disambiguated: {numbered} repeated subjects")
|
|
print(f"\n retitled: {len(changed)} (no link changed; the id is the link)")
|
|
finally:
|
|
db.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|