Spaces:
Paused
Paused
Yu Chen commited on
Commit ·
8d3145b
1
Parent(s): 1ce0b71
rebuild the pdf parsing logic
Browse files- chatkit/backend/app/file_processor.py +72 -256
- chatkit/backend/app/main.py +16 -1
- chatkit/backend/app/parsers/__init__.py +71 -0
- chatkit/backend/app/parsers/_table_common.py +217 -0
- chatkit/backend/app/parsers/base.py +111 -0
- chatkit/backend/app/parsers/llm_vision.py +231 -0
- chatkit/backend/app/parsers/pdfplumber_tables.py +55 -0
- chatkit/backend/app/parsers/pymupdf_tables.py +55 -0
- chatkit/backend/pyproject.toml +1 -0
- chatkit/backend/tests/test_native_parsers.py +76 -0
- chatkit/frontend/src/components/step1/FileUploadPanel.tsx +57 -3
- chatkit/frontend/src/components/step2/DataPreviewModal.tsx +2 -2
chatkit/backend/app/file_processor.py
CHANGED
|
@@ -1,157 +1,19 @@
|
|
| 1 |
-
"""File processing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 .
|
| 16 |
-
from .
|
| 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 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 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
|
| 205 |
-
raise ValueError("No
|
| 206 |
|
| 207 |
-
|
| 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 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
|
| 225 |
-
async def
|
| 226 |
-
|
| 227 |
-
text_parts: list[str],
|
| 228 |
data_type: str,
|
| 229 |
-
description: str
|
| 230 |
-
model: str
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 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 |
)}
|