pdf-quiz-generator/backend/scripts/seed_study_plans.py
Daniel ac92793600 feat: study-plan blocks as modules, sessions that know their block
From the three recordings and the AMBOSS screenshots.

Study plans
- Blocks of about 40, split evenly: 202 questions is six blocks of
  33-34, not five of 50 and one of 2. Reseeded (no progress or reading
  existed yet); the seeder now splits the same way.
- A block has its own page, laid out as a course module: the plan's
  blocks down the left, this block's reading then its session in the
  middle, back / previous / next along the bottom. Study or exam mode
  is chosen there, before the session exists; afterwards the mode is
  shown, not offered. The plan page is the table of contents and links
  into blocks rather than starting anything.
- Progress on a block comes from the same /quizzes/sessions row the
  Sessions page shows, so the two cannot disagree.

Sessions <-> plans
- A session started from a block carries its place in the plan: the
  session list and the analysis both return `plan` (plan, block,
  position, previous and next block). The analysis shows a strip with
  the way back to the block and on to the next one.
- Submitting a session marks its block complete. Nothing ever set
  completed_at before — every block read as unfinished forever.

Recommendations
- Framed by the learner's chosen study objective: answers and bank
  material linked to a different exam are left out, and the page is
  titled for the exam. Unlinked material stays in, as elsewhere.

Backend 216/216, frontend 257/258 (the one failure is
ArticleSplitView, which is timing-flaky under the full run and is
unrelated to this change; being checked separately).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:31:21 +02:00

145 lines
5.4 KiB
Python

"""Turn the year question sets into study plans of numbered blocks.
One plan per year, split evenly into blocks of about BLOCK_SIZE, plus a mixed plan that
draws MIXED_SIZE questions at random across every year.
Block membership is snapshotted, not a live filter: a plan you are part-way
through must not reshuffle between visits. Re-running updates block contents for
years that changed, and leaves the mixed plan's draw alone unless --reshuffle.
docker compose exec backend python -m scripts.seed_study_plans
docker compose exec backend python -m scripts.seed_study_plans --apply
"""
import random
import re
import sys
from math import ceil
from sqlalchemy import text as sa_text
from app.database import SessionLocal
from app.models.exam import Exam
from app.models.study_plan import StudyPlan, StudyPlanBlock
BLOCK_SIZE = 40
MIXED_SIZE = 300
MIXED_SLUG = "mixed-review"
# The imported material is tagged with the source programme's name; the plans
# built from it are ours and are named for what they are.
YEAR_NAME = "Board Review {year}"
YEAR_SLUG = "board-review-{year}"
def prep_tags(db):
"""Year tags, newest first.
The tags still carry the source programme's name because that is what the
imported material was labelled with; the plans built from them do not.
"""
rows = db.execute(sa_text("""
SELECT t.id, t.name FROM question_tags t
WHERE t.name ~ '^(Board Review|PREP) [0-9]{4}$'
ORDER BY t.name DESC
""")).fetchall()
return [(row[0], row[1]) for row in rows]
def question_ids_for(db, tag_id):
rows = db.execute(sa_text(
"SELECT question_id FROM question_tag_links WHERE tag_id = :t ORDER BY question_id"
), {"t": tag_id}).fetchall()
return [row[0] for row in rows]
def upsert_plan(db, slug, name, description, kind, sort_order, exam_id):
plan = db.query(StudyPlan).filter_by(slug=slug).first()
if plan is None:
plan = StudyPlan(slug=slug, name=name, description=description,
kind=kind, sort_order=sort_order, exam_id=exam_id)
db.add(plan)
db.flush()
else:
plan.name, plan.description = name, description
plan.kind, plan.sort_order, plan.exam_id = kind, sort_order, exam_id
return plan
def even_chunks(ids, target=BLOCK_SIZE):
"""Split into ceil(n / target) blocks whose sizes differ by at most one.
Plain slicing leaves a remainder — 202 questions became five blocks of 50
and one of 2 — and a block of two questions is not a study block.
"""
k = max(1, ceil(len(ids) / target))
base, extra = divmod(len(ids), k)
out, i = [], 0
for n in range(k):
size = base + (1 if n < extra else 0)
out.append(ids[i:i + size])
i += size
return out
def set_blocks(db, plan, chunks):
"""Replace the plan's blocks with `chunks`, numbered from 1."""
db.query(StudyPlanBlock).filter(StudyPlanBlock.plan_id == plan.id).delete(synchronize_session=False)
for index, ids in enumerate(chunks, start=1):
db.add(StudyPlanBlock(plan_id=plan.id, position=index,
title=f"Block {index}", question_ids=list(ids)))
def main():
apply_changes = "--apply" in sys.argv
reshuffle = "--reshuffle" in sys.argv
db = SessionLocal()
try:
exam = db.query(Exam).filter_by(slug="pediatrics-boards").first()
exam_id = exam.id if exam else None
tags = prep_tags(db)
if not tags:
print("No year tags found; nothing to do.")
return
everything, summary = [], []
for order, (tag_id, name) in enumerate(tags, start=1):
ids = question_ids_for(db, tag_id)
everything.extend(ids)
chunks = even_chunks(ids)
summary.append((name, len(ids), len(chunks)))
if apply_changes:
year = re.search(r"(\d{4})", name).group(1)
plan = upsert_plan(db, YEAR_SLUG.format(year=year), YEAR_NAME.format(year=year),
f"{len(ids)} questions in {len(chunks)} blocks of about {BLOCK_SIZE}.",
"set", order, exam_id)
set_blocks(db, plan, chunks)
mixed = db.query(StudyPlan).filter_by(slug=MIXED_SLUG).first()
draw_needed = reshuffle or mixed is None or not mixed.blocks
mixed_ids = []
if draw_needed:
pool = list(dict.fromkeys(everything))
random.shuffle(pool)
mixed_ids = pool[:MIXED_SIZE]
if apply_changes:
plan = upsert_plan(db, MIXED_SLUG, "Mixed Review",
f"{MIXED_SIZE} questions drawn at random from every year.",
"mixed", 0, exam_id)
if draw_needed:
set_blocks(db, plan, [mixed_ids])
db.commit()
print("APPLIED" if apply_changes else "DRY RUN")
for name, count, blocks in summary:
year = re.search(r"(\d{4})", name)
shown = YEAR_NAME.format(year=year.group(1)) if year else name
print(f" {shown:18s} {count:4d} questions -> {blocks} blocks")
print(f" {'Mixed Review':18s} {len(mixed_ids) or MIXED_SIZE:4d} questions -> 1 block"
f"{'' if draw_needed else ' (existing draw kept)'}")
if not apply_changes:
print("\n Re-run with --apply to write these plans.")
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())