File size: 9,045 Bytes
ad5d4b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce0b71
 
 
 
ad5d4b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce0b71
 
 
 
 
 
ad5d4b6
 
 
1ce0b71
ad5d4b6
1ce0b71
 
ad5d4b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce0b71
ad5d4b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce0b71
 
ad5d4b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c4a722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad5d4b6
 
 
1ce0b71
 
 
ad5d4b6
 
 
 
 
 
1ce0b71
ad5d4b6
 
1ce0b71
 
ad5d4b6
 
 
 
 
 
 
 
 
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""Canonical answer-grid data model.

Single source of truth for per-student report generation:
- total_questions (N)
- official_answers: 1xN vector of letters
- students: m rows of N letters (or None for blank)
- questions: N question metadata entries for rendering wrong blocks

Single-choice only. Letters A-Z. All comparisons are case-insensitive trim.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Optional

_BLANK_MARKERS = {"", "n/a", "none", "null"}
_CORRECT_MARKER = "="
# Legacy dash markers are accepted as input and converted to CORRECT_MARKER
_DASH_MARKERS = {"-", "–", "β€”"}
_LETTER_RE = re.compile(r"^[A-Z]$")


@dataclass(frozen=True)
class QuestionMeta:
    number: int
    text: str
    options: tuple[str, ...]


@dataclass(frozen=True)
class StudentRow:
    name: str
    answers: tuple[Optional[str], ...]


@dataclass(frozen=True)
class AnswerGrid:
    total_questions: int
    official_answers: tuple[Optional[str], ...]
    students: tuple[StudentRow, ...]
    questions: tuple[QuestionMeta, ...]


@dataclass(frozen=True)
class WrongAnswer:
    question: QuestionMeta
    student_answer: Optional[str]
    correct_answer: str


def normalize_letter(raw: Optional[str]) -> Optional[str]:
    """Normalize a raw cell to a letter (A-Z), the correct-marker "=", or None.

    - "=" or any legacy dash marker ("-", "–", "β€”") β†’ "=" (answered correctly)
    - Single letters with optional trailing "A) foo" β†’ uppercase letter
    - Empty/blank/unparseable β†’ None
    """
    if raw is None:
        return None
    s = str(raw).strip()
    if not s or s.casefold() in _BLANK_MARKERS:
        return None
    if s == _CORRECT_MARKER or s in _DASH_MARKERS:
        return _CORRECT_MARKER
    s = s.upper()
    # Tolerate "A)" or "A) foo" -> "A"
    if len(s) > 1 and s[0].isalpha() and s[1] in (")", ".", "、", " "):
        s = s[0]
    return s if _LETTER_RE.match(s) else None


def _normalize_row(
    answers: list, n: int
) -> tuple[Optional[str], ...]:
    """Normalize an answer row: pad/truncate to length n."""
    out: list[Optional[str]] = []
    for i in range(n):
        raw = answers[i] if i < len(answers) else None
        out.append(normalize_letter(raw))
    return tuple(out)


def from_dict(payload: dict) -> AnswerGrid:
    """Parse + validate a client-submitted answer grid."""
    if not isinstance(payload, dict):
        raise ValueError("payload must be a dict")

    n_raw = payload.get("total_questions")
    if not isinstance(n_raw, int) or n_raw < 1:
        raise ValueError("total_questions must be a positive integer")
    n = n_raw

    official = payload.get("official_answers") or []
    if not isinstance(official, list):
        raise ValueError("official_answers must be a list")
    official_tuple = _normalize_row(official, n)

    students_raw = payload.get("students") or []
    if not isinstance(students_raw, list):
        raise ValueError("students must be a list")
    students: list[StudentRow] = []
    for i, s in enumerate(students_raw):
        if not isinstance(s, dict):
            raise ValueError(f"students[{i}] must be a dict")
        name = str(s.get("name") or f"Student {i + 1}")
        answers = s.get("answers") or []
        if not isinstance(answers, list):
            raise ValueError(f"students[{i}].answers must be a list")
        students.append(StudentRow(name=name, answers=_normalize_row(answers, n)))

    questions_raw = payload.get("questions") or []
    if not isinstance(questions_raw, list):
        raise ValueError("questions must be a list")
    questions: list[QuestionMeta] = []
    for i in range(n):
        q = questions_raw[i] if i < len(questions_raw) else {}
        if not isinstance(q, dict):
            q = {}
        number = q.get("number") if isinstance(q.get("number"), int) else i + 1
        text = str(q.get("text") or "")
        opts_raw = q.get("options") or []
        opts = tuple(str(o) for o in opts_raw) if isinstance(opts_raw, list) else ()
        questions.append(QuestionMeta(number=number, text=text, options=opts))

    return AnswerGrid(
        total_questions=n,
        official_answers=official_tuple,
        students=tuple(students),
        questions=tuple(questions),
    )


def to_dict(grid: AnswerGrid) -> dict:
    """Serialize an AnswerGrid to JSON-friendly dict."""
    return {
        "total_questions": grid.total_questions,
        "official_answers": list(grid.official_answers),
        "students": [
            {"name": s.name, "answers": list(s.answers)} for s in grid.students
        ],
        "questions": [
            {"number": q.number, "text": q.text, "options": list(q.options)}
            for q in grid.questions
        ],
    }


def seed_from_parsed(
    questions_data: dict,
    student_answers_data: dict,
    teacher_answers_data: dict,
) -> AnswerGrid:
    """Synthesize an initial grid from existing parsed JSON.

    - N is derived from questions_data if present, else from teacher_answers length,
      else from the longest student row.
    - Student rows are padded/truncated to N.
    - Cells are normalized to letters, the correct-marker "=", or None.
    """
    q_list = (questions_data or {}).get("questions") or []
    t_list = (teacher_answers_data or {}).get("answers") or []
    s_list = (student_answers_data or {}).get("students") or []

    if q_list:
        n = len(q_list)
    elif t_list:
        n = max((a.get("question_number", 0) for a in t_list), default=0)
    else:
        longest = 0
        for s in s_list:
            ans = s.get("answers") or []
            nums = [a.get("question_number", 0) for a in ans]
            if nums:
                longest = max(longest, max(nums))
        n = longest

    n = max(n, 1)

    # Build official answers by question_number index
    official: list[Optional[str]] = [None] * n
    for a in t_list:
        qn = a.get("question_number")
        if isinstance(qn, int) and 1 <= qn <= n:
            official[qn - 1] = normalize_letter(a.get("correct_answer"))

    # Build student rows by question_number index. Legacy "-" markers are
    # mapped to the canonical "=" correct-marker by normalize_letter.
    students: list[StudentRow] = []
    for i, s in enumerate(s_list):
        row: list[Optional[str]] = [None] * n
        for a in s.get("answers") or []:
            qn = a.get("question_number")
            if isinstance(qn, int) and 1 <= qn <= n:
                row[qn - 1] = normalize_letter(a.get("answer"))
        name = str(s.get("name") or f"Student {i + 1}")
        students.append(StudentRow(name=name, answers=tuple(row)))

    # Build question metadata
    questions: list[QuestionMeta] = []
    for i in range(n):
        q = q_list[i] if i < len(q_list) else {}
        number = q.get("number") if isinstance(q.get("number"), int) else i + 1
        text = str(q.get("text") or "")
        opts_raw = q.get("options") or []
        opts = tuple(str(o) for o in opts_raw) if isinstance(opts_raw, list) else ()
        questions.append(QuestionMeta(number=number, text=text, options=opts))

    return AnswerGrid(
        total_questions=n,
        official_answers=tuple(official),
        students=tuple(students),
        questions=tuple(questions),
    )


def score_student(grid: AnswerGrid, student_index: int) -> tuple[int, int, float]:
    """Return (graded_questions, correct_count, score_percent) for one student.

    Only questions with a concrete official letter (not blank, not the "="
    correct-marker) are graded. A student cell of "=" counts as correct.
    """
    graded = sum(
        1 for c in grid.official_answers if c is not None and c != _CORRECT_MARKER
    )
    wrong = len(diff_student(grid, student_index))
    correct = graded - wrong
    score = round(correct / graded * 100, 1) if graded else 0.0
    return graded, correct, score


def diff_student(grid: AnswerGrid, student_index: int) -> list[WrongAnswer]:
    """Return the student's wrong answers ordered by question number.

    - Skips questions where the official key is blank (None) or "=".
    - Skips questions where the student's cell is "=" (answered correctly).
    - Otherwise flags any cell that differs from the official letter.
    """
    if student_index < 0 or student_index >= len(grid.students):
        raise IndexError(f"student_index {student_index} out of range")
    row = grid.students[student_index].answers
    wrongs: list[WrongAnswer] = []
    for i, correct in enumerate(grid.official_answers):
        if correct is None or correct == _CORRECT_MARKER:
            continue
        student_ans = row[i] if i < len(row) else None
        if student_ans == _CORRECT_MARKER:
            continue
        if student_ans != correct:
            wrongs.append(
                WrongAnswer(
                    question=grid.questions[i],
                    student_answer=student_ans,
                    correct_answer=correct,
                )
            )
    return wrongs