Files
python-template/backend/pdf_exam_parser_enhanced_v2.py
gaoandQwen-Coder 2dc75d6a63 feat: Add Claude AI exam parser and enhance dashboard UI
- Implement Claude Sonnet 4.5 based exam parser with multimodal understanding
- Create enhanced dashboard with analytics, AI insights and knowledge tree
- Add new UI components (AIAnalysisPanel, KnowledgeTreePanel, etc.)
- Improve paper viewer with question extraction and preview panels
- Add .env.example with configuration settings

Co-authored-by: Qwen-Coder <[email protected]>
2025-11-12 22:04:30 +08:00

578 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
增强版PDF试卷解析器 - 专门解决题号丢失问题
核心优化:
1. 改进题号识别正则表达式,支持更灵活的格式
2. 实现题号补全机制,自动修复遗漏的题目
3. 优化页眉页脚过滤,避免误删题目
4. 增强跨页题目处理能力
5. 添加详细的调试日志
"""
import re
import json
import math
import time
import os
import unicodedata
from collections import Counter, defaultdict
from typing import List, Dict, Optional, Tuple
import fitz # PyMuPDF
import pdfplumber
class PDFExamParserEnhancedV2:
def __init__(self):
# 全角转半角映射表
self.fullwidth_trans = str.maketrans(
"0123456789.()ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
"0123456789.()ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
)
# 改进的题号正则表达式 - 更灵活的识别
# 支持格式:1. 1) 1、 1. 一、 (严格限制,避免匹配子题)
self.question_patterns = [
# 主要题号格式(行首)- 最高优先级,匹配数字后跟标点符号
re.compile(r'^\s*(?:第\s*)?([0-9]{1,3}|[一二三四五六七八九十]{1,3})\s*[.).、]'),
# 特殊格式:数字后直接跟文字(如"1 解方程")
re.compile(r'^\s*([0-9]{1,3})\s*(?:解方程:|计算:|化简:|综合与实践|如图|已知|求证|解答|证明)'),
# 大题号格式(一、二、等)- 高优先级,严格匹配行首
re.compile(r'^\s*([一二三四五六七八九十]{1,3})\s*[、.]'),
# 解方程等特殊格式 - 最低优先级
re.compile(r'^\s*(?:解方程:|计算:|化简:)([0-9]{1,2})\s*[.]?'),
]
# 页面标题正则(可能干扰题号识别的文本)
self.page_header_patterns = [
re.compile(r'试卷第\s*\d+\s*页[,,]\s*共\s*\d+\s*页'),
re.compile(r'\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日'),
re.compile(r'学校.*班级.*姓名.*考号'),
re.compile(r'密.*封.*线.*内.*不.*要.*答.*题'),
]
# 选项正则 - 支持更多格式
self.option_patterns = [
re.compile(r'^\s*([A-H])\s*[\..、\))]'), # A. xxx
re.compile(r'([A-H])\s*[\..、\))]\s*([^A-H]*)$'), # 行内选项
re.compile(r'\(([A-H])\)\s*([^A-H]*)'), # (A) xxx
]
# 子题正则
self.subquestion_pattern = re.compile(r'\(([0-9]+)\)')
# 调试开关
self.debug = True
def log(self, message: str, level: str = "INFO"):
"""调试日志"""
if self.debug:
timestamp = time.strftime("%H:%M:%S")
print(f"[{timestamp}] [{level}] {message}")
def normalize_text(self, text: str) -> str:
"""文本标准化处理"""
if not text:
return ""
# 全角转半角
text = text.translate(self.fullwidth_trans)
# Unicode规范化
text = unicodedata.normalize("NFKC", text)
# 替换特殊空格和标点
text = text.replace("\u00A0", " ")
text = text.replace(".", ".").replace(":", ":")
text = text.replace("(", "(").replace(")", ")")
# 清理多余空格
text = re.sub(r'\s+', ' ', text)
return text.strip()
def is_page_header(self, text: str) -> bool:
"""判断是否为页眉内容"""
for pattern in self.page_header_patterns:
if pattern.search(text):
return True
return False
def extract_question_number(self, text: str) -> Optional[str]:
"""从文本中提取题号"""
# 调试:检查特定的文本
if "1 解方程" in text:
self.log(f"调试:找到匹配文本 - '{text}'")
for i, pattern in enumerate(self.question_patterns):
match = pattern.search(text)
if match:
number = match.group(1)
self.log(f"题号识别成功: '{number}' (模式{i+1}) - 原文: '{text}'")
return number
return None
def extract_lines_smart(self, pdf_path: str) -> List[Dict]:
"""智能文本行提取 - 改进页眉页脚过滤"""
try:
doc = fitz.open(pdf_path)
except Exception as e:
self.log(f"无法打开PDF文件: {pdf_path}, 错误: {str(e)}", "ERROR")
return []
lines = []
all_headers = []
all_footers = []
self.log(f"开始处理PDF: {pdf_path}")
self.log(f"总页数: {len(doc)}")
# First, try the original method with blocks
try:
# 第一轮:收集页眉页脚候选
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("blocks")
page_height = page.rect.height
for block in blocks:
# PyMuPDF blocks format: (x0, y0, x1, y1, text, block_no, block_type)
# The text is at index 4
if len(block) >= 5:
x0, y0, x1, y1 = block[0], block[1], block[2], block[3]
text = block[4]
# Skip non-text blocks
if not isinstance(text, str) or not text.strip():
continue
text = self.normalize_text(text)
if not text or len(text) < 3:
continue
# 收集页眉候选(顶部15%)
if y0 <= page_height * 0.15:
all_headers.append(text)
# 收集页脚候选(底部10%)
if y1 >= page_height * 0.90:
all_footers.append(text)
# 分析页眉页脚
header_candidates = {text for text, count in Counter(all_headers).items() if count >= 2}
footer_candidates = {text for text, count in Counter(all_footers).items() if count >= 2}
self.log(f"识别页眉: {list(header_candidates)[:5]}") # Show first 5 only
self.log(f"识别页脚: {list(footer_candidates)[:5]}") # Show first 5 only
# 第二轮:提取有效文本行
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("blocks")
page_height = page.rect.height
for block in blocks:
# PyMuPDF blocks format: (x0, y0, x1, y1, text, block_no, block_type)
if len(block) >= 5:
x0, y0, x1, y1 = block[0], block[1], block[2], block[3]
text = block[4]
# Skip non-text blocks
if not isinstance(text, str) or not text.strip():
continue
text = self.normalize_text(text)
if not text:
continue
# 过滤页眉页脚
is_header = (y0 <= page_height * 0.15 and text in header_candidates)
is_footer = (y1 >= page_height * 0.90 and text in footer_candidates)
if is_header:
self.log(f"过滤页眉: {text[:30]}...")
continue
if is_footer:
self.log(f"过滤页脚: {text[:30]}...")
continue
# 按行处理 - split the block text into individual lines
for line in text.split('\n'):
line = line.strip()
if line and len(line) > 1:
lines.append({
"page": page_num + 1,
"y": y0,
"x": x0,
"text": line
})
except Exception as e:
self.log(f"按blocks提取失败: {str(e)}, 尝试按页面提取", "WARNING")
# If blocks method fails, try alternative method
for page_num in range(len(doc)):
page = doc[page_num]
try:
# Extract text for the page
text = page.get_text()
if text:
for line_num, line in enumerate(text.split('\n')):
line = self.normalize_text(line)
if line and len(line) > 1:
lines.append({
"page": page_num + 1,
"y": line_num * 10, # Approximate y position
"x": 0, # Approximate x position
"text": line
})
except Exception as page_error:
self.log(f"页面 {page_num + 1} 提取失败: {str(page_error)}", "WARNING")
continue
doc.close()
# 按阅读顺序排序
lines.sort(key=lambda l: (l["page"], round(l["y"], 1), round(l["x"], 1)))
self.log(f"提取完成: {len(lines)} 行文本")
return lines
def restore_missing_questions(self, questions: List[Dict], original_text: str) -> List[Dict]:
"""智能补全遗漏的题目"""
if not questions:
return questions
self.log("开始题号补全检查...")
# 提取所有已识别的题号
existing_numbers = set()
for q in questions:
if q.get("number"):
try:
# 处理中文数字
if q["number"] in ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"]:
num_map = {"一":1, "二":2, "三":3, "四":4, "五":5, "六":6, "七":7, "八":8, "九":9, "十":10}
existing_numbers.add(num_map[q["number"]])
else:
existing_numbers.add(int(q["number"]))
except (ValueError, KeyError):
pass
self.log(f"已识别题号: {sorted(existing_numbers)}")
# 查找缺失的题号
if existing_numbers:
max_number = max(existing_numbers)
missing_numbers = [i for i in range(1, max_number + 1) if i not in existing_numbers]
# 只有当确实缺失题号时才补全
if missing_numbers and len(missing_numbers) > 0:
self.log(f"发现缺失题号: {missing_numbers}")
# 如果缺失的题号数量过多,说明可能是识别问题,跳过补全
if len(missing_numbers) <= max_number * 0.5: # 最多补全一半的题号
# 尝试从原文中补全缺失的题目
for missing_num in missing_numbers:
# 查找该题号在原文中的位置
pattern = rf'{missing_num}\s*[.).、]'
matches = list(re.finditer(pattern, original_text))
if matches:
# 找到第一个匹配位置
start_pos = matches[0].start()
# 查找题目结束位置(下一个题号或文件结束)
next_num_pattern = rf'{missing_num + 1}\s*[.).、]'
next_match = re.search(next_num_pattern, original_text[start_pos + 10:])
if next_match:
end_pos = start_pos + 10 + next_match.start()
else:
end_pos = len(original_text)
# 提取题目文本
question_text = original_text[start_pos:end_pos].strip()
if len(question_text) > 10: # 确保有足够的题目内容
self.log(f"补全题目{missing_num}: {question_text[:50]}...")
# 提取选项和答案
stem, options = self.extract_options_and_stem(question_text, True)
new_question = {
"number": str(missing_num),
"type": "single_choice" if options else "essay",
"text": stem,
"options": {opt["label"]: opt["text"] for opt in options},
"answer": "",
"explanation": "",
"source": "restored",
"page": 1 # 默认第1页
}
questions.append(new_question)
else:
self.log(f"缺失题号过多,跳过补全: {missing_numbers}")
# 按题号重新排序
def get_question_number(q):
try:
return int(q["number"]) if q["number"].isdigit() else 999
except:
return 999
questions.sort(key=get_question_number)
self.log(f"补全后共 {len(questions)} 道题目")
# 调试:显示所有题目的题号
question_numbers = [q.get('number', 'unknown') for q in questions]
self.log(f"所有题目题号: {question_numbers}")
return questions
def extract_options_and_stem(self, question_text: str, prefer_choice: bool) -> Tuple[str, List[Dict]]:
"""提取题干和选项"""
lines = question_text.split('\n')
options = []
stem_lines = []
i = 0
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue
# 检查是否为选项行
option_found = False
for pattern in self.option_patterns:
match = pattern.match(line)
if match:
option_found = True
label = match.group(1)
text = match.group(2) if len(match.groups()) > 1 else ""
# 如果选项文本不完整,查看下一行
if len(text) < 5 and i + 1 < len(lines):
next_line = lines[i + 1].strip()
if not re.match(r'^[A-H]', next_line) and len(next_line) > 0:
text += " " + next_line
i += 1
options.append({"label": label, "text": text.strip()})
break
if not option_found:
stem_lines.append(line)
i += 1
stem = "\n".join(stem_lines).strip()
# 清理题干中的题号
stem = re.sub(r'^\d+\s*[.).、]\s*', '', stem)
return stem, options
def process_pdf(self, pdf_path: str) -> Dict:
"""主处理函数"""
start_time = time.time()
self.log("=" * 50)
self.log("开始增强版PDF解析")
# Check if PDF file exists and is accessible
if not pdf_path:
return {"success": False, "error": "PDF路径为空"}
if not os.path.exists(pdf_path):
return {"success": False, "error": f"PDF文件不存在: {pdf_path}"}
if not os.path.isfile(pdf_path):
return {"success": False, "error": f"路径不是文件: {pdf_path}"}
try:
# 1. 提取文本行
lines = self.extract_lines_smart(pdf_path)
if not lines:
# Try one more method - extracting as plain text
self.log("尝试备用提取方法...")
try:
doc = fitz.open(pdf_path)
for page_num in range(len(doc)):
page = doc[page_num]
page_text = page.get_text()
if page_text.strip():
# Split page text into lines
for line_num, line_text in enumerate(page_text.split('\n')):
line_text = self.normalize_text(line_text.strip())
if line_text and len(line_text) > 1:
lines.append({
"page": page_num + 1,
"y": line_num * 10, # Approximate position
"x": 0,
"text": line_text
})
doc.close()
if lines:
self.log(f"备用方法成功提取 {len(lines)} 行文本")
else:
return {"success": False, "error": "PDF内容为空或无法读取文本"}
except Exception as backup_error:
self.log(f"备用方法失败: {str(backup_error)}", "ERROR")
return {"success": False, "error": "无法提取PDF文本"}
# 2. 提取原始文本用于题号补全
original_text = "\n".join([line["text"] for line in lines])
# 3. 识别题目
questions = []
current_question = None # 初始化为None
for line in lines:
text = line["text"]
# 检查是否为新题目开始
question_number = self.extract_question_number(text)
if question_number:
# 保存当前题目
if current_question and current_question.get("lines"):
question = self.parse_question_from_lines(current_question)
if question:
questions.append(question)
# 开始新题目
current_question = {
"number": question_number,
"lines": [text],
"page": line["page"],
"start_y": line["y"]
}
self.log(f"发现新题目: {question_number}")
else:
# 继续当前题目(如果有的话)
if current_question:
current_question["lines"].append(text)
# 处理最后一个题目
if current_question and current_question.get("lines"):
question = self.parse_question_from_lines(current_question)
if question:
questions.append(question)
# 4. 题号补全
questions = self.restore_missing_questions(questions, original_text)
# 5. 重新编号
questions = self.renumber_questions(questions)
end_time = time.time()
processing_time = end_time - start_time
self.log(f"解析完成: {len(questions)} 道题目,耗时 {processing_time:.2f}s")
return {
"success": True,
"questions": questions,
"stats": {
"total_questions": len(questions),
"processing_time": processing_time,
"method": "enhanced_v2",
"original_lines": len(lines)
}
}
except Exception as e:
self.log(f"解析失败: {str(e)}", "ERROR")
return {
"success": False,
"error": str(e),
"questions": [],
"stats": {"method": "enhanced_v2_failed"}
}
def parse_question_from_lines(self, question_data: Dict) -> Optional[Dict]:
"""从行数据解析题目"""
lines = question_data["lines"]
if not lines:
return None
# 合并所有文本
full_text = "\n".join(lines)
# 判断题目类型
has_options = any(re.search(r'[A-H]\s*[\..、\))]', line) for line in lines)
is_choice = has_options and len(lines) > 1
# 提取题干和选项
if is_choice:
stem, options = self.extract_options_and_stem(full_text, True)
return {
"number": question_data.get("number", ""),
"type": "single_choice",
"text": stem,
"options": {opt["label"]: opt["text"] for opt in options},
"answer": "",
"explanation": "",
"page": question_data.get("page", 1),
"source": "auto_extracted"
}
else:
return {
"number": question_data.get("number", ""),
"type": "essay" if len(full_text) > 20 else "fill_blank",
"text": full_text,
"options": {},
"answer": "",
"explanation": "",
"page": question_data.get("page", 1),
"source": "auto_extracted"
}
def renumber_questions(self, questions: List[Dict]) -> List[Dict]:
"""重新编号题目"""
for i, question in enumerate(questions):
question["number"] = str(i + 1)
return questions
# 测试函数
def test_enhanced_parser():
"""测试增强版解析器"""
parser = PDFExamParserEnhancedV2()
# 测试文件路径
test_pdf = "/Users/brunogao/work/codes/pingan/pager/backend/uploads/1/7c6df72c-3386-4909-8e6e-0471930d1284_2025年9月2日初中数学作业.pdf"
if not os.path.exists(test_pdf):
print(f"❌ 测试文件不存在: {test_pdf}")
return
print("🧪 开始测试增强版解析器...")
result = parser.process_pdf(test_pdf)
if result["success"]:
print(f"""
📊 测试结果:
- 成功: ✅
- 题目数量: {result["stats"]["total_questions"]}
- 处理时间: {result["stats"]["processing_time"]:.2f}s
- 处理方法: {result["stats"]["method"]}
""")
# 显示前3道题目
print("\n📝 前3道题目详情:")
for i, q in enumerate(result["questions"][:3]):
print(f"\n题目{q['number']}:")
print(f"类型: {q['type']}")
print(f"题干: {q['text'][:100]}...")
if q.get('options'):
print("选项:")
for label, text in q['options'].items():
print(f" {label}. {text}")
else:
print(f"❌ 测试失败: {result['error']}")
if __name__ == "__main__":
import os
test_enhanced_parser()