Download mathly2/attempts.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 6.12 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/attempts.py
- Command line
-
hf download hf://spaces/wzender/mathly/mathly2/attempts.py
-
curl -L -o attempts.py https://huggingface.co/spaces/wzender/mathly/resolve/main/mathly2/attempts.py
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} | |