mathly / mathly2 /attempts.py
wzender's picture
feat(board-work): implement board work feature for student answers
aeda545
Raw History Blame Contribute Delete
6.12 kB
"""Graded attempts: the engine under lesson quizzes, exercise tests and the simulation.
Only the simulation is timed (TIMED_KINDS); a lesson quiz or an exercise test stays open until the student hands it in. Every attempt
still stores a due_at (the database requires one), but only a timed attempt is held to it.
An attempt is open until it is graded. The server settles an overdue timed attempt on the first read after its deadline, so there is no
background job and the answer is right even if nothing ran at the deadline. Nothing here ever says which choice was right."""
from __future__ import annotations
import logging
from datetime import datetime
log = logging.getLogger("mathly2.attempts")
QUIZ_SIZE = 15
MINUTES_PER_QUESTION = 3
QUIZ_MINUTES = MINUTES_PER_QUESTION * QUIZ_SIZE # 45
PASS_RIGHT = 13 # right answers out of QUIZ_SIZE that pass a lesson quiz
TIMED_KINDS = frozenset({"simulation"}) # the only attempts with a time limit
def percent(right: int, total: int) -> int:
"""The grade: the share of right answers as a whole number 0-100, rounded half up (7/10 -> 70, 8/12 -> 67)."""
return 0 if total <= 0 else (200 * right + total) // (2 * total)
PASS_GRADE = percent(PASS_RIGHT, QUIZ_SIZE) # 87: a quiz is passed with this grade or better
def _right(bank, question: dict, answers: dict) -> bool:
choice = answers.get(question["key"])
graded = bank.grade(question["skill_id"], question["key"], choice) if isinstance(choice, int) else None
return bool(graded and graded["correct"])
CREDITS = (0, 0.5, 1) # what a teacher can give a question for its work: nothing, half, all
def points(bank, question: dict, answers: dict, credit: dict | None = None) -> float:
"""A question's points: the teacher's credit for the work when they gave one, else 1 for a right answer and 0 for a wrong one."""
given = (credit or {}).get(question["key"])
return float(given) if given in CREDITS else (1.0 if _right(bank, question, answers) else 0.0)
def grade(bank, questions: list[dict], answers: dict, credit: dict | None = None) -> tuple[int, int]:
"""(grade, number right). A question without an answer, or with an answer the bank does not know, is wrong. The teacher's credit for the
work (in halves) replaces a question's points."""
right = sum(1 for q in questions if _right(bank, q, answers))
halves = sum(round(2 * points(bank, q, answers, credit)) for q in questions)
return percent(halves, 2 * len(questions)), right
def add_wrong_to_review(store, bank, row: dict, at: datetime) -> None:
"""Every question of a submitted attempt that was answered wrongly, or not answered, goes to the student's review list (חזרה). Recording it
never fails the submit: a problem here is logged and the grade stands."""
wrong = [(q["skill_id"], q["key"], row["kind"]) for q in row["questions"] if not _right(bank, q, row["answers"])]
if not wrong:
return
try:
store.add_review(row["user_id"], wrong, at)
except Exception as exc:
log.warning("could not add wrong answers to the review list: %s", type(exc).__name__)
def is_open(row: dict) -> bool:
return row["submitted_at"] is None
def is_timed(row: dict) -> bool:
return row["kind"] in TIMED_KINDS
def is_overdue(row: dict, now: datetime) -> bool:
return is_open(row) and is_timed(row) and now >= row["due_at"]
def settle(store, bank, row: dict, now: datetime) -> dict:
"""Grade an overdue open attempt from the answers saved so far. Anything else is returned as it is."""
if not is_overdue(row, now):
return row
value, _ = grade(bank, row["questions"], row["answers"])
saved = store.save_attempt(row["id"], submitted_at=row["due_at"], timed_out=True, grade=value, answers=row["answers"])
add_wrong_to_review(store, bank, saved, row["due_at"])
return saved
def submit(store, bank, row: dict, now: datetime) -> dict:
"""The student hands in. Idempotent: a submitted attempt is returned unchanged; a timed attempt (the simulation) past its deadline
is settled as timed out. An untimed one is graded now, however late."""
if not is_open(row):
return row
if is_overdue(row, now):
return settle(store, bank, row, now)
value, _ = grade(bank, row["questions"], row["answers"])
saved = store.save_attempt(row["id"], submitted_at=now, timed_out=False, grade=value, answers=row["answers"]) # the answers it was graded on
add_wrong_to_review(store, bank, saved, now)
return saved
def result(bank, row: dict) -> dict:
"""What the student sees after a submitted attempt: the grade, the skills that lost points and, for each missed question, the
mistake's name and tip. It never carries a `correct` flag, the right choice or its position. A question the bank no longer has is skipped."""
lost: dict[str, dict] = {}
missed = []
for q in row["questions"]:
skill = bank.skill(q["skill_id"])
served = bank.practice_question(q["skill_id"], q["key"])
if skill is None or served is None or _right(bank, q, row["answers"]):
continue
choice = row["answers"].get(q["key"])
graded = bank.grade(q["skill_id"], q["key"], choice) if isinstance(choice, int) else None
mistake = graded["mistake"] if graded else None
picked = next((c["text"] for c in served["choices"] if c["index"] == choice), None)
lost.setdefault(q["skill_id"], {"id": q["skill_id"], "name": skill["name"], "missed": 0})["missed"] += 1
missed.append({"skill_id": q["skill_id"], "skill_name": skill["name"], "question": served["question"], "picked": picked,
"mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
right = sum(1 for q in row["questions"] if _right(bank, q, row["answers"]))
return {"id": row["id"], "grade": row["grade"], "right": right, "total": len(row["questions"]), "timed_out": row["timed_out"],
"lesson": row["scope"], "lost_skills": list(lost.values()), "missed": missed}