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: try native table parsers first on text-layer PDFs,
49
- otherwise fall back to the LLM vision parser.
50
 
51
- Questions data never tries native parsers — question text rarely fits a
52
- clean table grid.
 
 
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"]