Spaces:
Paused
Paused
| """Strict CSV parser for already-tabulated answer sheets. | |
| ## Contract | |
| A single CSV file represents the full answer table: | |
| - Row 0: header row (column titles like `Q1, Q2, ...` — content is ignored, | |
| only used to determine column count). | |
| - Row 1: the answer key. First cell is the name marker (e.g. `正確解答` / | |
| `標準答案` / `KEY`). Remaining cells are the correct letters per question. | |
| - Row 2..N: one row per student. First cell is the student's name. Remaining | |
| cells are the student's answer per question (single letter A-Z, `=` for | |
| correct, or blank for "did not answer"). | |
| Column 0 is always the name column; remaining columns are positional | |
| Q1..QN. Header text in row 0 is not required and is ignored if present. | |
| ## Example | |
| ``` | |
| ,Q1,Q2,Q3,Q4,Q5 | |
| 正確解答,B,A,A,C,D | |
| 梁祐邦,A,=,C,C,D | |
| 田瑜婕,=,A,C,B,D | |
| ``` | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import io | |
| from ..answer_grid import normalize_letter | |
| from .base import AnswerSheetParser, ParserFile, ParserResult, get_extension | |
| # Markers we treat as "this row is the answer key" if a stricter reader needs | |
| # them — kept loose because the file is positional, not phrase-driven. | |
| _KEY_NAME_MARKERS = {"正確解答", "標準答案", "預設標準答案", "key", "answer"} | |
| class StrictCsvParser: | |
| name = "csv_strict" | |
| display_name = "CSV (strict format)" | |
| description = ( | |
| "Direct upload of an already-tabulated answer sheet. " | |
| "Row 0 = header (Q1, Q2, ... — ignored), row 1 = answer key " | |
| "(first cell '正確解答'), row 2+ = students. " | |
| "Column 0 = name; remaining columns are positional Q1..QN. " | |
| "Cells: A-Z, '=' for correct, or blank." | |
| ) | |
| def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool: | |
| if data_type not in ("student_answers", "teacher_answers"): | |
| return False | |
| return get_extension(filename) == ".csv" | |
| async def parse( | |
| self, | |
| files: list[ParserFile], | |
| data_type: str, | |
| description: str = "", | |
| model: str = "gpt-5.4", | |
| ) -> ParserResult: | |
| if data_type == "student_answers": | |
| return _parse_students(files, self.name) | |
| if data_type == "teacher_answers": | |
| return _parse_teacher(files, self.name) | |
| raise ValueError(f"csv_strict does not support data_type={data_type}") | |
| # --------------------------------------------------------------------------- | |
| # Pure helpers | |
| # --------------------------------------------------------------------------- | |
| def _decode(file_bytes: bytes) -> str: | |
| for encoding in ("utf-8-sig", "utf-8"): | |
| try: | |
| return file_bytes.decode(encoding) | |
| except UnicodeDecodeError: | |
| continue | |
| raise ValueError( | |
| "CSV must be UTF-8 encoded; please save as UTF-8 in your spreadsheet tool." | |
| ) | |
| def _read_rows(text: str) -> list[list[str]]: | |
| reader = csv.reader(io.StringIO(text), skipinitialspace=True) | |
| rows: list[list[str]] = [] | |
| for row in reader: | |
| # Drop fully-blank trailing cells (Excel adds these); keep empty cells | |
| # in the middle so column positions stay aligned. | |
| while row and row[-1].strip() == "": | |
| row = row[:-1] | |
| if not row: | |
| continue | |
| rows.append([c.strip() for c in row]) | |
| return rows | |
| def _column_count(rows: list[list[str]]) -> int: | |
| return max((len(r) for r in rows), default=0) | |
| def _pad_row(row: list[str], n: int) -> list[str]: | |
| if len(row) >= n: | |
| return row | |
| return row + [""] * (n - len(row)) | |
| def _row_to_answers(row: list[str], n_questions: int) -> list[dict]: | |
| """Project a row's answer cells to the canonical answer list.""" | |
| answers: list[dict] = [] | |
| for i in range(n_questions): | |
| cell = row[i + 1] if i + 1 < len(row) else "" | |
| letter = normalize_letter(cell) | |
| answers.append({"question_number": i + 1, "answer": letter}) | |
| return answers | |
| def _is_key_row(name_cell: str) -> bool: | |
| return name_cell.strip().lower() in {m.lower() for m in _KEY_NAME_MARKERS} | |
| CORRECT_MARKER = "=" | |
| def _has_any_equals(students: list[dict]) -> bool: | |
| """True if any student cell holds the correct-marker.""" | |
| for student in students: | |
| for ans in student.get("answers") or []: | |
| if ans.get("answer") == CORRECT_MARKER: | |
| return True | |
| return False | |
| def _auto_fill_correct_marker( | |
| students: list[dict], | |
| official_letters: list[str | None], | |
| ) -> tuple[list[dict], int]: | |
| """Replace student cells matching the official letter with '='. | |
| Pure: returns a new list of student dicts, never mutates input. | |
| Returns (new_students, n_replaced). | |
| """ | |
| new_students: list[dict] = [] | |
| replaced = 0 | |
| for student in students: | |
| new_answers: list[dict] = [] | |
| for ans in student.get("answers") or []: | |
| qn = ans.get("question_number") | |
| value = ans.get("answer") | |
| idx = qn - 1 if isinstance(qn, int) else -1 | |
| key_letter = ( | |
| official_letters[idx] | |
| if 0 <= idx < len(official_letters) | |
| else None | |
| ) | |
| if ( | |
| value is not None | |
| and value != CORRECT_MARKER | |
| and key_letter is not None | |
| and value == key_letter | |
| ): | |
| new_answers.append({**ans, "answer": CORRECT_MARKER}) | |
| replaced += 1 | |
| else: | |
| new_answers.append(dict(ans)) | |
| new_students.append({**student, "answers": new_answers}) | |
| return new_students, replaced | |
| # --------------------------------------------------------------------------- | |
| # Per-data_type extractors | |
| # --------------------------------------------------------------------------- | |
| def _parse_students(files: list[ParserFile], parser_name: str) -> ParserResult: | |
| students: list[dict] = [] | |
| notes: list[str] = [] | |
| skipped = 0 | |
| for f in files: | |
| text = _decode(f.content) | |
| rows = _read_rows(text) | |
| if not rows: | |
| raise ValueError(f"{f.filename}: CSV is empty.") | |
| if len(rows) < 3: | |
| raise ValueError( | |
| f"{f.filename}: expected a header row, an answer-key row, and at " | |
| f"least one student row (got {len(rows)} row(s))." | |
| ) | |
| n_cols = _column_count(rows) | |
| if n_cols < 2: | |
| raise ValueError( | |
| f"{f.filename}: CSV must have at least 2 columns (name + Q1)." | |
| ) | |
| n_questions = n_cols - 1 | |
| # rows[0] is the column header (Q1..QN). It's ignored beyond column count. | |
| # rows[1] should be the answer key — warn if the name cell isn't a marker. | |
| key_row = _pad_row(rows[1], n_cols) | |
| key_name = key_row[0] | |
| if not _is_key_row(key_name): | |
| notes.append( | |
| f"{f.filename}: row 2 first column is '{key_name}', expected " | |
| f"'正確解答' / '標準答案' — treating it as the key anyway." | |
| ) | |
| official_letters = [ | |
| normalize_letter(key_row[i + 1]) for i in range(n_questions) | |
| ] | |
| file_students: list[dict] = [] | |
| for row in rows[2:]: | |
| padded = _pad_row(row, n_cols) | |
| name = padded[0] | |
| if not name: | |
| skipped += 1 | |
| continue | |
| file_students.append( | |
| { | |
| "name": name, | |
| "id": "", | |
| "answers": _row_to_answers(padded, n_questions), | |
| } | |
| ) | |
| # If this file's student section has no '=' anywhere, infer correctness | |
| # by comparing each cell to the official key letter. | |
| if file_students and not _has_any_equals(file_students): | |
| file_students, replaced = _auto_fill_correct_marker( | |
| file_students, official_letters | |
| ) | |
| if replaced: | |
| notes.append( | |
| f"{f.filename}: auto-converted {replaced} correct answer(s) " | |
| f"to '=' (no '=' marker was found in this file)." | |
| ) | |
| students.extend(file_students) | |
| if skipped: | |
| notes.append(f"Skipped {skipped} row(s) with empty name.") | |
| if not students: | |
| raise ValueError("No student rows found in CSV.") | |
| return ParserResult( | |
| data={"students": students}, | |
| parser_name=parser_name, | |
| notes=tuple(notes), | |
| ) | |
| def _parse_teacher(files: list[ParserFile], parser_name: str) -> ParserResult: | |
| if len(files) > 1: | |
| # If multiple files come in, we keep only the first (single-key contract). | |
| notes_prefix = ( | |
| f"Multiple files uploaded; only '{files[0].filename}' was used as the key.", | |
| ) | |
| else: | |
| notes_prefix = () | |
| f = files[0] | |
| text = _decode(f.content) | |
| rows = _read_rows(text) | |
| if not rows: | |
| raise ValueError(f"{f.filename}: CSV is empty.") | |
| if len(rows) < 2: | |
| raise ValueError( | |
| f"{f.filename}: expected a header row plus an answer-key row " | |
| f"(got {len(rows)} row(s))." | |
| ) | |
| n_cols = _column_count(rows) | |
| if n_cols < 2: | |
| raise ValueError( | |
| f"{f.filename}: CSV must have at least 2 columns (name + Q1)." | |
| ) | |
| n_questions = n_cols - 1 | |
| # Row 0 is the column header; row 1 is the answer key. | |
| key_row = _pad_row(rows[1], n_cols) | |
| notes: list[str] = list(notes_prefix) | |
| first_name = key_row[0] | |
| if not _is_key_row(first_name): | |
| notes.append( | |
| f"{f.filename}: row 2 first column is '{first_name}', expected " | |
| f"'正確解答' / '標準答案' — treating it as the key anyway." | |
| ) | |
| answers: list[dict] = [] | |
| for i in range(n_questions): | |
| cell = key_row[i + 1] | |
| letter = normalize_letter(cell) | |
| if letter is None: | |
| raise ValueError( | |
| f"{f.filename}: answer key cell at Q{i + 1} is empty or not a letter " | |
| f"(got '{cell}'). The teacher key must be a single letter A-Z." | |
| ) | |
| if letter == "=": | |
| raise ValueError( | |
| f"{f.filename}: answer key cell at Q{i + 1} is '='. The teacher key " | |
| f"must be a concrete letter, not the correct-marker." | |
| ) | |
| answers.append( | |
| { | |
| "question_number": i + 1, | |
| "correct_answer": letter, | |
| "explanation": None, | |
| } | |
| ) | |
| return ParserResult( | |
| data={"answers": answers}, | |
| parser_name=parser_name, | |
| notes=tuple(notes), | |
| ) | |