Yu Chen commited on
Commit
73c0cf0
·
1 Parent(s): 363b160

simplify the answer parsing tool selection

Browse files
chatkit/backend/app/parsers/__init__.py CHANGED
@@ -13,14 +13,12 @@ from __future__ import annotations
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
18
 
19
  AUTO = "auto"
20
 
21
  _REGISTRY: dict[str, AnswerSheetParser] = {
22
- PyMuPDFTablesParser.name: PyMuPDFTablesParser(),
23
- PdfPlumberTablesParser.name: PdfPlumberTablesParser(),
24
  StrictCsvParser.name: StrictCsvParser(),
25
  LLMVisionParser.name: LLMVisionParser(),
26
  }
@@ -64,10 +62,9 @@ def pick_auto(file_bytes: bytes, filename: str, data_type: str) -> AnswerSheetPa
64
  if get_extension(filename) == ".csv":
65
  return _REGISTRY[StrictCsvParser.name]
66
 
67
- for parser_name in (PyMuPDFTablesParser.name, PdfPlumberTablesParser.name):
68
- parser = _REGISTRY[parser_name]
69
- if parser.can_handle(file_bytes, filename, routing_data_type):
70
- return parser
71
  return _REGISTRY[LLMVisionParser.name]
72
 
73
 
 
13
  from .base import AnswerSheetParser, ParserResult, get_extension
14
  from .csv_strict import StrictCsvParser
15
  from .llm_vision import LLMVisionParser
16
+ from .native_pdf_tables import NativePdfParser
 
17
 
18
  AUTO = "auto"
19
 
20
  _REGISTRY: dict[str, AnswerSheetParser] = {
21
+ NativePdfParser.name: NativePdfParser(),
 
22
  StrictCsvParser.name: StrictCsvParser(),
23
  LLMVisionParser.name: LLMVisionParser(),
24
  }
 
62
  if get_extension(filename) == ".csv":
63
  return _REGISTRY[StrictCsvParser.name]
64
 
65
+ native = _REGISTRY[NativePdfParser.name]
66
+ if native.can_handle(file_bytes, filename, routing_data_type):
67
+ return native
 
68
  return _REGISTRY[LLMVisionParser.name]
69
 
70
 
chatkit/backend/app/parsers/native_pdf_tables.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Facade for native-PDF table extraction.
2
+
3
+ Tries PyMuPDF first; if it raises or yields no tables, falls back to
4
+ pdfplumber. If both fail, raises a single user-facing error message
5
+ recommending the CSV path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from .base import AnswerSheetParser, ParserFile, ParserResult
13
+ from .pdfplumber_tables import PdfPlumberTablesParser
14
+ from .pymupdf_tables import PyMuPDFTablesParser
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class NativePdfParser:
20
+ name = "native_pdf_tables"
21
+ display_name = "Native PDF tables"
22
+ description = (
23
+ "Deterministic table extraction for native (digital) PDFs such as "
24
+ "考生作答一覽表 exports. Internally tries PyMuPDF first and falls "
25
+ "back to pdfplumber. Fast, no LLM call, no API quota."
26
+ )
27
+
28
+ def __init__(self) -> None:
29
+ self._engines: list[AnswerSheetParser] = [
30
+ PyMuPDFTablesParser(),
31
+ PdfPlumberTablesParser(),
32
+ ]
33
+
34
+ def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool:
35
+ return any(
36
+ engine.can_handle(file_bytes, filename, data_type)
37
+ for engine in self._engines
38
+ )
39
+
40
+ async def parse(
41
+ self,
42
+ files: list[ParserFile],
43
+ data_type: str,
44
+ description: str = "",
45
+ model: str = "gpt-5.4",
46
+ ) -> ParserResult:
47
+ errors: list[str] = []
48
+ for engine in self._engines:
49
+ try:
50
+ result = await engine.parse(files, data_type, description, model)
51
+ logger.info(
52
+ "native_pdf_tables: %s succeeded for data_type=%s",
53
+ engine.name,
54
+ data_type,
55
+ )
56
+ return ParserResult(
57
+ data=result.data,
58
+ parser_name=self.name,
59
+ notes=(
60
+ *result.notes,
61
+ f"native PDF engine: {engine.name}",
62
+ ),
63
+ )
64
+ except Exception as exc: # noqa: BLE001 — try every engine
65
+ logger.warning(
66
+ "native_pdf_tables: %s failed (%s); trying next engine",
67
+ engine.name,
68
+ exc,
69
+ )
70
+ errors.append(f"{engine.name}: {exc}")
71
+ continue
72
+
73
+ joined = "; ".join(errors) if errors else "no suitable engine"
74
+ raise ValueError(
75
+ "Unable to parse the PDF format. Please convert to CSV using our "
76
+ f"template and re-upload. (engines tried: {joined})"
77
+ )
chatkit/backend/tests/test_native_parsers.py CHANGED
@@ -1,4 +1,8 @@
1
- """Integration tests for native PDF table parsers against sample data."""
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
@@ -8,6 +12,8 @@ import pytest
8
 
9
  from app.parsers import get_parser, pick_auto
10
  from app.parsers.base import ParserFile
 
 
11
 
12
  SAMPLE_PDF = Path(
13
  "/Users/yu.c/Desktop/npo-ca-classlens-data copy/example-2/9A作答一覽表.pdf"
@@ -21,19 +27,25 @@ def sample_file() -> ParserFile:
21
  return ParserFile(filename=SAMPLE_PDF.name, content=SAMPLE_PDF.read_bytes())
22
 
23
 
 
 
 
 
 
 
 
 
 
24
  @pytest.mark.integration
25
- @pytest.mark.parametrize("parser_name", ["pymupdf_tables", "pdfplumber_tables"])
26
- async def test_parse_student_answers(sample_file: ParserFile, parser_name: str):
27
- parser = get_parser(parser_name)
28
- result = await parser.parse([sample_file], "student_answers")
29
  students = result.data["students"]
30
  assert len(students) == 7, f"expected 7 students, got {len(students)}"
31
- # Every student should have ~49 answers
32
  for s in students:
33
  assert 40 <= len(s["answers"]) <= 50
34
  assert s["name"]
35
  assert s["id"]
36
- # Spot-check a known student
37
  by_name = {s["name"]: s for s in students}
38
  assert "陳筱琳" in by_name
39
  assert by_name["陳筱琳"]["id"] == "902-13"
@@ -43,34 +55,56 @@ async def test_parse_student_answers(sample_file: ParserFile, parser_name: str):
43
 
44
 
45
  @pytest.mark.integration
46
- @pytest.mark.parametrize("parser_name", ["pymupdf_tables", "pdfplumber_tables"])
47
- async def test_parse_teacher_answers(sample_file: ParserFile, parser_name: str):
48
- parser = get_parser(parser_name)
49
- result = await parser.parse([sample_file], "teacher_answers")
50
  answers = result.data["answers"]
51
- # Answer key from the PDF is 49 letters (non-None)
52
  assert 40 <= len(answers) <= 50
53
- # From the PDF: "BAACCACACB..."
54
  first_10 = "".join(a["correct_answer"] for a in answers[:10])
55
  assert first_10 == "BAACCACACB"
56
 
57
 
 
 
 
 
 
58
  @pytest.mark.integration
59
- async def test_auto_picks_native_for_text_pdf(sample_file: ParserFile):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  chosen = pick_auto(sample_file.content, sample_file.filename, "student_answers")
61
- assert chosen.name in ("pymupdf_tables", "pdfplumber_tables")
62
 
63
 
64
  @pytest.mark.integration
65
  async def test_auto_falls_back_to_llm_for_questions(sample_file: ParserFile):
66
- # Native parsers don't handle `questions`; auto should pick LLM.
67
  chosen = pick_auto(sample_file.content, sample_file.filename, "questions")
68
  assert chosen.name == "llm_vision"
69
-
70
-
71
- @pytest.mark.integration
72
- async def test_native_parser_refuses_image_file():
73
- parser = get_parser("pymupdf_tables")
74
- png_bytes = b"\x89PNG\r\n\x1a\n" # header-only fake PNG
75
- fake = ParserFile(filename="scan.png", content=png_bytes)
76
- assert parser.can_handle(fake.content, fake.filename, "student_answers") is False
 
1
+ """Integration tests for native PDF table extraction.
2
+
3
+ Covers the public `native_pdf_tables` facade (registered) and exercises the
4
+ underlying engine classes directly to keep regression coverage on each.
5
+ """
6
 
7
  from __future__ import annotations
8
 
 
12
 
13
  from app.parsers import get_parser, pick_auto
14
  from app.parsers.base import ParserFile
15
+ from app.parsers.pdfplumber_tables import PdfPlumberTablesParser
16
+ from app.parsers.pymupdf_tables import PyMuPDFTablesParser
17
 
18
  SAMPLE_PDF = Path(
19
  "/Users/yu.c/Desktop/npo-ca-classlens-data copy/example-2/9A作答一覽表.pdf"
 
27
  return ParserFile(filename=SAMPLE_PDF.name, content=SAMPLE_PDF.read_bytes())
28
 
29
 
30
+ # ---------------------------------------------------------------------------
31
+ # Engine-level coverage (PyMuPDF + pdfplumber)
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def _engines():
36
+ return [PyMuPDFTablesParser(), PdfPlumberTablesParser()]
37
+
38
+
39
  @pytest.mark.integration
40
+ @pytest.mark.parametrize("engine", _engines(), ids=lambda e: e.name)
41
+ async def test_engine_parses_student_answers(sample_file: ParserFile, engine):
42
+ result = await engine.parse([sample_file], "student_answers")
 
43
  students = result.data["students"]
44
  assert len(students) == 7, f"expected 7 students, got {len(students)}"
 
45
  for s in students:
46
  assert 40 <= len(s["answers"]) <= 50
47
  assert s["name"]
48
  assert s["id"]
 
49
  by_name = {s["name"]: s for s in students}
50
  assert "陳筱琳" in by_name
51
  assert by_name["陳筱琳"]["id"] == "902-13"
 
55
 
56
 
57
  @pytest.mark.integration
58
+ @pytest.mark.parametrize("engine", _engines(), ids=lambda e: e.name)
59
+ async def test_engine_parses_teacher_answers(sample_file: ParserFile, engine):
60
+ result = await engine.parse([sample_file], "teacher_answers")
 
61
  answers = result.data["answers"]
 
62
  assert 40 <= len(answers) <= 50
 
63
  first_10 = "".join(a["correct_answer"] for a in answers[:10])
64
  assert first_10 == "BAACCACACB"
65
 
66
 
67
+ # ---------------------------------------------------------------------------
68
+ # Facade behavior
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
  @pytest.mark.integration
73
+ async def test_native_facade_succeeds_via_first_engine(sample_file: ParserFile):
74
+ parser = get_parser("native_pdf_tables")
75
+ result = await parser.parse([sample_file], "student_answers")
76
+ assert result.parser_name == "native_pdf_tables"
77
+ assert any("native PDF engine" in n for n in result.notes)
78
+ assert len(result.data["students"]) == 7
79
+
80
+
81
+ @pytest.mark.unit
82
+ async def test_native_facade_raises_csv_hint_when_all_engines_fail():
83
+ parser = get_parser("native_pdf_tables")
84
+ fake = ParserFile(filename="not-a.pdf", content=b"%PDF-1.4 garbage")
85
+ with pytest.raises(ValueError, match=r"(?i)please convert to csv"):
86
+ await parser.parse([fake], "student_answers")
87
+
88
+
89
+ @pytest.mark.unit
90
+ def test_native_facade_refuses_image_file():
91
+ parser = get_parser("native_pdf_tables")
92
+ png_bytes = b"\x89PNG\r\n\x1a\n"
93
+ assert parser.can_handle(png_bytes, "scan.png", "student_answers") is False
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Auto-router
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ @pytest.mark.integration
102
+ async def test_auto_picks_native_facade_for_text_pdf(sample_file: ParserFile):
103
  chosen = pick_auto(sample_file.content, sample_file.filename, "student_answers")
104
+ assert chosen.name == "native_pdf_tables"
105
 
106
 
107
  @pytest.mark.integration
108
  async def test_auto_falls_back_to_llm_for_questions(sample_file: ParserFile):
 
109
  chosen = pick_auto(sample_file.content, sample_file.filename, "questions")
110
  assert chosen.name == "llm_vision"