Spaces:
Paused
Paused
Yu Chen commited on
Commit ·
ea3a98c
1
Parent(s): 5aa8761
custom csv upload logic
Browse files
chatkit/backend/app/parsers/__init__.py
CHANGED
|
@@ -10,7 +10,8 @@ Adding a new parser:
|
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
-
from .base import AnswerSheetParser, ParserResult
|
|
|
|
| 14 |
from .llm_vision import LLMVisionParser
|
| 15 |
from .pdfplumber_tables import PdfPlumberTablesParser
|
| 16 |
from .pymupdf_tables import PyMuPDFTablesParser
|
|
@@ -20,6 +21,7 @@ AUTO = "auto"
|
|
| 20 |
_REGISTRY: dict[str, AnswerSheetParser] = {
|
| 21 |
PyMuPDFTablesParser.name: PyMuPDFTablesParser(),
|
| 22 |
PdfPlumberTablesParser.name: PdfPlumberTablesParser(),
|
|
|
|
| 23 |
LLMVisionParser.name: LLMVisionParser(),
|
| 24 |
}
|
| 25 |
|
|
@@ -45,15 +47,19 @@ def get_parser(name: str) -> AnswerSheetParser:
|
|
| 45 |
|
| 46 |
|
| 47 |
def pick_auto(file_bytes: bytes, filename: str, data_type: str) -> AnswerSheetParser:
|
| 48 |
-
"""Auto-mode selector
|
| 49 |
-
otherwise fall back to the LLM vision parser.
|
| 50 |
|
| 51 |
-
Questions
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
"""
|
| 54 |
if data_type == "questions":
|
| 55 |
return _REGISTRY[LLMVisionParser.name]
|
| 56 |
|
|
|
|
|
|
|
|
|
|
| 57 |
for parser_name in (PyMuPDFTablesParser.name, PdfPlumberTablesParser.name):
|
| 58 |
parser = _REGISTRY[parser_name]
|
| 59 |
if parser.can_handle(file_bytes, filename, data_type):
|
|
|
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
from .base import AnswerSheetParser, ParserResult, get_extension
|
| 14 |
+
from .csv_strict import StrictCsvParser
|
| 15 |
from .llm_vision import LLMVisionParser
|
| 16 |
from .pdfplumber_tables import PdfPlumberTablesParser
|
| 17 |
from .pymupdf_tables import PyMuPDFTablesParser
|
|
|
|
| 21 |
_REGISTRY: dict[str, AnswerSheetParser] = {
|
| 22 |
PyMuPDFTablesParser.name: PyMuPDFTablesParser(),
|
| 23 |
PdfPlumberTablesParser.name: PdfPlumberTablesParser(),
|
| 24 |
+
StrictCsvParser.name: StrictCsvParser(),
|
| 25 |
LLMVisionParser.name: LLMVisionParser(),
|
| 26 |
}
|
| 27 |
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def pick_auto(file_bytes: bytes, filename: str, data_type: str) -> AnswerSheetParser:
|
| 50 |
+
"""Auto-mode selector.
|
|
|
|
| 51 |
|
| 52 |
+
- Questions always use LLM vision (text rarely fits a clean grid).
|
| 53 |
+
- .csv answer-side uploads → StrictCsvParser.
|
| 54 |
+
- Native PDF tables for student/teacher answers when there's a text layer.
|
| 55 |
+
- Otherwise fall back to LLM vision.
|
| 56 |
"""
|
| 57 |
if data_type == "questions":
|
| 58 |
return _REGISTRY[LLMVisionParser.name]
|
| 59 |
|
| 60 |
+
if get_extension(filename) == ".csv":
|
| 61 |
+
return _REGISTRY[StrictCsvParser.name]
|
| 62 |
+
|
| 63 |
for parser_name in (PyMuPDFTablesParser.name, PdfPlumberTablesParser.name):
|
| 64 |
parser = _REGISTRY[parser_name]
|
| 65 |
if parser.can_handle(file_bytes, filename, data_type):
|
chatkit/backend/app/parsers/csv_strict.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Strict CSV parser for already-tabulated answer sheets.
|
| 2 |
+
|
| 3 |
+
## Contract
|
| 4 |
+
|
| 5 |
+
A single CSV file represents the full answer table:
|
| 6 |
+
|
| 7 |
+
- Row 0: the answer key. First cell is the name marker (e.g. `正確解答` /
|
| 8 |
+
`標準答案` / `KEY`). Remaining cells are the correct letters per question.
|
| 9 |
+
- Row 1..N: one row per student. First cell is the student's name. Remaining
|
| 10 |
+
cells are the student's answer per question (single letter A-Z, `=` for
|
| 11 |
+
correct, or blank for "did not answer").
|
| 12 |
+
|
| 13 |
+
There is no separate header row. Column 0 is always the name column;
|
| 14 |
+
remaining columns are positional Q1..QN. Other column-header text is not
|
| 15 |
+
required and is ignored if present.
|
| 16 |
+
|
| 17 |
+
## Example
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
正確解答,B,A,A,C,D
|
| 21 |
+
梁祐邦,A,=,C,C,D
|
| 22 |
+
田瑜婕,=,A,C,B,D
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
When this file is uploaded to the `student_answers` zone, only rows 1..N are
|
| 26 |
+
extracted. When uploaded to the `teacher_answers` zone, only row 0 is used.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import csv
|
| 32 |
+
import io
|
| 33 |
+
|
| 34 |
+
from ..answer_grid import normalize_letter
|
| 35 |
+
from .base import AnswerSheetParser, ParserFile, ParserResult, get_extension
|
| 36 |
+
|
| 37 |
+
# Markers we treat as "this row is the answer key" if a stricter reader needs
|
| 38 |
+
# them — kept loose because the file is positional, not phrase-driven.
|
| 39 |
+
_KEY_NAME_MARKERS = {"正確解答", "標準答案", "預設標準答案", "key", "answer"}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class StrictCsvParser:
|
| 43 |
+
name = "csv_strict"
|
| 44 |
+
display_name = "CSV (strict format)"
|
| 45 |
+
description = (
|
| 46 |
+
"Direct upload of an already-tabulated answer sheet. "
|
| 47 |
+
"Row 0 = answer key (first cell '正確解答'), row 1+ = students. "
|
| 48 |
+
"Column 0 = name; remaining columns are positional Q1..QN. "
|
| 49 |
+
"Cells: A-Z, '=' for correct, or blank."
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool:
|
| 53 |
+
if data_type not in ("student_answers", "teacher_answers"):
|
| 54 |
+
return False
|
| 55 |
+
return get_extension(filename) == ".csv"
|
| 56 |
+
|
| 57 |
+
async def parse(
|
| 58 |
+
self,
|
| 59 |
+
files: list[ParserFile],
|
| 60 |
+
data_type: str,
|
| 61 |
+
description: str = "",
|
| 62 |
+
model: str = "gpt-5.4",
|
| 63 |
+
) -> ParserResult:
|
| 64 |
+
if data_type == "student_answers":
|
| 65 |
+
return _parse_students(files, self.name)
|
| 66 |
+
if data_type == "teacher_answers":
|
| 67 |
+
return _parse_teacher(files, self.name)
|
| 68 |
+
raise ValueError(f"csv_strict does not support data_type={data_type}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# Pure helpers
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _decode(file_bytes: bytes) -> str:
|
| 77 |
+
for encoding in ("utf-8-sig", "utf-8"):
|
| 78 |
+
try:
|
| 79 |
+
return file_bytes.decode(encoding)
|
| 80 |
+
except UnicodeDecodeError:
|
| 81 |
+
continue
|
| 82 |
+
raise ValueError(
|
| 83 |
+
"CSV must be UTF-8 encoded; please save as UTF-8 in your spreadsheet tool."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _read_rows(text: str) -> list[list[str]]:
|
| 88 |
+
reader = csv.reader(io.StringIO(text), skipinitialspace=True)
|
| 89 |
+
rows: list[list[str]] = []
|
| 90 |
+
for row in reader:
|
| 91 |
+
# Drop fully-blank trailing cells (Excel adds these); keep empty cells
|
| 92 |
+
# in the middle so column positions stay aligned.
|
| 93 |
+
while row and row[-1].strip() == "":
|
| 94 |
+
row = row[:-1]
|
| 95 |
+
if not row:
|
| 96 |
+
continue
|
| 97 |
+
rows.append([c.strip() for c in row])
|
| 98 |
+
return rows
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _column_count(rows: list[list[str]]) -> int:
|
| 102 |
+
return max((len(r) for r in rows), default=0)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _pad_row(row: list[str], n: int) -> list[str]:
|
| 106 |
+
if len(row) >= n:
|
| 107 |
+
return row
|
| 108 |
+
return row + [""] * (n - len(row))
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _row_to_answers(row: list[str], n_questions: int) -> list[dict]:
|
| 112 |
+
"""Project a row's answer cells to the canonical answer list."""
|
| 113 |
+
answers: list[dict] = []
|
| 114 |
+
for i in range(n_questions):
|
| 115 |
+
cell = row[i + 1] if i + 1 < len(row) else ""
|
| 116 |
+
letter = normalize_letter(cell)
|
| 117 |
+
answers.append({"question_number": i + 1, "answer": letter})
|
| 118 |
+
return answers
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _is_key_row(name_cell: str) -> bool:
|
| 122 |
+
return name_cell.strip().lower() in {m.lower() for m in _KEY_NAME_MARKERS}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# Per-data_type extractors
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _parse_students(files: list[ParserFile], parser_name: str) -> ParserResult:
|
| 131 |
+
students: list[dict] = []
|
| 132 |
+
notes: list[str] = []
|
| 133 |
+
skipped = 0
|
| 134 |
+
|
| 135 |
+
for f in files:
|
| 136 |
+
text = _decode(f.content)
|
| 137 |
+
rows = _read_rows(text)
|
| 138 |
+
if not rows:
|
| 139 |
+
raise ValueError(f"{f.filename}: CSV is empty.")
|
| 140 |
+
if len(rows) < 2:
|
| 141 |
+
raise ValueError(
|
| 142 |
+
f"{f.filename}: expected the answer key on row 1 plus at least one "
|
| 143 |
+
f"student row, got {len(rows)} row(s)."
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
n_cols = _column_count(rows)
|
| 147 |
+
if n_cols < 2:
|
| 148 |
+
raise ValueError(
|
| 149 |
+
f"{f.filename}: CSV must have at least 2 columns (name + Q1)."
|
| 150 |
+
)
|
| 151 |
+
n_questions = n_cols - 1
|
| 152 |
+
|
| 153 |
+
# Skip the answer-key row (typically row 0). If the first row's name
|
| 154 |
+
# cell does not look like a key marker, warn but still skip it so the
|
| 155 |
+
# user's positional contract is respected.
|
| 156 |
+
first_name = rows[0][0] if rows[0] else ""
|
| 157 |
+
if not _is_key_row(first_name):
|
| 158 |
+
notes.append(
|
| 159 |
+
f"{f.filename}: row 1 first column is '{first_name}', expected "
|
| 160 |
+
f"'正確解答' / '標準答案' — treating it as the key anyway."
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
for idx, row in enumerate(rows[1:], start=2):
|
| 164 |
+
padded = _pad_row(row, n_cols)
|
| 165 |
+
name = padded[0]
|
| 166 |
+
if not name:
|
| 167 |
+
skipped += 1
|
| 168 |
+
continue
|
| 169 |
+
students.append(
|
| 170 |
+
{
|
| 171 |
+
"name": name,
|
| 172 |
+
"id": "",
|
| 173 |
+
"answers": _row_to_answers(padded, n_questions),
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
if skipped:
|
| 178 |
+
notes.append(f"Skipped {skipped} row(s) with empty name.")
|
| 179 |
+
|
| 180 |
+
if not students:
|
| 181 |
+
raise ValueError("No student rows found in CSV.")
|
| 182 |
+
|
| 183 |
+
return ParserResult(
|
| 184 |
+
data={"students": students},
|
| 185 |
+
parser_name=parser_name,
|
| 186 |
+
notes=tuple(notes),
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _parse_teacher(files: list[ParserFile], parser_name: str) -> ParserResult:
|
| 191 |
+
if len(files) > 1:
|
| 192 |
+
# If multiple files come in, we keep only the first (single-key contract).
|
| 193 |
+
notes_prefix = (
|
| 194 |
+
f"Multiple files uploaded; only '{files[0].filename}' was used as the key.",
|
| 195 |
+
)
|
| 196 |
+
else:
|
| 197 |
+
notes_prefix = ()
|
| 198 |
+
|
| 199 |
+
f = files[0]
|
| 200 |
+
text = _decode(f.content)
|
| 201 |
+
rows = _read_rows(text)
|
| 202 |
+
if not rows:
|
| 203 |
+
raise ValueError(f"{f.filename}: CSV is empty.")
|
| 204 |
+
|
| 205 |
+
n_cols = _column_count(rows)
|
| 206 |
+
if n_cols < 2:
|
| 207 |
+
raise ValueError(
|
| 208 |
+
f"{f.filename}: CSV must have at least 2 columns (name + Q1)."
|
| 209 |
+
)
|
| 210 |
+
n_questions = n_cols - 1
|
| 211 |
+
|
| 212 |
+
key_row = _pad_row(rows[0], n_cols)
|
| 213 |
+
notes: list[str] = list(notes_prefix)
|
| 214 |
+
|
| 215 |
+
first_name = key_row[0]
|
| 216 |
+
if not _is_key_row(first_name):
|
| 217 |
+
notes.append(
|
| 218 |
+
f"{f.filename}: row 1 first column is '{first_name}', expected "
|
| 219 |
+
f"'正確解答' / '標準答案' — treating it as the key anyway."
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
answers: list[dict] = []
|
| 223 |
+
for i in range(n_questions):
|
| 224 |
+
cell = key_row[i + 1]
|
| 225 |
+
letter = normalize_letter(cell)
|
| 226 |
+
if letter is None:
|
| 227 |
+
raise ValueError(
|
| 228 |
+
f"{f.filename}: answer key cell at Q{i + 1} is empty or not a letter "
|
| 229 |
+
f"(got '{cell}'). The teacher key must be a single letter A-Z."
|
| 230 |
+
)
|
| 231 |
+
if letter == "=":
|
| 232 |
+
raise ValueError(
|
| 233 |
+
f"{f.filename}: answer key cell at Q{i + 1} is '='. The teacher key "
|
| 234 |
+
f"must be a concrete letter, not the correct-marker."
|
| 235 |
+
)
|
| 236 |
+
answers.append(
|
| 237 |
+
{
|
| 238 |
+
"question_number": i + 1,
|
| 239 |
+
"correct_answer": letter,
|
| 240 |
+
"explanation": None,
|
| 241 |
+
}
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
return ParserResult(
|
| 245 |
+
data={"answers": answers},
|
| 246 |
+
parser_name=parser_name,
|
| 247 |
+
notes=tuple(notes),
|
| 248 |
+
)
|
chatkit/backend/tests/test_csv_strict.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the StrictCsvParser."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app.parsers import get_parser, pick_auto
|
| 8 |
+
from app.parsers.base import ParserFile
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _file(text: str, name: str = "answers.csv", encoding: str = "utf-8") -> ParserFile:
|
| 12 |
+
return ParserFile(filename=name, content=text.encode(encoding))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.mark.unit
|
| 16 |
+
def test_can_handle_csv_only_for_answer_zones():
|
| 17 |
+
p = get_parser("csv_strict")
|
| 18 |
+
assert p.can_handle(b"", "x.csv", "student_answers") is True
|
| 19 |
+
assert p.can_handle(b"", "x.csv", "teacher_answers") is True
|
| 20 |
+
assert p.can_handle(b"", "x.csv", "questions") is False
|
| 21 |
+
assert p.can_handle(b"", "x.pdf", "student_answers") is False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.mark.unit
|
| 25 |
+
async def test_student_answers_basic():
|
| 26 |
+
csv_text = "正確解答,B,A,A,C,D\n梁祐邦,A,=,C,C,D\n田瑜婕,=,A,C,B,D\n"
|
| 27 |
+
p = get_parser("csv_strict")
|
| 28 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 29 |
+
students = result.data["students"]
|
| 30 |
+
assert [s["name"] for s in students] == ["梁祐邦", "田瑜婕"]
|
| 31 |
+
assert [a["answer"] for a in students[0]["answers"]] == ["A", "=", "C", "C", "D"]
|
| 32 |
+
assert [a["answer"] for a in students[1]["answers"]] == ["=", "A", "C", "B", "D"]
|
| 33 |
+
assert all(a["question_number"] == i + 1 for i, a in enumerate(students[0]["answers"]))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.unit
|
| 37 |
+
async def test_student_answers_blank_cell_becomes_none():
|
| 38 |
+
csv_text = "正確解答,B,A,A\n梁祐邦,A,,C\n"
|
| 39 |
+
p = get_parser("csv_strict")
|
| 40 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 41 |
+
answers = [a["answer"] for a in result.data["students"][0]["answers"]]
|
| 42 |
+
assert answers == ["A", None, "C"]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.unit
|
| 46 |
+
async def test_student_answers_legacy_dash_becomes_equals():
|
| 47 |
+
csv_text = "正確解答,B,A,A\n梁祐邦,-,A,A\n"
|
| 48 |
+
p = get_parser("csv_strict")
|
| 49 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 50 |
+
assert result.data["students"][0]["answers"][0]["answer"] == "="
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest.mark.unit
|
| 54 |
+
async def test_student_answers_skips_empty_name_rows():
|
| 55 |
+
csv_text = "正確解答,B,A\n梁祐邦,A,A\n,B,B\n田瑜婕,B,A\n"
|
| 56 |
+
p = get_parser("csv_strict")
|
| 57 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 58 |
+
assert [s["name"] for s in result.data["students"]] == ["梁祐邦", "田瑜婕"]
|
| 59 |
+
assert any("Skipped" in n for n in result.notes)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@pytest.mark.unit
|
| 63 |
+
async def test_student_answers_warns_when_first_name_isnt_marker():
|
| 64 |
+
csv_text = "梁祐邦,A,B\n田瑜婕,B,A\n"
|
| 65 |
+
p = get_parser("csv_strict")
|
| 66 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 67 |
+
# First row is treated as the key — student count is 1
|
| 68 |
+
assert len(result.data["students"]) == 1
|
| 69 |
+
assert result.data["students"][0]["name"] == "田瑜婕"
|
| 70 |
+
assert any("treating it as the key" in n for n in result.notes)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@pytest.mark.unit
|
| 74 |
+
async def test_student_answers_rejects_too_few_rows():
|
| 75 |
+
csv_text = "正確解答,A,B,C\n"
|
| 76 |
+
p = get_parser("csv_strict")
|
| 77 |
+
with pytest.raises(ValueError, match="at least one student"):
|
| 78 |
+
await p.parse([_file(csv_text)], "student_answers")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.unit
|
| 82 |
+
async def test_student_answers_rejects_single_column():
|
| 83 |
+
csv_text = "正確解答\n梁祐邦\n"
|
| 84 |
+
p = get_parser("csv_strict")
|
| 85 |
+
with pytest.raises(ValueError, match="at least 2 columns"):
|
| 86 |
+
await p.parse([_file(csv_text)], "student_answers")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.unit
|
| 90 |
+
async def test_student_answers_pads_short_rows():
|
| 91 |
+
csv_text = "正確解答,B,A,A,C\n梁祐邦,A,=\n"
|
| 92 |
+
p = get_parser("csv_strict")
|
| 93 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 94 |
+
answers = [a["answer"] for a in result.data["students"][0]["answers"]]
|
| 95 |
+
assert answers == ["A", "=", None, None]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@pytest.mark.unit
|
| 99 |
+
async def test_student_answers_arbitrary_other_column_text_ignored():
|
| 100 |
+
"""Header text in row 0 cells beyond col 0 doesn't matter — col headers
|
| 101 |
+
are not validated, the file is positional."""
|
| 102 |
+
csv_text = "正確解答,B,A,A\n梁祐邦,A,=,C\n"
|
| 103 |
+
p = get_parser("csv_strict")
|
| 104 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 105 |
+
assert len(result.data["students"]) == 1
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@pytest.mark.unit
|
| 109 |
+
async def test_teacher_answers_basic():
|
| 110 |
+
csv_text = "正確解答,B,A,A,C,D\n梁祐邦,A,=,C,C,D\n"
|
| 111 |
+
p = get_parser("csv_strict")
|
| 112 |
+
result = await p.parse([_file(csv_text)], "teacher_answers")
|
| 113 |
+
answers = result.data["answers"]
|
| 114 |
+
assert [a["correct_answer"] for a in answers] == ["B", "A", "A", "C", "D"]
|
| 115 |
+
assert [a["question_number"] for a in answers] == [1, 2, 3, 4, 5]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@pytest.mark.unit
|
| 119 |
+
async def test_teacher_answers_rejects_equals_in_key():
|
| 120 |
+
csv_text = "正確解答,B,=,A\n梁祐邦,A,A,A\n"
|
| 121 |
+
p = get_parser("csv_strict")
|
| 122 |
+
with pytest.raises(ValueError, match="must be a concrete letter"):
|
| 123 |
+
await p.parse([_file(csv_text)], "teacher_answers")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@pytest.mark.unit
|
| 127 |
+
async def test_teacher_answers_rejects_blank_cell_in_key():
|
| 128 |
+
csv_text = "正確解答,B,,A\n梁祐邦,A,A,A\n"
|
| 129 |
+
p = get_parser("csv_strict")
|
| 130 |
+
with pytest.raises(ValueError, match="empty or not a letter"):
|
| 131 |
+
await p.parse([_file(csv_text)], "teacher_answers")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@pytest.mark.unit
|
| 135 |
+
async def test_utf8_bom_accepted():
|
| 136 |
+
csv_text = "正確解答,B,A\n梁祐邦,A,A\n"
|
| 137 |
+
file = ParserFile(filename="answers.csv", content=("" + csv_text).encode("utf-8"))
|
| 138 |
+
p = get_parser("csv_strict")
|
| 139 |
+
result = await p.parse([file], "student_answers")
|
| 140 |
+
assert result.data["students"][0]["name"] == "梁祐邦"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@pytest.mark.unit
|
| 144 |
+
async def test_non_utf8_raises_clear_error():
|
| 145 |
+
csv_text = "正確解答,B,A\n梁祐邦,A,A\n"
|
| 146 |
+
file = ParserFile(filename="answers.csv", content=csv_text.encode("big5"))
|
| 147 |
+
p = get_parser("csv_strict")
|
| 148 |
+
with pytest.raises(ValueError, match="UTF-8"):
|
| 149 |
+
await p.parse([file], "student_answers")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@pytest.mark.unit
|
| 153 |
+
def test_pick_auto_routes_csv_to_strict():
|
| 154 |
+
chosen = pick_auto(b"", "answers.csv", "student_answers")
|
| 155 |
+
assert chosen.name == "csv_strict"
|
| 156 |
+
chosen = pick_auto(b"", "answers.csv", "teacher_answers")
|
| 157 |
+
assert chosen.name == "csv_strict"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@pytest.mark.unit
|
| 161 |
+
def test_pick_auto_questions_never_uses_csv():
|
| 162 |
+
chosen = pick_auto(b"", "answers.csv", "questions")
|
| 163 |
+
assert chosen.name == "llm_vision"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@pytest.mark.unit
|
| 167 |
+
async def test_lowercase_marker_accepted():
|
| 168 |
+
csv_text = "key,B,A\n梁祐邦,A,A\n"
|
| 169 |
+
p = get_parser("csv_strict")
|
| 170 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 171 |
+
# Should NOT warn — "key" is in the marker set
|
| 172 |
+
assert not any("treating it as the key" in n for n in result.notes)
|
| 173 |
+
assert len(result.data["students"]) == 1
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@pytest.mark.unit
|
| 177 |
+
async def test_excel_trailing_empty_columns_ignored():
|
| 178 |
+
csv_text = "正確解答,B,A,A,,\n梁祐邦,A,=,C,,\n"
|
| 179 |
+
p = get_parser("csv_strict")
|
| 180 |
+
result = await p.parse([_file(csv_text)], "student_answers")
|
| 181 |
+
answers = [a["answer"] for a in result.data["students"][0]["answers"]]
|
| 182 |
+
assert answers == ["A", "=", "C"]
|