Yu Chen commited on
Commit
8d3145b
·
1 Parent(s): 1ce0b71

rebuild the pdf parsing logic

Browse files
chatkit/backend/app/file_processor.py CHANGED
@@ -1,157 +1,19 @@
1
- """File processing: upload files and extract structured Q&A via GPT-4 Vision / GPT-4o."""
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- import base64
6
  import json
7
- import mimetypes
8
- from pathlib import Path
9
- from typing import Optional
10
 
11
- import fitz # pymupdf
12
- from openai import AsyncOpenAI
13
  from fastapi import UploadFile
14
 
15
- from .config import get_settings
16
- from .database import save_parsed_data, delete_parsed_data
17
-
18
-
19
- # Extensions that should be read as text, not sent as images
20
- TEXT_EXTENSIONS = {".txt", ".csv", ".tsv", ".json", ".xml", ".md", ".html", ".htm", ".log"}
21
-
22
- # Image extensions with their MIME types
23
- IMAGE_MIME = {
24
- ".png": "image/png",
25
- ".jpg": "image/jpeg",
26
- ".jpeg": "image/jpeg",
27
- ".gif": "image/gif",
28
- ".bmp": "image/bmp",
29
- ".webp": "image/webp",
30
- ".tiff": "image/tiff",
31
- ".tif": "image/tiff",
32
- }
33
-
34
- NO_FABRICATION_RULE = """
35
- CRITICAL: Only extract data that is ACTUALLY present in the provided content.
36
- Do NOT invent, fabricate, or hallucinate any data.
37
- If the content is empty, unreadable, or does not contain the expected data, return an empty result like {"questions": []} or {"students": []} or {"answers": []}.
38
- """
39
-
40
- IMAGE_DESCRIPTION_RULE = """
41
- IMPORTANT - Images, Diagrams, Charts, and Figures:
42
- - If a question contains or references an image, diagram, chart, graph, map, table, or any visual element, you MUST describe it in detail as part of the question text.
43
- - Use the format: [圖片描述: ...detailed description...] inserted where the image appears in the question.
44
- - The description must be detailed enough that someone who CANNOT see the image can still fully understand and answer the question.
45
- - Include all relevant data points, labels, axes, values, shapes, positions, colors, relationships, and any text visible in the image.
46
- - For charts/graphs: describe the type, axes labels, data values, trends, and all visible data points.
47
- - For diagrams: describe all components, connections, labels, measurements, and spatial relationships.
48
- - For maps: describe locations, boundaries, labels, and any marked features.
49
- - For tables embedded as images: transcribe the full table content.
50
- """
51
-
52
- EXTRACTION_PROMPTS = {
53
- "questions": """You are an exam data extraction expert. Extract all exam questions from the provided content.
54
-
55
- This system supports SINGLE-CHOICE questions only (one correct letter: A, B, C, D, ...).
56
-
57
- Return a JSON object with this structure:
58
- {
59
- "questions": [
60
- {
61
- "number": 1,
62
- "text": "the full question text (including [圖片描述: ...] for any images/diagrams)",
63
- "options": ["A) ...", "B) ...", "C) ...", "D) ..."]
64
- }
65
- ]
66
- }
67
-
68
- Rules:
69
- - Extract EVERY question you can see
70
- - Preserve the original language of the questions
71
- - Always include the options exactly as written; every question must have options
72
- - Do NOT include fields other than number/text/options
73
- """ + IMAGE_DESCRIPTION_RULE + NO_FABRICATION_RULE + "- Return valid JSON only",
74
-
75
- "student_answers": """You are an exam data extraction expert. Extract all student answers from the provided content.
76
-
77
- This system supports SINGLE-CHOICE questions only.
78
-
79
- Return a JSON object with this structure:
80
- {
81
- "students": [
82
- {
83
- "name": "student name or ID",
84
- "id": "student ID if visible",
85
- "answers": [
86
- {
87
- "question_number": 1,
88
- "answer": "A" | "B" | "C" | ... | "=" | null
89
- }
90
- ]
91
- }
92
- ]
93
- }
94
-
95
- Rules:
96
- - Extract answers for EVERY student visible
97
- - Answer format (use exactly one of these):
98
- - A single uppercase letter (A, B, C, D, ...) — the letter the student chose
99
- - "=" — the student answered correctly (e.g., source uses "-" / "✓" / "correct" / matches the answer key)
100
- - null — the student left the question blank
101
- - Use the student's name or ID as shown in the document
102
- """ + NO_FABRICATION_RULE + "- Return valid JSON only",
103
-
104
- "teacher_answers": """You are an exam data extraction expert. Extract the correct/model answers (answer key) from the provided content.
105
-
106
- This system supports SINGLE-CHOICE questions only.
107
-
108
- Return a JSON object with this structure:
109
- {
110
- "answers": [
111
- {
112
- "question_number": 1,
113
- "correct_answer": "A" | "B" | "C" | ...,
114
- "explanation": "explanation if provided, otherwise null"
115
- }
116
- ]
117
- }
118
-
119
- Rules:
120
- - Extract the correct answer for EVERY question
121
- - correct_answer MUST be a single uppercase letter (A, B, C, D, ...)
122
- - Include explanations if they are provided
123
- """ + NO_FABRICATION_RULE + "- Return valid JSON only",
124
- }
125
-
126
-
127
- def pdf_to_images(pdf_bytes: bytes) -> list[bytes]:
128
- """Convert PDF bytes to a list of PNG image bytes, one per page."""
129
- doc = fitz.open(stream=pdf_bytes, filetype="pdf")
130
- images = []
131
- for page in doc:
132
- # Render at 2x resolution for better OCR quality
133
- pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
134
- images.append(pix.tobytes("png"))
135
- doc.close()
136
- return images
137
-
138
-
139
- def try_read_as_text(file_bytes: bytes, filename: str) -> Optional[str]:
140
- """Try to decode file bytes as text. Returns None if not text."""
141
- ext = Path(filename).suffix.lower()
142
- if ext in TEXT_EXTENSIONS:
143
- for encoding in ("utf-8", "utf-8-sig", "big5", "gb2312", "shift_jis", "latin-1"):
144
- try:
145
- return file_bytes.decode(encoding)
146
- except (UnicodeDecodeError, ValueError):
147
- continue
148
- return None
149
-
150
-
151
- def get_image_mime(filename: str) -> Optional[str]:
152
- """Get MIME type for image files. Returns None if not a known image type."""
153
- ext = Path(filename).suffix.lower()
154
- return IMAGE_MIME.get(ext)
155
 
156
 
157
  async def process_uploaded_files(
@@ -160,124 +22,78 @@ async def process_uploaded_files(
160
  session_id: int,
161
  description: str = "",
162
  model: str = "gpt-5.4",
 
163
  ) -> dict:
164
- """
165
- Process uploaded files: convert to images/text, then extract data via GPT-4o.
166
- Returns the structured data extracted.
167
- """
168
- image_parts: list[dict] = [] # image content parts for GPT
169
- text_parts: list[str] = [] # text content from text files
170
- source_files: list[str] = []
171
 
 
 
 
172
  for file in files:
173
- file_bytes = await file.read()
174
- filename = file.filename or "unknown"
175
-
176
- if not file_bytes:
177
- raise ValueError(f"File '{filename}' is empty (0 bytes). Please upload a valid file.")
178
-
179
- ext = Path(filename).suffix.lower()
180
- source_files.append(filename)
181
-
182
- if ext == ".pdf":
183
- page_images = pdf_to_images(file_bytes)
184
- for i, img in enumerate(page_images):
185
- b64 = base64.b64encode(img).decode()
186
- image_parts.append({
187
- "type": "image_url",
188
- "image_url": {"url": f"data:image/png;base64,{b64}", "detail": "high"},
189
- })
190
- else:
191
- # Try to read as text first
192
- text_content = try_read_as_text(file_bytes, filename)
193
- if text_content is not None:
194
- text_parts.append(f"--- Content from {filename} ---\n{text_content}")
195
- else:
196
- # Try as image with correct MIME type
197
- mime = get_image_mime(filename) or "image/png"
198
- b64 = base64.b64encode(file_bytes).decode()
199
- image_parts.append({
200
- "type": "image_url",
201
- "image_url": {"url": f"data:{mime};base64,{b64}", "detail": "high"},
202
- })
203
 
204
- if not image_parts and not text_parts:
205
- raise ValueError("No valid content found in uploaded files")
206
 
207
- # Extract data using selected model
208
- structured = await extract_data(
209
- image_parts=image_parts,
210
- text_parts=text_parts,
211
- data_type=data_type,
212
- description=description,
213
- model=model,
214
- )
215
 
216
- # Clear old data of this type for the session, then save new
217
  await delete_parsed_data(session_id, data_type)
218
 
219
- raw_text = json.dumps({"source_files": source_files}, ensure_ascii=False)
220
- await save_parsed_data(session_id, data_type, files[0].filename or "upload", raw_text, structured)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
- return structured
 
 
 
 
 
 
223
 
224
 
225
- async def extract_data(
226
- image_parts: list[dict],
227
- text_parts: list[str],
228
  data_type: str,
229
- description: str = "",
230
- model: str = "gpt-5.4",
231
- ) -> dict:
232
- """Call GPT-4o to extract structured data from images and/or text."""
233
- settings = get_settings()
234
- client = AsyncOpenAI(api_key=settings.openai_api_key)
235
-
236
- system_prompt = EXTRACTION_PROMPTS.get(data_type)
237
- if not system_prompt:
238
- raise ValueError(f"Unknown data_type: {data_type}")
239
-
240
- # Build user message content
241
- user_text = f"Please extract the {data_type.replace('_', ' ')} from the provided content."
242
- if description.strip():
243
- user_text = f"Context about this data: {description.strip()}\n\n{user_text}"
244
-
245
- # If we have text content, include it
246
- if text_parts:
247
- combined_text = "\n\n".join(text_parts)
248
- user_text += f"\n\n--- FILE CONTENT ---\n{combined_text}"
249
-
250
- content_parts: list[dict] = [{"type": "text", "text": user_text}]
251
- content_parts.extend(image_parts)
252
-
253
- messages = [
254
- {"role": "system", "content": system_prompt},
255
- {"role": "user", "content": content_parts},
256
- ]
257
-
258
- # Build kwargs — some models don't support all parameters
259
- kwargs: dict = {
260
- "model": model,
261
- "messages": messages,
262
- "max_completion_tokens": 16384,
263
- }
264
- # json_object response_format may not work on all models; try with it first
265
- try:
266
- response = await client.chat.completions.create(
267
- **kwargs,
268
- response_format={"type": "json_object"},
269
- )
270
- except Exception:
271
- # Fallback: no response_format, rely on prompt to return JSON
272
- response = await client.chat.completions.create(**kwargs)
273
-
274
- choice = response.choices[0]
275
- content = choice.message.content
276
- if not content:
277
- reason = getattr(choice, "finish_reason", "unknown")
278
- refusal = getattr(choice.message, "refusal", None)
279
- detail = f"finish_reason={reason}"
280
- if refusal:
281
- detail += f", refusal={refusal}"
282
- raise ValueError(f"Model returned empty content ({detail}). Try a different model.")
283
- return json.loads(content)
 
1
+ """File processing orchestrator.
2
+
3
+ Delegates extraction to a pluggable parser chosen by name or auto-picked.
4
+ Saves the parsed structured data to the database, along with metadata about
5
+ which parser produced it.
6
+ """
7
 
8
  from __future__ import annotations
9
 
 
10
  import json
 
 
 
11
 
 
 
12
  from fastapi import UploadFile
13
 
14
+ from .database import delete_parsed_data, save_parsed_data
15
+ from .parsers import AUTO, get_parser, pick_auto
16
+ from .parsers.base import ParserFile, ParserResult
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  async def process_uploaded_files(
 
22
  session_id: int,
23
  description: str = "",
24
  model: str = "gpt-5.4",
25
+ parser: str = AUTO,
26
  ) -> dict:
27
+ """Parse uploaded files and persist the structured result.
 
 
 
 
 
 
28
 
29
+ Returns the parsed data dict with a `_meta` entry describing which parser ran.
30
+ """
31
+ parser_files: list[ParserFile] = []
32
  for file in files:
33
+ content = await file.read()
34
+ if not content:
35
+ raise ValueError(f"File '{file.filename}' is empty.")
36
+ parser_files.append(
37
+ ParserFile(filename=file.filename or "unknown", content=content)
38
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ if not parser_files:
41
+ raise ValueError("No files uploaded")
42
 
43
+ result = await _run_with_fallback(parser_files, data_type, description, model, parser)
 
 
 
 
 
 
 
44
 
 
45
  await delete_parsed_data(session_id, data_type)
46
 
47
+ source_files = [f.filename for f in parser_files]
48
+ raw_text = json.dumps(
49
+ {
50
+ "source_files": source_files,
51
+ "parser": result.parser_name,
52
+ "notes": list(result.notes),
53
+ },
54
+ ensure_ascii=False,
55
+ )
56
+ await save_parsed_data(
57
+ session_id,
58
+ data_type,
59
+ parser_files[0].filename,
60
+ raw_text,
61
+ result.data,
62
+ )
63
 
64
+ return {
65
+ **result.data,
66
+ "_meta": {
67
+ "parser": result.parser_name,
68
+ "notes": list(result.notes),
69
+ },
70
+ }
71
 
72
 
73
+ async def _run_with_fallback(
74
+ parser_files: list[ParserFile],
 
75
  data_type: str,
76
+ description: str,
77
+ model: str,
78
+ parser_name: str,
79
+ ) -> ParserResult:
80
+ """Run the selected parser; in auto mode, fall back to LLM on failure."""
81
+ if parser_name == AUTO:
82
+ first_file = parser_files[0]
83
+ chosen = pick_auto(first_file.content, first_file.filename, data_type)
84
+ try:
85
+ return await chosen.parse(parser_files, data_type, description, model)
86
+ except Exception as primary_err:
87
+ if chosen.name == "llm_vision":
88
+ raise
89
+ # Fallback to LLM vision on any native-parser failure
90
+ llm = get_parser("llm_vision")
91
+ result = await llm.parse(parser_files, data_type, description, model)
92
+ return ParserResult(
93
+ data=result.data,
94
+ parser_name=result.parser_name,
95
+ notes=(f"auto-fallback from {chosen.name}: {primary_err}", *result.notes),
96
+ )
97
+
98
+ chosen = get_parser(parser_name)
99
+ return await chosen.parse(parser_files, data_type, description, model)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
chatkit/backend/app/main.py CHANGED
@@ -36,6 +36,7 @@ from .database import (
36
  from .auth import get_current_user, register_user, login_user
37
  from .file_processor import process_uploaded_files
38
  from .answer_grid import from_dict as grid_from_dict, seed_from_parsed, to_dict as grid_to_dict
 
39
  from .report_generator import generate_student_report, build_student_markdown
40
 
41
  STATIC_DIR = Path(__file__).parent.parent / "static"
@@ -125,6 +126,12 @@ async def api_get_session(session_id: int, user=Depends(get_current_user)):
125
  # File Upload & Processing Endpoints
126
  # =============================================================================
127
 
 
 
 
 
 
 
128
  @app.post("/api/sessions/{session_id}/upload")
129
  async def api_upload_files(
130
  session_id: int,
@@ -132,6 +139,7 @@ async def api_upload_files(
132
  files: list[UploadFile] = File(...),
133
  description: str = Form(""),
134
  model: str = Form("gpt-5.4"),
 
135
  user=Depends(get_current_user),
136
  ):
137
  """Upload files for a session. data_type: 'questions', 'student_answers', or 'teacher_answers'."""
@@ -143,7 +151,14 @@ async def api_upload_files(
143
  raise HTTPException(status_code=400, detail="data_type must be 'questions', 'student_answers', or 'teacher_answers'")
144
 
145
  try:
146
- structured = await process_uploaded_files(files, data_type, session_id, description=description, model=model)
 
 
 
 
 
 
 
147
  await update_session_status(session_id, "processed")
148
  # Invalidate any previously-confirmed answer grid since inputs changed
149
  await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE)
 
36
  from .auth import get_current_user, register_user, login_user
37
  from .file_processor import process_uploaded_files
38
  from .answer_grid import from_dict as grid_from_dict, seed_from_parsed, to_dict as grid_to_dict
39
+ from .parsers import AUTO, list_parsers
40
  from .report_generator import generate_student_report, build_student_markdown
41
 
42
  STATIC_DIR = Path(__file__).parent.parent / "static"
 
126
  # File Upload & Processing Endpoints
127
  # =============================================================================
128
 
129
+ @app.get("/api/parsers")
130
+ async def api_list_parsers():
131
+ """List available parser backends for the upload UI."""
132
+ return {"parsers": list_parsers()}
133
+
134
+
135
  @app.post("/api/sessions/{session_id}/upload")
136
  async def api_upload_files(
137
  session_id: int,
 
139
  files: list[UploadFile] = File(...),
140
  description: str = Form(""),
141
  model: str = Form("gpt-5.4"),
142
+ parser: str = Form(AUTO),
143
  user=Depends(get_current_user),
144
  ):
145
  """Upload files for a session. data_type: 'questions', 'student_answers', or 'teacher_answers'."""
 
151
  raise HTTPException(status_code=400, detail="data_type must be 'questions', 'student_answers', or 'teacher_answers'")
152
 
153
  try:
154
+ structured = await process_uploaded_files(
155
+ files,
156
+ data_type,
157
+ session_id,
158
+ description=description,
159
+ model=model,
160
+ parser=parser,
161
+ )
162
  await update_session_status(session_id, "processed")
163
  # Invalidate any previously-confirmed answer grid since inputs changed
164
  await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE)
chatkit/backend/app/parsers/__init__.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pluggable parser registry for answer-sheet extraction.
2
+
3
+ Auto mode: picks the best parser based on file type and data_type.
4
+ Manual mode: caller specifies parser name from `list_parsers()`.
5
+
6
+ Adding a new parser:
7
+ 1. Create a module that exposes a subclass of `AnswerSheetParser`.
8
+ 2. Import and register it below in `_REGISTRY`.
9
+ """
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
17
+
18
+ AUTO = "auto"
19
+
20
+ _REGISTRY: dict[str, AnswerSheetParser] = {
21
+ PyMuPDFTablesParser.name: PyMuPDFTablesParser(),
22
+ PdfPlumberTablesParser.name: PdfPlumberTablesParser(),
23
+ LLMVisionParser.name: LLMVisionParser(),
24
+ }
25
+
26
+
27
+ def list_parsers() -> list[dict[str, str]]:
28
+ """Return parser metadata for UI dropdowns."""
29
+ out: list[dict[str, str]] = [
30
+ {
31
+ "id": AUTO,
32
+ "name": "Auto (recommended)",
33
+ "description": "System picks the best parser for each file.",
34
+ }
35
+ ]
36
+ for p in _REGISTRY.values():
37
+ out.append({"id": p.name, "name": p.display_name, "description": p.description})
38
+ return out
39
+
40
+
41
+ def get_parser(name: str) -> AnswerSheetParser:
42
+ if name not in _REGISTRY:
43
+ raise ValueError(f"Unknown parser: {name}")
44
+ return _REGISTRY[name]
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):
60
+ return parser
61
+ return _REGISTRY[LLMVisionParser.name]
62
+
63
+
64
+ __all__ = [
65
+ "AUTO",
66
+ "AnswerSheetParser",
67
+ "ParserResult",
68
+ "get_parser",
69
+ "list_parsers",
70
+ "pick_auto",
71
+ ]
chatkit/backend/app/parsers/_table_common.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for native-table parsers.
2
+
3
+ Answer-sheet PDFs (e.g. the `考生作答一覽表` format) contain a grid where each
4
+ logical student row is split by the extractor into:
5
+ - a "header" row carrying class / seat / name (and per-row score cells)
6
+ - one or more follow-up "data" rows carrying the letter strings
7
+
8
+ Example extracted rows:
9
+ ['902', '13', '陳筱琳', '', '', '', '', ..., '51', None, None, None, '51', ...]
10
+ [None, None, None, '==CBBBA==C', '===B======', 'B=B==BB==B', ...]
11
+
12
+ We walk the rows statefully, grouping follow-up data rows into the most recent
13
+ header, then extract letters from the combined cell set.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from typing import Optional
21
+
22
+ from ..answer_grid import normalize_letter
23
+
24
+ # Markers that identify the answer-key header row.
25
+ ANSWER_KEY_MARKERS = ("預設標準答案", "標準答案", "answer key")
26
+
27
+ # Chars kept when extracting letters from a cell.
28
+ _ANSWER_CELL_ALLOWED = re.compile(r"[A-Za-z=\-–—]")
29
+
30
+ # A cell counts as a "letter cell" if most of its chars survive filtering and
31
+ # it has at least 3 chars. This prevents single-char artifacts like a class
32
+ # number from being mis-read as one letter.
33
+ _LETTER_CELL_MIN_LEN = 3
34
+
35
+
36
+ @dataclass
37
+ class _Group:
38
+ """A header row plus any trailing data rows that belong to it."""
39
+
40
+ header: list
41
+ data_rows: list[list] = field(default_factory=list)
42
+
43
+
44
+ def _cell_text(cell) -> str:
45
+ if cell is None:
46
+ return ""
47
+ return str(cell).strip()
48
+
49
+
50
+ def _is_classlike(value: str) -> bool:
51
+ return bool(re.fullmatch(r"\d{2,4}", value))
52
+
53
+
54
+ def _is_empty_cell(cell) -> bool:
55
+ return _cell_text(cell) == ""
56
+
57
+
58
+ def _is_answer_key_header(row: list) -> bool:
59
+ text = " ".join(_cell_text(c) for c in row)
60
+ return any(marker in text for marker in ANSWER_KEY_MARKERS)
61
+
62
+
63
+ def _is_student_header(row: list) -> bool:
64
+ """Header row has a class-like number + a non-empty name cell."""
65
+ first_vals = [c for c in row if not _is_empty_cell(c)]
66
+ if len(first_vals) < 2:
67
+ return False
68
+ # Look for class pattern in the leading cells
69
+ head = [_cell_text(c) for c in row[:3]]
70
+ head_nonempty = [h for h in head if h]
71
+ if not head_nonempty:
72
+ return False
73
+ if not _is_classlike(head_nonempty[0]):
74
+ return False
75
+ # At least one later value should look like a name (non-letter-cell)
76
+ return True
77
+
78
+
79
+ def _is_data_row(row: list) -> bool:
80
+ """A data row has empty name/class columns and letter cells elsewhere."""
81
+ head = row[:3]
82
+ if any(not _is_empty_cell(c) for c in head):
83
+ return False
84
+ return any(_looks_like_letter_cell(c) for c in row)
85
+
86
+
87
+ def _looks_like_letter_cell(cell) -> bool:
88
+ text = _cell_text(cell)
89
+ if len(text) < _LETTER_CELL_MIN_LEN:
90
+ return False
91
+ kept = "".join(_ANSWER_CELL_ALLOWED.findall(text))
92
+ return len(kept) >= _LETTER_CELL_MIN_LEN and len(kept) >= len(text) * 0.7
93
+
94
+
95
+ def _extract_letters_from_cell(cell) -> list[Optional[str]]:
96
+ clean = "".join(_ANSWER_CELL_ALLOWED.findall(_cell_text(cell)))
97
+ return [normalize_letter(ch) for ch in clean]
98
+
99
+
100
+ def _collect_letters(group: _Group) -> list[Optional[str]]:
101
+ """Concatenate letter cells across header + data rows, in order."""
102
+ letters: list[Optional[str]] = []
103
+ for row in (group.header, *group.data_rows):
104
+ for cell in row:
105
+ if _looks_like_letter_cell(cell):
106
+ letters.extend(_extract_letters_from_cell(cell))
107
+ return letters
108
+
109
+
110
+ def _extract_name(row: list) -> Optional[str]:
111
+ """Return the first non-empty, non-numeric cell in positions 0-4."""
112
+ for cell in row[:5]:
113
+ text = _cell_text(cell)
114
+ if not text:
115
+ continue
116
+ if re.fullmatch(r"-?\d+(\.\d+)?", text):
117
+ continue
118
+ if _looks_like_letter_cell(text):
119
+ continue
120
+ return text
121
+ return None
122
+
123
+
124
+ def _extract_student_id(row: list) -> Optional[str]:
125
+ """Return 'class-seat' from the header row when present."""
126
+ head = [_cell_text(c) for c in row[:3]]
127
+ class_val = next((h for h in head if _is_classlike(h)), None)
128
+ if not class_val:
129
+ return None
130
+ seat_val = next(
131
+ (h for h in head if h and h != class_val and re.fullmatch(r"\d{1,3}", h)),
132
+ None,
133
+ )
134
+ return f"{class_val}-{seat_val}" if seat_val else class_val
135
+
136
+
137
+ def _group_rows(rows: list[list]) -> tuple[Optional[_Group], list[_Group]]:
138
+ """Walk rows and group each header with its trailing data rows.
139
+
140
+ Returns (answer_key_group, student_groups).
141
+ """
142
+ answer_key: Optional[_Group] = None
143
+ students: list[_Group] = []
144
+ current: Optional[_Group] = None
145
+ bucket: Optional[str] = None # "key" or "student"
146
+
147
+ for row in rows:
148
+ if _is_answer_key_header(row):
149
+ current = _Group(header=row)
150
+ answer_key = current
151
+ bucket = "key"
152
+ continue
153
+ if _is_student_header(row):
154
+ current = _Group(header=row)
155
+ students.append(current)
156
+ bucket = "student"
157
+ continue
158
+ if current is not None and _is_data_row(row):
159
+ current.data_rows.append(row)
160
+ continue
161
+ # Non-data, non-header row ends the current group.
162
+ if bucket is not None and not _is_data_row(row):
163
+ # Only reset on rows with visible header-like content (avoid noise rows)
164
+ if any(not _is_empty_cell(c) for c in row[:3]):
165
+ current = None
166
+ bucket = None
167
+
168
+ return answer_key, students
169
+
170
+
171
+ def table_to_student_answers(rows: list[list]) -> dict:
172
+ _, student_groups = _group_rows(rows)
173
+ students: list[dict] = []
174
+ for g in student_groups:
175
+ name = _extract_name(g.header)
176
+ if not name:
177
+ continue
178
+ letters = _collect_letters(g)
179
+ if not letters:
180
+ continue
181
+ students.append(
182
+ {
183
+ "name": name,
184
+ "id": _extract_student_id(g.header) or "",
185
+ "answers": [
186
+ {"question_number": i + 1, "answer": letter}
187
+ for i, letter in enumerate(letters)
188
+ ],
189
+ }
190
+ )
191
+ return {"students": students}
192
+
193
+
194
+ def table_to_teacher_answers(rows: list[list]) -> dict:
195
+ key_group, _ = _group_rows(rows)
196
+ if key_group is None:
197
+ return {"answers": []}
198
+ letters = _collect_letters(key_group)
199
+ return {
200
+ "answers": [
201
+ {
202
+ "question_number": i + 1,
203
+ "correct_answer": letter,
204
+ "explanation": None,
205
+ }
206
+ for i, letter in enumerate(letters)
207
+ if letter is not None
208
+ ]
209
+ }
210
+
211
+
212
+ def table_to_data(rows: list[list], data_type: str) -> dict:
213
+ if data_type == "student_answers":
214
+ return table_to_student_answers(rows)
215
+ if data_type == "teacher_answers":
216
+ return table_to_teacher_answers(rows)
217
+ raise ValueError(f"Native table parser does not support data_type={data_type}")
chatkit/backend/app/parsers/base.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser base types and shared file utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional, Protocol
8
+
9
+ TEXT_EXTENSIONS = {".txt", ".csv", ".tsv", ".json", ".xml", ".md", ".html", ".htm", ".log"}
10
+
11
+ IMAGE_MIME = {
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".gif": "image/gif",
16
+ ".bmp": "image/bmp",
17
+ ".webp": "image/webp",
18
+ ".tiff": "image/tiff",
19
+ ".tif": "image/tiff",
20
+ }
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ParserFile:
25
+ """A file uploaded for parsing."""
26
+
27
+ filename: str
28
+ content: bytes
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ParserResult:
33
+ """Structured output of a parser run."""
34
+
35
+ data: dict
36
+ parser_name: str
37
+ notes: tuple[str, ...] = field(default_factory=tuple)
38
+
39
+
40
+ class AnswerSheetParser(Protocol):
41
+ """Protocol for answer-sheet parsers.
42
+
43
+ Implementations should be stateless — one instance per process is fine.
44
+ """
45
+
46
+ name: str
47
+ display_name: str
48
+ description: str
49
+
50
+ def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool: ...
51
+
52
+ async def parse(
53
+ self,
54
+ files: list[ParserFile],
55
+ data_type: str,
56
+ description: str = "",
57
+ model: str = "gpt-5.4",
58
+ ) -> ParserResult: ...
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Shared file helpers
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ def get_extension(filename: str) -> str:
67
+ return Path(filename).suffix.lower()
68
+
69
+
70
+ def is_pdf(filename: str) -> bool:
71
+ return get_extension(filename) == ".pdf"
72
+
73
+
74
+ def is_image(filename: str) -> bool:
75
+ return get_extension(filename) in IMAGE_MIME
76
+
77
+
78
+ def get_image_mime(filename: str) -> Optional[str]:
79
+ return IMAGE_MIME.get(get_extension(filename))
80
+
81
+
82
+ def try_read_as_text(file_bytes: bytes, filename: str) -> Optional[str]:
83
+ """Decode text files using a list of likely encodings."""
84
+ if get_extension(filename) not in TEXT_EXTENSIONS:
85
+ return None
86
+ for encoding in ("utf-8", "utf-8-sig", "big5", "gb2312", "shift_jis", "latin-1"):
87
+ try:
88
+ return file_bytes.decode(encoding)
89
+ except (UnicodeDecodeError, ValueError):
90
+ continue
91
+ return None
92
+
93
+
94
+ def pdf_has_text_layer(pdf_bytes: bytes) -> bool:
95
+ """True if the PDF has an extractable text layer (i.e., not an image scan)."""
96
+ try:
97
+ import fitz # local import so parsers without pymupdf don't die
98
+ except ImportError:
99
+ return False
100
+ try:
101
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
102
+ try:
103
+ for page in doc:
104
+ text = page.get_text("text").strip()
105
+ if text:
106
+ return True
107
+ return False
108
+ finally:
109
+ doc.close()
110
+ except Exception:
111
+ return False
chatkit/backend/app/parsers/llm_vision.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM vision parser: converts files to images/text and calls OpenAI to extract
2
+ structured JSON. Works on any input format (PDF, images, text files)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ from typing import Optional
9
+
10
+ from openai import AsyncOpenAI
11
+
12
+ from ..config import get_settings
13
+ from .base import (
14
+ AnswerSheetParser,
15
+ ParserFile,
16
+ ParserResult,
17
+ get_image_mime,
18
+ is_pdf,
19
+ try_read_as_text,
20
+ )
21
+
22
+ NO_FABRICATION_RULE = """
23
+ CRITICAL: Only extract data that is ACTUALLY present in the provided content.
24
+ Do NOT invent, fabricate, or hallucinate any data.
25
+ If the content is empty, unreadable, or does not contain the expected data, return an empty result like {"questions": []} or {"students": []} or {"answers": []}.
26
+ """
27
+
28
+ IMAGE_DESCRIPTION_RULE = """
29
+ IMPORTANT - Images, Diagrams, Charts, and Figures:
30
+ - If a question contains or references an image, diagram, chart, graph, map, table, or any visual element, you MUST describe it in detail as part of the question text.
31
+ - Use the format: [圖片描述: ...detailed description...] inserted where the image appears in the question.
32
+ - The description must be detailed enough that someone who CANNOT see the image can still fully understand and answer the question.
33
+ - Include all relevant data points, labels, axes, values, shapes, positions, colors, relationships, and any text visible in the image.
34
+ """
35
+
36
+ EXTRACTION_PROMPTS = {
37
+ "questions": """You are an exam data extraction expert. Extract all exam questions from the provided content.
38
+
39
+ This system supports SINGLE-CHOICE questions only (one correct letter: A, B, C, D, ...).
40
+
41
+ Return a JSON object with this structure:
42
+ {
43
+ "questions": [
44
+ {
45
+ "number": 1,
46
+ "text": "the full question text (including [圖片描述: ...] for any images/diagrams)",
47
+ "options": ["A) ...", "B) ...", "C) ...", "D) ..."]
48
+ }
49
+ ]
50
+ }
51
+
52
+ Rules:
53
+ - Extract EVERY question you can see
54
+ - Preserve the original language of the questions
55
+ - Always include the options exactly as written; every question must have options
56
+ - Do NOT include fields other than number/text/options
57
+ """ + IMAGE_DESCRIPTION_RULE + NO_FABRICATION_RULE + "- Return valid JSON only",
58
+ "student_answers": """You are an exam data extraction expert. Extract all student answers from the provided content.
59
+
60
+ This system supports SINGLE-CHOICE questions only.
61
+
62
+ Return a JSON object with this structure:
63
+ {
64
+ "students": [
65
+ {
66
+ "name": "student name or ID",
67
+ "id": "student ID if visible",
68
+ "answers": [
69
+ {
70
+ "question_number": 1,
71
+ "answer": "A" | "B" | "C" | ... | "=" | null
72
+ }
73
+ ]
74
+ }
75
+ ]
76
+ }
77
+
78
+ Rules:
79
+ - Extract answers for EVERY student visible
80
+ - Answer format (use exactly one of these):
81
+ - A single uppercase letter (A, B, C, D, ...) — the letter the student chose
82
+ - "=" — the student answered correctly (source uses "-" / "✓" / "correct" / matches the answer key)
83
+ - null — the student left the question blank
84
+ - Use the student's name or ID as shown in the document
85
+ """ + NO_FABRICATION_RULE + "- Return valid JSON only",
86
+ "teacher_answers": """You are an exam data extraction expert. Extract the correct/model answers (answer key) from the provided content.
87
+
88
+ This system supports SINGLE-CHOICE questions only.
89
+
90
+ Return a JSON object with this structure:
91
+ {
92
+ "answers": [
93
+ {
94
+ "question_number": 1,
95
+ "correct_answer": "A" | "B" | "C" | ...,
96
+ "explanation": "explanation if provided, otherwise null"
97
+ }
98
+ ]
99
+ }
100
+
101
+ Rules:
102
+ - Extract the correct answer for EVERY question
103
+ - correct_answer MUST be a single uppercase letter (A, B, C, D, ...)
104
+ - Include explanations if they are provided
105
+ """ + NO_FABRICATION_RULE + "- Return valid JSON only",
106
+ }
107
+
108
+
109
+ def _pdf_to_images(pdf_bytes: bytes) -> list[bytes]:
110
+ import fitz
111
+
112
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
113
+ images: list[bytes] = []
114
+ try:
115
+ for page in doc:
116
+ pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
117
+ images.append(pix.tobytes("png"))
118
+ finally:
119
+ doc.close()
120
+ return images
121
+
122
+
123
+ class LLMVisionParser:
124
+ name = "llm_vision"
125
+ display_name = "LLM Vision (GPT)"
126
+ description = "Sends images/text to GPT. Works on any format; slower and uses API quota."
127
+
128
+ def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool:
129
+ return True
130
+
131
+ async def parse(
132
+ self,
133
+ files: list[ParserFile],
134
+ data_type: str,
135
+ description: str = "",
136
+ model: str = "gpt-5.4",
137
+ ) -> ParserResult:
138
+ if data_type not in EXTRACTION_PROMPTS:
139
+ raise ValueError(f"Unknown data_type: {data_type}")
140
+
141
+ image_parts: list[dict] = []
142
+ text_parts: list[str] = []
143
+
144
+ for f in files:
145
+ if not f.content:
146
+ raise ValueError(f"File '{f.filename}' is empty.")
147
+ if is_pdf(f.filename):
148
+ for page_png in _pdf_to_images(f.content):
149
+ b64 = base64.b64encode(page_png).decode()
150
+ image_parts.append(
151
+ {
152
+ "type": "image_url",
153
+ "image_url": {
154
+ "url": f"data:image/png;base64,{b64}",
155
+ "detail": "high",
156
+ },
157
+ }
158
+ )
159
+ continue
160
+ text = try_read_as_text(f.content, f.filename)
161
+ if text is not None:
162
+ text_parts.append(f"--- Content from {f.filename} ---\n{text}")
163
+ continue
164
+ mime = get_image_mime(f.filename) or "image/png"
165
+ b64 = base64.b64encode(f.content).decode()
166
+ image_parts.append(
167
+ {
168
+ "type": "image_url",
169
+ "image_url": {
170
+ "url": f"data:{mime};base64,{b64}",
171
+ "detail": "high",
172
+ },
173
+ }
174
+ )
175
+
176
+ if not image_parts and not text_parts:
177
+ raise ValueError("No valid content found in uploaded files")
178
+
179
+ data = await _call_llm(image_parts, text_parts, data_type, description, model)
180
+ return ParserResult(data=data, parser_name=self.name)
181
+
182
+
183
+ async def _call_llm(
184
+ image_parts: list[dict],
185
+ text_parts: list[str],
186
+ data_type: str,
187
+ description: str,
188
+ model: str,
189
+ ) -> dict:
190
+ settings = get_settings()
191
+ client = AsyncOpenAI(api_key=settings.openai_api_key)
192
+
193
+ user_text = f"Please extract the {data_type.replace('_', ' ')} from the provided content."
194
+ if description.strip():
195
+ user_text = f"Context about this data: {description.strip()}\n\n{user_text}"
196
+ if text_parts:
197
+ user_text += "\n\n--- FILE CONTENT ---\n" + "\n\n".join(text_parts)
198
+
199
+ content_parts: list[dict] = [{"type": "text", "text": user_text}]
200
+ content_parts.extend(image_parts)
201
+
202
+ messages = [
203
+ {"role": "system", "content": EXTRACTION_PROMPTS[data_type]},
204
+ {"role": "user", "content": content_parts},
205
+ ]
206
+
207
+ kwargs: dict = {
208
+ "model": model,
209
+ "messages": messages,
210
+ "max_completion_tokens": 16384,
211
+ }
212
+
213
+ try:
214
+ response = await client.chat.completions.create(
215
+ **kwargs,
216
+ response_format={"type": "json_object"},
217
+ )
218
+ except Exception:
219
+ response = await client.chat.completions.create(**kwargs)
220
+
221
+ choice = response.choices[0]
222
+ content = choice.message.content
223
+ if not content:
224
+ reason = getattr(choice, "finish_reason", "unknown")
225
+ raise ValueError(f"Model returned empty content (finish_reason={reason}).")
226
+ return json.loads(content)
227
+
228
+
229
+ # Optional helper retained for callers who only need the prompt text
230
+ def extraction_prompt(data_type: str) -> Optional[str]:
231
+ return EXTRACTION_PROMPTS.get(data_type)
chatkit/backend/app/parsers/pdfplumber_tables.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native PDF table parser using pdfplumber.
2
+
3
+ Useful as a second-chance native parser when PyMuPDF misses a table."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from .base import AnswerSheetParser, ParserFile, ParserResult, is_pdf, pdf_has_text_layer
8
+ from ._table_common import table_to_data
9
+
10
+
11
+ class PdfPlumberTablesParser:
12
+ name = "pdfplumber_tables"
13
+ display_name = "pdfplumber Tables (native PDF)"
14
+ description = (
15
+ "Alternative deterministic table extractor for native PDFs. "
16
+ "Try this if PyMuPDF returns wrong or partial tables."
17
+ )
18
+
19
+ def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool:
20
+ if data_type not in ("student_answers", "teacher_answers"):
21
+ return False
22
+ if not is_pdf(filename):
23
+ return False
24
+ return pdf_has_text_layer(file_bytes)
25
+
26
+ async def parse(
27
+ self,
28
+ files: list[ParserFile],
29
+ data_type: str,
30
+ description: str = "",
31
+ model: str = "gpt-5.4",
32
+ ) -> ParserResult:
33
+ import pdfplumber
34
+ import io
35
+
36
+ all_rows: list[list] = []
37
+ notes: list[str] = []
38
+ for f in files:
39
+ if not is_pdf(f.filename):
40
+ notes.append(f"skipped non-PDF: {f.filename}")
41
+ continue
42
+ with pdfplumber.open(io.BytesIO(f.content)) as pdf:
43
+ for page_idx, page in enumerate(pdf.pages):
44
+ tables = page.extract_tables()
45
+ if not tables:
46
+ notes.append(f"{f.filename} page {page_idx + 1}: no tables found")
47
+ continue
48
+ for t in tables:
49
+ all_rows.extend(t)
50
+
51
+ if not all_rows:
52
+ raise ValueError("pdfplumber found no tables in the uploaded PDF(s).")
53
+
54
+ data = table_to_data(all_rows, data_type)
55
+ return ParserResult(data=data, parser_name=self.name, notes=tuple(notes))
chatkit/backend/app/parsers/pymupdf_tables.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native PDF table parser using PyMuPDF's page.find_tables()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import AnswerSheetParser, ParserFile, ParserResult, is_pdf, pdf_has_text_layer
6
+ from ._table_common import table_to_data
7
+
8
+
9
+ class PyMuPDFTablesParser:
10
+ name = "pymupdf_tables"
11
+ display_name = "PyMuPDF Tables (native PDF)"
12
+ description = (
13
+ "Deterministic table extraction for native PDFs (e.g. 考生作答一覽表 exports). "
14
+ "Fast, no LLM call. Only for PDFs with a text layer."
15
+ )
16
+
17
+ def can_handle(self, file_bytes: bytes, filename: str, data_type: str) -> bool:
18
+ if data_type not in ("student_answers", "teacher_answers"):
19
+ return False
20
+ if not is_pdf(filename):
21
+ return False
22
+ return pdf_has_text_layer(file_bytes)
23
+
24
+ async def parse(
25
+ self,
26
+ files: list[ParserFile],
27
+ data_type: str,
28
+ description: str = "",
29
+ model: str = "gpt-5.4",
30
+ ) -> ParserResult:
31
+ import fitz
32
+
33
+ all_rows: list[list] = []
34
+ notes: list[str] = []
35
+ for f in files:
36
+ if not is_pdf(f.filename):
37
+ notes.append(f"skipped non-PDF: {f.filename}")
38
+ continue
39
+ doc = fitz.open(stream=f.content, filetype="pdf")
40
+ try:
41
+ for page_idx, page in enumerate(doc):
42
+ tables = list(page.find_tables())
43
+ if not tables:
44
+ notes.append(f"{f.filename} page {page_idx + 1}: no tables found")
45
+ continue
46
+ for t in tables:
47
+ all_rows.extend(t.extract())
48
+ finally:
49
+ doc.close()
50
+
51
+ if not all_rows:
52
+ raise ValueError("PyMuPDF found no tables in the uploaded PDF(s).")
53
+
54
+ data = table_to_data(all_rows, data_type)
55
+ return ParserResult(data=data, parser_name=self.name, notes=tuple(notes))
chatkit/backend/pyproject.toml CHANGED
@@ -16,6 +16,7 @@ dependencies = [
16
  "pydantic>=2.0.0",
17
  "httpx>=0.25.0",
18
  "pymupdf>=1.23.0",
 
19
  ]
20
 
21
  [project.optional-dependencies]
 
16
  "pydantic>=2.0.0",
17
  "httpx>=0.25.0",
18
  "pymupdf>=1.23.0",
19
+ "pdfplumber>=0.11.0",
20
  ]
21
 
22
  [project.optional-dependencies]
chatkit/backend/tests/test_native_parsers.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for native PDF table parsers against sample data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ 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"
14
+ )
15
+
16
+
17
+ @pytest.fixture
18
+ def sample_file() -> ParserFile:
19
+ if not SAMPLE_PDF.exists():
20
+ pytest.skip(f"Sample PDF not available: {SAMPLE_PDF}")
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"
40
+ first_10 = [a["answer"] for a in by_name["陳筱琳"]["answers"][:10]]
41
+ # From the PDF: "==CBBBA==C"
42
+ assert first_10 == ["=", "=", "C", "B", "B", "B", "A", "=", "=", "C"]
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
chatkit/frontend/src/components/step1/FileUploadPanel.tsx CHANGED
@@ -1,7 +1,13 @@
1
- import { useState, useRef } from "react";
2
- import { apiUpload } from "../../lib/api";
3
  import { ModelSelector } from "../ModelSelector";
4
 
 
 
 
 
 
 
5
  interface FileUploadPanelProps {
6
  sessionId: number;
7
  parsedData: Record<string, unknown>;
@@ -60,8 +66,20 @@ export function FileUploadPanel({
60
  const [analyzing, setAnalyzing] = useState<Record<string, boolean>>({});
61
  const [errors, setErrors] = useState<Record<string, string>>({});
62
  const [model, setModel] = useState("gpt-5.4");
 
 
 
 
 
 
63
  const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
64
 
 
 
 
 
 
 
65
  const handleFileSelect = (type: string, selected: FileList | null) => {
66
  if (!selected) return;
67
  setFiles((prev) => ({ ...prev, [type]: Array.from(selected) }));
@@ -85,6 +103,7 @@ export function FileUploadPanel({
85
  formData.append("data_type", type);
86
  formData.append("description", descriptions[type] || "");
87
  formData.append("model", model);
 
88
  for (const f of typeFiles) {
89
  formData.append("files", f);
90
  }
@@ -149,6 +168,31 @@ export function FileUploadPanel({
149
  rows={2}
150
  />
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  <input
153
  ref={(el) => { fileInputRefs.current[zone.type] = el; }}
154
  type="file"
@@ -201,7 +245,17 @@ export function FileUploadPanel({
201
  {/* Parsed status + summary */}
202
  {hasParsed && (
203
  <div className="mt-3 p-3 bg-[var(--color-background)] rounded-lg">
204
- <p className="text-xs text-[var(--color-success)] font-semibold mb-1">✓ 解析完成</p>
 
 
 
 
 
 
 
 
 
 
205
  <ZoneSummary type={zone.type} data={zoneData} />
206
  </div>
207
  )}
 
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { apiGet, apiUpload } from "../../lib/api";
3
  import { ModelSelector } from "../ModelSelector";
4
 
5
+ interface ParserOption {
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ }
10
+
11
  interface FileUploadPanelProps {
12
  sessionId: number;
13
  parsedData: Record<string, unknown>;
 
66
  const [analyzing, setAnalyzing] = useState<Record<string, boolean>>({});
67
  const [errors, setErrors] = useState<Record<string, string>>({});
68
  const [model, setModel] = useState("gpt-5.4");
69
+ const [parsers, setParsers] = useState<ParserOption[]>([]);
70
+ const [parserByZone, setParserByZone] = useState<Record<string, string>>({
71
+ questions: "auto",
72
+ student_answers: "auto",
73
+ teacher_answers: "auto",
74
+ });
75
  const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
76
 
77
+ useEffect(() => {
78
+ apiGet<{ parsers: ParserOption[] }>("/api/parsers")
79
+ .then((res) => setParsers(res.parsers))
80
+ .catch(() => {});
81
+ }, []);
82
+
83
  const handleFileSelect = (type: string, selected: FileList | null) => {
84
  if (!selected) return;
85
  setFiles((prev) => ({ ...prev, [type]: Array.from(selected) }));
 
103
  formData.append("data_type", type);
104
  formData.append("description", descriptions[type] || "");
105
  formData.append("model", model);
106
+ formData.append("parser", parserByZone[type] || "auto");
107
  for (const f of typeFiles) {
108
  formData.append("files", f);
109
  }
 
168
  rows={2}
169
  />
170
 
171
+ {parsers.length > 0 && (
172
+ <div className="mb-3">
173
+ <label className="block text-xs text-[var(--color-text-muted)] mb-1">
174
+ 解析工具
175
+ </label>
176
+ <select
177
+ value={parserByZone[zone.type] || "auto"}
178
+ onChange={(e) =>
179
+ setParserByZone((prev) => ({ ...prev, [zone.type]: e.target.value }))
180
+ }
181
+ className="input !py-1.5 text-xs w-full"
182
+ title={
183
+ parsers.find((p) => p.id === (parserByZone[zone.type] || "auto"))
184
+ ?.description ?? ""
185
+ }
186
+ >
187
+ {parsers.map((p) => (
188
+ <option key={p.id} value={p.id}>
189
+ {p.name}
190
+ </option>
191
+ ))}
192
+ </select>
193
+ </div>
194
+ )}
195
+
196
  <input
197
  ref={(el) => { fileInputRefs.current[zone.type] = el; }}
198
  type="file"
 
245
  {/* Parsed status + summary */}
246
  {hasParsed && (
247
  <div className="mt-3 p-3 bg-[var(--color-background)] rounded-lg">
248
+ <p className="text-xs text-[var(--color-success)] font-semibold mb-1">
249
+ ✓ 解析完成
250
+ {(() => {
251
+ const meta = (zoneData as { _meta?: { parser?: string } })._meta;
252
+ return meta?.parser ? (
253
+ <span className="ml-2 text-[var(--color-text-muted)] font-normal">
254
+ (by {meta.parser})
255
+ </span>
256
+ ) : null;
257
+ })()}
258
+ </p>
259
  <ZoneSummary type={zone.type} data={zoneData} />
260
  </div>
261
  )}
chatkit/frontend/src/components/step2/DataPreviewModal.tsx CHANGED
@@ -52,7 +52,7 @@ export function DataPreviewModal({
52
  onClick={onClose}
53
  />
54
 
55
- <div className="relative w-full max-w-4xl max-h-[90vh] flex flex-col card overflow-hidden">
56
  <div className="flex items-center justify-between p-5 border-b border-[var(--color-border)]">
57
  <h3 className="font-display text-xl font-bold text-[var(--color-text)]">
58
  🔍 最終提示詞預覽
@@ -116,7 +116,7 @@ export function DataPreviewModal({
116
  <textarea
117
  value={preview?.markdown_prompt ?? ""}
118
  readOnly
119
- className="input !rounded-xl font-mono text-sm leading-relaxed resize-y flex-1"
120
  spellCheck={false}
121
  />
122
  )}
 
52
  onClick={onClose}
53
  />
54
 
55
+ <div className="relative w-full max-w-6xl h-[92vh] flex flex-col card overflow-hidden">
56
  <div className="flex items-center justify-between p-5 border-b border-[var(--color-border)]">
57
  <h3 className="font-display text-xl font-bold text-[var(--color-text)]">
58
  🔍 最終提示詞預覽
 
116
  <textarea
117
  value={preview?.markdown_prompt ?? ""}
118
  readOnly
119
+ className="input !rounded-xl font-mono text-sm leading-relaxed resize-none flex-1 min-h-0"
120
  spellCheck={false}
121
  />
122
  )}