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}