mathly / mathly2 /exam.py
wzender's picture
Claude Opus 5.5
Tests on request and a readiness number that only tests raise
82cae90
Raw History Blame Contribute Delete
4.85 kB
"""Drawing an exercise test or a simulation for a real test (PRD EXT-4, EXT-5, SIM-2): mixed questions from all covered lessons,
weighted toward the student's weak skills, never from a skill the student has not started."""
from __future__ import annotations
import random
EXERCISE_SHARE = {1: 0.6, 2: 0.3} # the share of questions that come from weak skills in exercise test 1 and 2; the simulation has none
FOCUS = 3 # the exercise test on the weak topics: only the skills that lost points or are still being learned
FOCUS_SIZE = 15
BALANCED, MANUAL = 1, 4 # practice tests by mode (the number in the attempt's scope "<test>:<n>"); 2 is an older balanced one
MODE_OF = {1: "balanced", 2: "balanced", FOCUS: "focus", MANUAL: "manual"}
NUMBER_OF = {"balanced": BALANCED, "focus": FOCUS, "manual": MANUAL}
TITLES = {"balanced": "诪讘讞谉 转专讙讜诇 诪讗讜讝谉", "focus": "诪讘讞谉 转专讙讜诇 诪诪讜拽讚", "manual": "诪讘讞谉 转专讙讜诇 讘讘讞讬专讛"}
def mode_of_scope(scope: str) -> str:
_, _, number = scope.partition(":")
return MODE_OF.get(int(number), "balanced") if number.isdigit() else "balanced"
def _round(x: float) -> int:
return int(x + 0.5)
def draw(bank, lessons: list[str], states: dict[str, str], n: int, *, share: float = 0.0, weak_before: set[str] | None = None,
used: dict[str, int] | None = None, rng: random.Random | None = None, include_unstarted: bool = False) -> dict:
"""{"questions": [{skill_id, key}], "weak": [skill ids], "practice_first": [skill ids]}.
Covered skills are those of `lessons` that have questions. A skill in the not-started state is never drawn: it is listed in
`practice_first`. A weak skill is a started covered skill in the learning state, or one in `weak_before` (answered wrong in an
earlier exercise test of this real test). With `include_unstarted`, not-started skills are drawn too (and still listed in `practice_first`). Weak skills give one question each, up to `round(n * share)`; the rest comes from the
other skills one per skill round after round (least-used questions first, ties random), then from the weak ones again."""
used, rng, weak_before = used or {}, rng or random.Random(), weak_before or set()
covered = [s["id"] for s in bank.skills if s["unit"] in lessons and bank.question_keys(s["id"])]
started = [i for i in covered if states.get(i, "not_started") != "not_started"]
practice_first = [i for i in covered if i not in started]
if include_unstarted:
started = covered
weak = [i for i in started if states.get(i) == "learning" or i in weak_before]
other = [i for i in started if i not in weak]
pools = {i: sorted(bank.question_keys(i), key=lambda key: (used.get(key, 0), rng.random())) for i in started}
taken: list[dict] = []
seen: set[str] = set()
def take(skill_id: str) -> bool:
for key in pools[skill_id]:
if key not in seen:
seen.add(key)
taken.append({"skill_id": skill_id, "key": key})
return True
return False
wanted = _round(n * share)
shuffled_weak = rng.sample(weak, len(weak))
for skill_id in shuffled_weak[:wanted]:
take(skill_id)
order = rng.sample(other, len(other)) if share else rng.sample(started, len(started)) # no weak share: an even spread over all
for group in ([order, shuffled_weak] if share else [order]): # the weak skills are used again only when the others run out
progress = True
while len(taken) < n and progress:
progress = False
for skill_id in group:
if len(taken) >= n:
break
if take(skill_id):
progress = True
rng.shuffle(taken)
return {"questions": taken[:n], "weak": weak, "practice_first": practice_first}
def draw_focus(bank, weak: list[str], n: int = FOCUS_SIZE, used: dict[str, int] | None = None, rng: random.Random | None = None) -> list[dict]:
"""`n` questions from these skills only (the weak ones, or the ones the student picked), one per skill round after round (least-used first)."""
used, rng = used or {}, rng or random.Random()
pools = {i: sorted(bank.question_keys(i), key=lambda key: (used.get(key, 0), rng.random())) for i in weak if bank.question_keys(i)}
order, taken, seen = rng.sample(list(pools), len(pools)), [], set()
progress = True
while len(taken) < n and progress:
progress = False
for skill_id in order:
key = next((k for k in pools[skill_id] if k not in seen), None)
if key and len(taken) < n:
seen.add(key)
taken.append({"skill_id": skill_id, "key": key})
progress = True
rng.shuffle(taken)
return taken