File size: 6,484 Bytes
a1d7d52 a7ffb22 a1d7d52 a7ffb22 5384c20 a7ffb22 5384c20 0815e01 5384c20 0815e01 a1d7d52 423224a a7ffb22 0815e01 a7ffb22 aeda545 a7ffb22 aeda545 a7ffb22 5384c20 a7ffb22 a1d7d52 a7ffb22 a1d7d52 a7ffb22 423224a a7ffb22 423224a 5384c20 a7ffb22 a1d7d52 a7ffb22 423224a a7ffb22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | """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
WRITTEN_KINDS = frozenset({"simulation"}) # no choices: the way on the board, checked by the AI or the teacher (writtentest.py)
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
return _hand_in(store, bank, row, row["due_at"], timed_out=True)
def _hand_in(store, bank, row: dict, at: datetime, timed_out: bool) -> dict:
"""Store the hand-in. A choices test is graded now; a written one (the simulation) has no grade until its boards are checked."""
if row["kind"] in WRITTEN_KINDS:
return store.save_attempt(row["id"], submitted_at=at, timed_out=timed_out, grade=None, answers=row["answers"])
value, _ = grade(bank, row["questions"], row["answers"])
saved = store.save_attempt(row["id"], submitted_at=at, timed_out=timed_out, grade=value, answers=row["answers"]) # the answers it was graded on
add_wrong_to_review(store, bank, saved, 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)
return _hand_in(store, bank, row, now, timed_out=False)
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}
|