Spaces:
Paused
Paused
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 .
|
| 17 |
-
from .pymupdf_tables import PyMuPDFTablesParser
|
| 18 |
|
| 19 |
AUTO = "auto"
|
| 20 |
|
| 21 |
_REGISTRY: dict[str, AnswerSheetParser] = {
|
| 22 |
-
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 26 |
-
async def
|
| 27 |
-
|
| 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("
|
| 47 |
-
async def
|
| 48 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
chosen = pick_auto(sample_file.content, sample_file.filename, "student_answers")
|
| 61 |
-
assert chosen.name
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|