pdf-quiz-generator/backend/scripts/rename_study_plans.py
Daniel a1459b2965 refactor: name the study plans ourselves, and stop reserving 64k tokens a call
"PREP" is the American Academy of Pediatrics' trademark for their own product.
The plans here are our own sets of questions grouped by year, so they are now
named for what they are: Board Review 2021, and Mixed Review for the plan that
draws from every year at once.

Renamed in the database as well as the code — 13 plans, 14 quizzes a learner had
already generated from a block, and the 12 year tags, which appear in the
question bank's filters and are as visible as the plans. The seeder matches both
the old and new names so a fresh import still finds its material, and the tagger
mints the new one so the next run cannot undo this. Prompts and comments that
described the source PDFs by that name now describe them by what they are.

The generation run's 377 failures were not a bug
Every call was reserving the model's full 64k output ceiling, and OpenRouter
refuses the whole request when the balance is below the reservation — "you
requested up to 64000 tokens, but can only afford 52017" — however short the
answer would actually be. `_call_model` now takes a max_tokens, and the article
writer asks for 4000, which is comfortable for three views of one topic and
keeps each request small enough to be affordable. 98 articles were written
before the balance ran down; 158 exist in total.

Generation is paused at the user's request while credits are topped up.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 01:41:18 +02:00

108 lines
4.2 KiB
Python

"""Rename the study plans away from the vendor's programme name.
"PREP" is the American Academy of Pediatrics' trademark for its own product.
The plans here are our own sets of questions grouped by year, so they get names
that describe what they are: "Board Review 2021", and "Mixed Review" for the
plan that draws from every year at once.
Slugs change with them, which is safe because a study plan is reached by id and
the slug is not a public address. Quizzes a learner already generated from a
block are renamed too, so a title in their history matches the plan it came from
rather than referring to something that no longer exists. So are the year tags,
which appear in the question bank's filters and are as visible as the plans.
docker compose exec backend python -m scripts.rename_study_plans
docker compose exec backend python -m scripts.rename_study_plans --apply
"""
import re
import sys
from sqlalchemy import text as sa_text
from app.database import SessionLocal
YEAR_NAME = "Board Review {year}"
YEAR_SLUG = "board-review-{year}"
MIXED_NAME = "Mixed Review"
MIXED_SLUG = "mixed-review"
def planned(db):
"""(id, old name, new name, old slug, new slug) for everything to rename."""
rows = db.execute(sa_text(
"SELECT id, slug, name, kind FROM study_plans ORDER BY sort_order, name")).fetchall()
changes = []
for plan in rows:
year = re.search(r"(\d{4})", plan.name)
if plan.kind == "mixed" or "mixed" in plan.name.lower():
new_name, new_slug = MIXED_NAME, MIXED_SLUG
elif year:
new_name = YEAR_NAME.format(year=year.group(1))
new_slug = YEAR_SLUG.format(year=year.group(1))
else:
continue
if (new_name, new_slug) != (plan.name, plan.slug):
changes.append((plan.id, plan.name, new_name, plan.slug, new_slug))
return changes
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
# No early return when the plans are already done: the quizzes and the
# year tags are renamed by the same pass, and a second run has to be able
# to finish what a first one left.
changes = planned(db)
print(f" plans to rename: {len(changes)}\n")
for _pid, old_name, new_name, old_slug, new_slug in changes:
print(f" {old_name:<16} -> {new_name:<22} ({old_slug} -> {new_slug})")
quizzes = db.execute(sa_text(
"SELECT id, title FROM quizzes WHERE title LIKE '%PREP%'")).fetchall()
print(f"\n quizzes already generated from a block: {len(quizzes)}")
tags = db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE name ~ '^PREP [0-9]{4}$'")).fetchall()
print(f" year tags shown in the bank's filters : {len(tags)}")
if not apply_changes:
print("\n Re-run with --apply to rename them.")
return 0
for plan_id, _old_name, new_name, _old_slug, new_slug in changes:
db.execute(sa_text("UPDATE study_plans SET name = :n, slug = :s WHERE id = :i"),
{"n": new_name, "s": new_slug, "i": plan_id})
renamed = 0
for quiz in quizzes:
title = quiz.title
year = re.search(r"PREP (\d{4})", title)
if year:
title = title.replace(f"PREP {year.group(1)}", YEAR_NAME.format(year=year.group(1)))
else:
title = title.replace("PREP Mixed", MIXED_NAME).replace("PREP", "Board Review")
if title != quiz.title:
db.execute(sa_text("UPDATE quizzes SET title = :t WHERE id = :i"),
{"t": title, "i": quiz.id})
renamed += 1
retagged = 0
for tag in tags:
year = re.search(r"(\d{4})", tag.name)
if not year:
continue
db.execute(sa_text("UPDATE question_tags SET name = :n WHERE id = :i"),
{"n": YEAR_NAME.format(year=year.group(1)), "i": tag.id})
retagged += 1
db.commit()
print(f"\n plans renamed : {len(changes)}")
print(f" quizzes renamed : {renamed}")
print(f" tags renamed : {retagged}")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())