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
145 lines
5.4 KiB
Python
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())
|