## 主要改进 ### 🚀 Gemini 3 Pro 集成 - 支持通过 OpenRouter API 调用 Google Gemini 3 Pro Preview 模型 - 针对数学试卷优化 prompt,增强数学公式识别能力 - 自动检测 Gemini 模型并启用数学优化模式 - 设置 Gemini 3 Pro 为默认解析模型(AI_MODEL 环境变量) ### 🔧 JSON 转义修复 - 实现 fix_json_escaping() 函数处理 LaTeX 公式中的反斜杠 - 智能识别并转义 LaTeX 命令(\frac, \sqrt, \pm 等) - 保留有效的 JSON 转义序列(\", \\, \/, \uXXXX) - 解决 Gemini 返回的 LaTeX 公式 JSON 解析失败问题 ### 📐 数学解析增强 - 详细的 LaTeX 公式转换说明(分数、根式、积分、矩阵等) - 特殊数学符号支持(不等式、集合、逻辑、箭头等) - 几何图形识别和描述 - 完整的解题步骤和思路分析 ### 📝 新增文件 - GEMINI_INTEGRATION.md: 完整的集成文档 - test_gemini_math.py: Claude vs Gemini 对比测试脚本 - test_gemini_fixed.py: JSON 修复验证测试 - example_gemini_usage.py: 使用示例和最佳实践 - quick_test_gemini.sh: 快速测试脚本 ### 🎯 测试结果 - ✅ 成功解析数学试卷(5 道题,100 分) - ✅ 正确提取所有 LaTeX 公式 - ✅ 详细的解题步骤和知识点标注 - ✅ 准确的难度评估(难度 2-5) ## 配置说明 在 .env 文件中设置: ```bash AI_MODEL=google/gemini-3-pro-preview USE_CLAUDE_PARSER=true ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
849 lines
30 KiB
Python
849 lines
30 KiB
Python
"""
|
||
基于 Claude Sonnet 4.5 的智能试卷解析器
|
||
支持多学科(数学、物理、化学等)试卷的智能解析
|
||
支持多格式(PDF、Word、图片)文件处理
|
||
能够识别题目、图片、数学公式等多种内容
|
||
"""
|
||
|
||
import os
|
||
import io
|
||
import json
|
||
import base64
|
||
import tempfile
|
||
import requests
|
||
import logging
|
||
from typing import List, Dict, Optional, Tuple, Union
|
||
from pathlib import Path
|
||
import fitz # PyMuPDF
|
||
from PIL import Image
|
||
from docx import Document
|
||
from docx.oxml.table import CT_Tbl
|
||
from docx.oxml.text.paragraph import CT_P
|
||
from docx.table import _Cell, Table
|
||
from docx.text.paragraph import Paragraph
|
||
|
||
# 配置日志
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ClaudeExamParser:
|
||
"""
|
||
使用 Claude Sonnet 4.5 的智能试卷解析器
|
||
核心优势:
|
||
1. 多模态理解:支持文字、图片、公式混合内容
|
||
2. 学科识别:自动识别数学、物理、化学等学科
|
||
3. 智能分割:自动识别题目边界和题号
|
||
4. 公式识别:理解 LaTeX、图片公式等多种格式
|
||
5. 上下文理解:保持题目完整性,不会截断
|
||
"""
|
||
|
||
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
||
"""
|
||
初始化解析器
|
||
|
||
Args:
|
||
api_key: OpenRouter API密钥,如不提供则从环境变量 OPENROUTER_API_KEY 读取
|
||
model: 模型名称,可选:
|
||
- anthropic/claude-sonnet-4.5 (默认,推荐)
|
||
- google/gemini-3-pro-preview (Gemini 3 Pro,数学能力强)
|
||
- openai/gpt-4o
|
||
- openai/gpt-4-turbo
|
||
"""
|
||
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
|
||
if not self.api_key:
|
||
raise ValueError("需要提供 OpenRouter API Key,或在环境变量中设置 OPENROUTER_API_KEY")
|
||
|
||
self.api_url = "https://openrouter.ai/api/v1/chat/completions"
|
||
self.model = model or os.getenv("AI_MODEL", "anthropic/claude-sonnet-4.5")
|
||
|
||
# 检测是否使用 Gemini 模型
|
||
self.is_gemini = "gemini" in self.model.lower()
|
||
|
||
logger.info(f"🤖 使用模型: {self.model}")
|
||
if self.is_gemini:
|
||
logger.info(f"💎 检测到 Gemini 模型,将启用数学优化模式")
|
||
|
||
def _get_optimized_prompt(self, subject: Optional[str], grade: Optional[str], total_pages: int) -> str:
|
||
"""
|
||
根据学科和模型类型生成优化的 prompt
|
||
|
||
Args:
|
||
subject: 学科类型
|
||
grade: 年级
|
||
total_pages: 总页数
|
||
|
||
Returns:
|
||
优化的 prompt 文本
|
||
"""
|
||
is_math = subject and "数学" in subject
|
||
|
||
# 基础 prompt
|
||
base_prompt = f"""你是一个专业的试卷解析助手。请分析这份试卷(共{total_pages}页),并提取所有题目信息。"""
|
||
|
||
# Gemini 数学专项优化
|
||
if self.is_gemini and is_math:
|
||
return base_prompt + """
|
||
|
||
**🎯 数学试卷专项解析要求(Gemini 优化版)**
|
||
|
||
1. **题目识别**(高精度)
|
||
- 准确识别所有题型:选择题、填空题、解答题、证明题、计算题、应用题
|
||
- 精确提取题号(支持:1、2、3 或 一、二、三 或 (1)、(2) 等多种格式)
|
||
- 识别大题下的小题(如:第18题(1)(2)(3))
|
||
|
||
2. **数学公式识别**(核心能力)
|
||
- **LaTeX 转换**:所有数学表达式必须转换为标准 LaTeX 格式
|
||
- **内联公式**:用 $...$ 包裹,如 $x^2 + y^2 = r^2$
|
||
- **块级公式**:用 $$...$$ 包裹,如 $$\\int_a^b f(x)dx$$
|
||
- **复杂公式**:
|
||
* 分数:$\\frac{a}{b}$
|
||
* 根式:$\\sqrt{x}$、$\\sqrt[n]{x}$
|
||
* 极限:$\\lim_{x \\to \\infty}$
|
||
* 求和:$\\sum_{i=1}^{n}$
|
||
* 积分:$\\int$、$\\iint$、$\\iiint$
|
||
* 矩阵:$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$
|
||
* 向量:$\\vec{a}$、$\\overrightarrow{AB}$
|
||
* 三角函数:$\\sin$、$\\cos$、$\\tan$
|
||
- **特殊符号**:
|
||
* 不等式:$\\leq$、$\\geq$、$\\neq$
|
||
* 集合:$\\in$、$\\subset$、$\\cup$、$\\cap$、$\\varnothing$
|
||
* 逻辑:$\\forall$、$\\exists$、$\\therefore$
|
||
* 箭头:$\\rightarrow$、$\\Rightarrow$、$\\Leftrightarrow$
|
||
|
||
3. **几何图形识别**
|
||
- 详细描述图形类型(三角形、圆、抛物线、函数图像等)
|
||
- 标注关键点、线段、角度标记
|
||
- 记录坐标系信息(坐标轴、刻度、原点)
|
||
- 描述图形中的文字标注和数值
|
||
|
||
4. **解题步骤提取**(如有答案)
|
||
- 识别每个步骤的推导过程
|
||
- 提取关键公式和定理引用
|
||
- 记录计算过程中的中间结果
|
||
|
||
5. **知识点标注**(数学专项)
|
||
- 代数:方程、不等式、函数、数列
|
||
- 几何:平面几何、立体几何、解析几何
|
||
- 三角:三角函数、三角恒等式
|
||
- 微积分:导数、积分、极限
|
||
- 概率统计:概率、统计、随机变量
|
||
- 向量与矩阵:向量运算、矩阵运算
|
||
|
||
6. **难度评估**(1-5级)
|
||
- 1级:基础概念和简单计算
|
||
- 2级:常规题型和标准解法
|
||
- 3级:综合应用和中等难度
|
||
- 4级:较难题型和复杂推导
|
||
- 5级:压轴题和创新题型
|
||
|
||
**输出格式:**
|
||
返回严格的 JSON 格式:
|
||
```json
|
||
{
|
||
"subject": "数学",
|
||
"grade": "年级(如果能识别)",
|
||
"exam_type": "试卷类型(期中/期末/月考/模拟等)",
|
||
"total_questions": 20,
|
||
"total_score": 150,
|
||
"questions": [
|
||
{
|
||
"number": 1,
|
||
"sub_number": null,
|
||
"type": "choice/fill_blank/calculation/essay/proof",
|
||
"question": "题目完整内容(包含LaTeX公式)",
|
||
"options": ["A. $x=1$", "B. $x=2$", "C. $x=3$", "D. $x=4$"],
|
||
"answer": "C",
|
||
"solution": "详细解答步骤(如果试卷提供)",
|
||
"analysis": "解题思路和方法总结",
|
||
"difficulty": 3,
|
||
"score": 5,
|
||
"knowledge_points": ["一元二次方程", "因式分解"],
|
||
"has_image": true,
|
||
"image_description": "直角坐标系中的抛物线y=x^2,顶点在原点,开口向上",
|
||
"page": 1,
|
||
"formulas": ["$x^2 - 3x + 2 = 0$", "$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$"],
|
||
"key_concepts": ["二次函数性质", "求根公式"]
|
||
}
|
||
],
|
||
"stats": {
|
||
"choice_count": 10,
|
||
"fill_blank_count": 5,
|
||
"calculation_count": 3,
|
||
"essay_count": 2,
|
||
"difficulty_distribution": {"1": 2, "2": 5, "3": 8, "4": 4, "5": 1}
|
||
}
|
||
}
|
||
```
|
||
|
||
**特别注意事项:**
|
||
- ✅ 所有数学符号必须用 LaTeX 表示,不要用纯文本
|
||
- ✅ 公式要完整准确,不能有语法错误
|
||
- ✅ 图形描述要详尽,包含所有关键信息
|
||
- ✅ 如果题目跨页,需要合并完整内容
|
||
- ✅ 保持题目原文,不要改写或简化
|
||
- ✅ 选项中的公式也要用 LaTeX 格式
|
||
- ❌ 不要遗漏任何题目或步骤
|
||
- ❌ 不要混淆题号和小题号
|
||
"""
|
||
|
||
# 通用 prompt(适用于其他学科或模型)
|
||
else:
|
||
return base_prompt + """
|
||
|
||
**解析要求:**
|
||
1. **题目识别**:识别所有题目,包括选择题、填空题、计算题、解答题等
|
||
2. **题号提取**:准确提取题目编号(如:1、2、3 或 一、二、三)
|
||
3. **内容完整**:保留题目的完整内容,包括文字、公式、图片描述
|
||
4. **公式识别**:将数学公式转换为 LaTeX 格式,例如:$x^2 + y^2 = r^2$
|
||
5. **图片描述**:详细描述题目中的图片内容(如几何图形、实验装置、化学结构式等)
|
||
6. **选项提取**:对于选择题,提取所有选项(A、B、C、D等)
|
||
7. **学科识别**:自动识别学科(数学、物理、化学、生物、英语等)
|
||
8. **难度评估**:根据题目内容评估难度(1-5)
|
||
9. **知识点标注**:标注涉及的主要知识点
|
||
|
||
**输出格式:**
|
||
返回 JSON 格式,结构如下:
|
||
```json
|
||
{
|
||
"subject": "数学/物理/化学/生物/英语等",
|
||
"grade": "年级(如果能识别)",
|
||
"total_questions": 20,
|
||
"questions": [
|
||
{
|
||
"number": 1,
|
||
"type": "choice/fill_blank/calculation/essay",
|
||
"question": "题目完整内容(包含LaTeX公式)",
|
||
"options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"],
|
||
"answer": "C",
|
||
"analysis": "解析说明(如果试卷中提供)",
|
||
"difficulty": 3,
|
||
"knowledge_points": ["知识点1", "知识点2"],
|
||
"has_image": true,
|
||
"image_description": "图片详细描述",
|
||
"page": 1,
|
||
"formulas": ["$x^2 + y^2 = r^2$"]
|
||
}
|
||
],
|
||
"stats": {
|
||
"choice_count": 10,
|
||
"fill_blank_count": 5,
|
||
"calculation_count": 3,
|
||
"essay_count": 2
|
||
}
|
||
}
|
||
```
|
||
|
||
**注意事项:**
|
||
- 如果题目跨页,需要合并完整内容
|
||
- 公式务必使用 LaTeX 格式
|
||
- 图片要详细描述,包括关键信息
|
||
- 题号要准确,即使格式不统一
|
||
- 保持题目原文,不要改写
|
||
"""
|
||
|
||
if subject:
|
||
return base_prompt + f"\n**预设学科:** {subject}"
|
||
if grade:
|
||
return base_prompt + f"\n**预设年级:** {grade}"
|
||
|
||
return base_prompt
|
||
|
||
def parse_exam_file(
|
||
self,
|
||
file_path: str,
|
||
subject: Optional[str] = None,
|
||
grade: Optional[str] = None
|
||
) -> Dict:
|
||
"""
|
||
解析试卷文件(支持 PDF、Word、图片)
|
||
|
||
Args:
|
||
file_path: 文件路径
|
||
subject: 学科(可选,AI会自动识别)
|
||
grade: 年级(可选)
|
||
|
||
Returns:
|
||
解析结果,包含题目列表和元数据
|
||
"""
|
||
file_ext = Path(file_path).suffix.lower()
|
||
|
||
if file_ext == '.pdf':
|
||
return self._parse_pdf(file_path, subject, grade)
|
||
elif file_ext in ['.doc', '.docx']:
|
||
return self._parse_word(file_path, subject, grade)
|
||
elif file_ext in ['.jpg', '.jpeg', '.png', '.bmp']:
|
||
return self._parse_image(file_path, subject, grade)
|
||
else:
|
||
raise ValueError(f"不支持的文件格式: {file_ext}")
|
||
|
||
def _parse_pdf(
|
||
self,
|
||
pdf_path: str,
|
||
subject: Optional[str] = None,
|
||
grade: Optional[str] = None
|
||
) -> Dict:
|
||
"""
|
||
解析 PDF 试卷
|
||
策略:将每页转为高清图片,使用 Claude 的视觉能力解析
|
||
"""
|
||
doc = fitz.open(pdf_path)
|
||
total_pages = len(doc)
|
||
|
||
# 存储所有页面的图片
|
||
page_images = []
|
||
|
||
logger.info(f"📄 开始解析 PDF,共 {total_pages} 页...")
|
||
|
||
for page_num in range(total_pages):
|
||
page = doc[page_num]
|
||
# 使用高分辨率渲染(300 DPI)
|
||
mat = fitz.Matrix(300/72, 300/72)
|
||
pix = page.get_pixmap(matrix=mat)
|
||
|
||
# 转换为 PIL Image
|
||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||
|
||
# 转为 base64
|
||
buffer = io.BytesIO()
|
||
img.save(buffer, format='PNG', optimize=True, quality=95)
|
||
img_base64 = base64.standard_b64encode(buffer.getvalue()).decode()
|
||
|
||
page_images.append({
|
||
"page": page_num + 1,
|
||
"image": img_base64
|
||
})
|
||
|
||
logger.info(f" ✓ 第 {page_num + 1}/{total_pages} 页已转换")
|
||
|
||
doc.close()
|
||
|
||
# 使用 Claude 解析所有页面
|
||
return self._parse_with_claude(page_images, subject, grade, total_pages)
|
||
|
||
def _parse_word(
|
||
self,
|
||
word_path: str,
|
||
subject: Optional[str] = None,
|
||
grade: Optional[str] = None
|
||
) -> Dict:
|
||
"""
|
||
解析 Word 试卷
|
||
策略:提取文本和图片,组合后发送给 Claude
|
||
"""
|
||
doc = Document(word_path)
|
||
|
||
# 提取内容(文本 + 图片)
|
||
content_blocks = []
|
||
|
||
logger.info(f"📝 开始解析 Word 文档...")
|
||
|
||
for element in doc.element.body:
|
||
if isinstance(element, CT_P):
|
||
# 段落
|
||
para = Paragraph(element, doc)
|
||
text = para.text.strip()
|
||
if text:
|
||
content_blocks.append({
|
||
"type": "text",
|
||
"content": text
|
||
})
|
||
|
||
# 检查段落中的图片
|
||
for run in para.runs:
|
||
for drawing in run.element.findall('.//a:blip',
|
||
namespaces={'a': 'http://schemas.openxmlformats.org/drawingml/2006/main'}):
|
||
image_id = drawing.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
|
||
image_part = doc.part.related_parts[image_id]
|
||
image_bytes = image_part.blob
|
||
|
||
# 转为 base64
|
||
img_base64 = base64.standard_b64encode(image_bytes).decode()
|
||
content_blocks.append({
|
||
"type": "image",
|
||
"content": img_base64
|
||
})
|
||
|
||
logger.info(f" ✓ 提取了 {len(content_blocks)} 个内容块")
|
||
|
||
# 使用 Claude 解析
|
||
return self._parse_word_content(content_blocks, subject, grade)
|
||
|
||
def _parse_image(
|
||
self,
|
||
image_path: str,
|
||
subject: Optional[str] = None,
|
||
grade: Optional[str] = None
|
||
) -> Dict:
|
||
"""
|
||
解析图片试卷
|
||
"""
|
||
logger.info(f"🖼️ 开始解析图片试卷...")
|
||
|
||
# 读取图片并转为 base64
|
||
with open(image_path, 'rb') as f:
|
||
img_bytes = f.read()
|
||
img_base64 = base64.standard_b64encode(img_bytes).decode()
|
||
|
||
page_images = [{
|
||
"page": 1,
|
||
"image": img_base64
|
||
}]
|
||
|
||
return self._parse_with_claude(page_images, subject, grade, 1)
|
||
|
||
def _parse_with_claude(
|
||
self,
|
||
page_images: List[Dict],
|
||
subject: Optional[str],
|
||
grade: Optional[str],
|
||
total_pages: int
|
||
) -> Dict:
|
||
"""
|
||
使用 Claude API 解析试卷图片
|
||
"""
|
||
model_name = "Gemini 3 Pro" if self.is_gemini else "Claude Sonnet 4.5"
|
||
logger.info(f"\n🤖 正在调用 {model_name} 进行智能解析...")
|
||
if self.is_gemini and subject and "数学" in subject:
|
||
logger.info(f"💡 启用数学专项优化模式...")
|
||
|
||
# 构建消息内容
|
||
content = []
|
||
|
||
# 使用优化的 prompt
|
||
instruction = self._get_optimized_prompt(subject, grade, total_pages)
|
||
|
||
content.append({
|
||
"type": "text",
|
||
"text": instruction
|
||
})
|
||
|
||
# 添加所有页面图片
|
||
for page_data in page_images:
|
||
content.append({
|
||
"type": "image",
|
||
"source": {
|
||
"type": "base64",
|
||
"media_type": "image/png",
|
||
"data": page_data["image"]
|
||
}
|
||
})
|
||
|
||
# 构建 OpenRouter 格式的消息
|
||
# OpenRouter 使用 OpenAI 格式的 API
|
||
messages = []
|
||
|
||
# 将内容转换为 OpenRouter 格式
|
||
user_content = []
|
||
for item in content:
|
||
if item["type"] == "text":
|
||
user_content.append({
|
||
"type": "text",
|
||
"text": item["text"]
|
||
})
|
||
elif item["type"] == "image":
|
||
user_content.append({
|
||
"type": "image_url",
|
||
"image_url": {
|
||
"url": f"data:{item['source']['media_type']};base64,{item['source']['data']}"
|
||
}
|
||
})
|
||
|
||
messages.append({
|
||
"role": "user",
|
||
"content": user_content
|
||
})
|
||
|
||
# 调用 OpenRouter API
|
||
try:
|
||
headers = {
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
"HTTP-Referer": "https://github.com/your-repo", # 可选,用于排名
|
||
"X-Title": "PaperExamParser" # 可选,显示在排名中
|
||
}
|
||
|
||
payload = {
|
||
"model": self.model,
|
||
"messages": messages,
|
||
"max_tokens": 16000,
|
||
"temperature": 0
|
||
}
|
||
|
||
logger.info(f"📤 发送请求到 OpenRouter...")
|
||
logger.info(f" URL: {self.api_url}")
|
||
logger.info(f" 模型: {self.model}")
|
||
logger.info(f" 图片数量: {len(page_images)}")
|
||
|
||
response = requests.post(
|
||
self.api_url,
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=300 # 5分钟超时
|
||
)
|
||
|
||
logger.info(f"📥 收到响应: HTTP {response.status_code}")
|
||
response.raise_for_status()
|
||
result_data = response.json()
|
||
|
||
# 解析响应
|
||
response_text = result_data["choices"][0]["message"]["content"]
|
||
|
||
logger.info(f"📄 响应内容长度: {len(response_text)} 字符")
|
||
|
||
# 提取 JSON(可能被包裹在 markdown 代码块中)
|
||
json_match = response_text.strip()
|
||
|
||
# 移除 markdown 代码块标记
|
||
if json_match.startswith("```json"):
|
||
json_match = json_match[7:] # 移除 ```json
|
||
elif json_match.startswith("```"):
|
||
json_match = json_match[3:] # 移除 ```
|
||
|
||
if json_match.endswith("```"):
|
||
json_match = json_match[:-3] # 移除结尾的 ```
|
||
|
||
json_match = json_match.strip()
|
||
|
||
logger.info(f"📝 提取的JSON长度: {len(json_match)} 字符")
|
||
logger.info(f"📝 JSON前100字符: {json_match[:100]}")
|
||
logger.info(f"📝 JSON后100字符: {json_match[-100:]}")
|
||
|
||
# 修复JSON中的转义问题(LaTeX公式中的反斜杠、引号等)
|
||
import re
|
||
|
||
def fix_json_escaping(json_str):
|
||
r"""
|
||
修复JSON字符串中的转义问题
|
||
主要处理LaTeX公式中的反斜杠(如 \frac, \sqrt 等)
|
||
策略:在字符串值中,转义所有反斜杠,但保留已知的有效JSON转义序列
|
||
"""
|
||
result = []
|
||
i = 0
|
||
in_string = False
|
||
|
||
while i < len(json_str):
|
||
char = json_str[i]
|
||
|
||
# 检测字符串的开始和结束(非转义的引号)
|
||
if char == '"':
|
||
# 检查前面是否有反斜杠
|
||
num_backslashes = 0
|
||
j = i - 1
|
||
while j >= 0 and json_str[j] == '\\':
|
||
num_backslashes += 1
|
||
j -= 1
|
||
|
||
# 偶数个反斜杠(包括0)表示引号未被转义
|
||
if num_backslashes % 2 == 0:
|
||
in_string = not in_string
|
||
|
||
result.append(char)
|
||
i += 1
|
||
continue
|
||
|
||
# 在字符串内部处理反斜杠
|
||
if in_string and char == '\\':
|
||
if i + 1 < len(json_str):
|
||
next_char = json_str[i + 1]
|
||
|
||
# 已经正确转义的反斜杠 \\
|
||
if next_char == '\\':
|
||
result.append('\\\\')
|
||
i += 2
|
||
continue
|
||
|
||
# 转义的引号 \"
|
||
if next_char == '"':
|
||
result.append('\\"')
|
||
i += 2
|
||
continue
|
||
|
||
# 转义的斜杠 \/
|
||
if next_char == '/':
|
||
result.append('\\/')
|
||
i += 2
|
||
continue
|
||
|
||
# Unicode转义 \uXXXX
|
||
if next_char == 'u' and i + 5 < len(json_str):
|
||
hex_chars = json_str[i+2:i+6]
|
||
if len(hex_chars) == 4 and all(c in '0123456789abcdefABCDEF' for c in hex_chars):
|
||
# 有效的Unicode转义,保持原样
|
||
result.append('\\u')
|
||
result.append(hex_chars)
|
||
i += 6
|
||
continue
|
||
|
||
# 其他所有情况:这是LaTeX命令,需要转义反斜杠
|
||
# 包括 \frac, \sqrt, \ne, \theta, \begin, 等等
|
||
# 也包括 \b, \f, \n, \r, \t (在数学公式中是LaTeX,不是JSON转义)
|
||
result.append('\\\\')
|
||
result.append(next_char)
|
||
i += 2
|
||
else:
|
||
# 字符串末尾的反斜杠,转义它
|
||
result.append('\\\\')
|
||
i += 1
|
||
else:
|
||
result.append(char)
|
||
i += 1
|
||
|
||
return ''.join(result)
|
||
|
||
# 使用更宽松的JSON解析
|
||
try:
|
||
result = json.loads(json_match)
|
||
except json.JSONDecodeError as e:
|
||
logger.warning(f"⚠️ 标准JSON解析失败,尝试修复...")
|
||
logger.info(f" 错误位置: 行{e.lineno}, 列{e.colno}")
|
||
logger.info(f" 错误信息: {str(e)}")
|
||
|
||
# 尝试修复JSON转义问题
|
||
try:
|
||
fixed_json = fix_json_escaping(json_match)
|
||
logger.info(f" 已修复JSON转义,重新尝试解析...")
|
||
result = json.loads(fixed_json)
|
||
except json.JSONDecodeError as e2:
|
||
logger.error(f" 修复后仍然失败: {str(e2)}")
|
||
logger.error(f" 尝试使用宽松模式...")
|
||
# 保存修复后的内容用于调试
|
||
import tempfile
|
||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f:
|
||
f.write(fixed_json)
|
||
debug_file = f.name
|
||
logger.error(f" 修复后的JSON已保存到: {debug_file}")
|
||
|
||
# Also save original for comparison
|
||
with tempfile.NamedTemporaryFile(mode='w', suffix='_original.json', delete=False, encoding='utf-8') as f:
|
||
f.write(json_match)
|
||
orig_file = f.name
|
||
logger.error(f" 原始JSON已保存到: {orig_file}")
|
||
raise e2
|
||
|
||
logger.info(f"✅ 解析成功!共识别 {result.get('total_questions', 0)} 道题目")
|
||
logger.info(f" 学科: {result.get('subject', '未识别')}")
|
||
logger.info(f" 统计: {result.get('stats', {})}")
|
||
|
||
return {
|
||
"success": True,
|
||
"result": result,
|
||
"raw_response": response_text
|
||
}
|
||
|
||
except requests.exceptions.Timeout as e:
|
||
logger.error(f"❌ API 请求超时: {e}")
|
||
logger.error(f"请求URL: {self.api_url}")
|
||
logger.error(f"模型: {self.model}")
|
||
return {
|
||
"success": False,
|
||
"error": f"API 请求超时: {str(e)}"
|
||
}
|
||
except requests.exceptions.ConnectionError as e:
|
||
logger.error(f"❌ 网络连接错误: {e}")
|
||
logger.error(f"请求URL: {self.api_url}")
|
||
return {
|
||
"success": False,
|
||
"error": f"无法连接到OpenRouter服务: {str(e)}"
|
||
}
|
||
except requests.exceptions.HTTPError as e:
|
||
logger.error(f"❌ HTTP错误: {e}")
|
||
logger.error(f"响应状态码: {e.response.status_code if e.response else 'N/A'}")
|
||
logger.error(f"响应内容: {e.response.text if e.response else 'N/A'}")
|
||
return {
|
||
"success": False,
|
||
"error": f"API返回错误 (HTTP {e.response.status_code if e.response else 'N/A'}): {e.response.text if e.response else str(e)}"
|
||
}
|
||
except requests.exceptions.RequestException as e:
|
||
logger.error(f"❌ API 请求错误: {e}")
|
||
return {
|
||
"success": False,
|
||
"error": f"API 请求失败: {str(e)}"
|
||
}
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"❌ JSON 解析错误: {e}")
|
||
logger.error(f"原始响应: {response_text}")
|
||
return {
|
||
"success": False,
|
||
"error": "返回的格式无法解析",
|
||
"raw_response": response_text
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"❌ 未知错误: {e}", exc_info=True)
|
||
return {
|
||
"success": False,
|
||
"error": str(e)
|
||
}
|
||
|
||
def _parse_word_content(
|
||
self,
|
||
content_blocks: List[Dict],
|
||
subject: Optional[str],
|
||
grade: Optional[str]
|
||
) -> Dict:
|
||
"""
|
||
解析 Word 文档内容
|
||
"""
|
||
logger.info(f"\n🤖 正在调用 Claude Sonnet 4.5 进行智能解析...")
|
||
|
||
# 构建消息内容
|
||
content = []
|
||
|
||
# 添加系统提示(同上)
|
||
instruction = f"""你是一个专业的试卷解析助手。请分析这份 Word 试卷,并提取所有题目信息。
|
||
|
||
**解析要求:**(同上,省略...)
|
||
|
||
返回 JSON 格式的解析结果。
|
||
"""
|
||
|
||
if subject:
|
||
instruction += f"\n**预设学科:** {subject}"
|
||
if grade:
|
||
instruction += f"\n**预设年级:** {grade}"
|
||
|
||
content.append({
|
||
"type": "text",
|
||
"text": instruction
|
||
})
|
||
|
||
# 添加所有内容块
|
||
for block in content_blocks:
|
||
if block["type"] == "text":
|
||
content.append({
|
||
"type": "text",
|
||
"text": block["content"]
|
||
})
|
||
elif block["type"] == "image":
|
||
content.append({
|
||
"type": "image",
|
||
"source": {
|
||
"type": "base64",
|
||
"media_type": "image/png",
|
||
"data": block["content"]
|
||
}
|
||
})
|
||
|
||
# 使用相同的 OpenRouter API 调用逻辑
|
||
# 构建 OpenRouter 格式的消息
|
||
messages = []
|
||
user_content = []
|
||
|
||
for item in content:
|
||
if item["type"] == "text":
|
||
user_content.append({
|
||
"type": "text",
|
||
"text": item["text"]
|
||
})
|
||
elif item["type"] == "image":
|
||
user_content.append({
|
||
"type": "image_url",
|
||
"image_url": {
|
||
"url": f"data:{item['source']['media_type']};base64,{item['source']['data']}"
|
||
}
|
||
})
|
||
|
||
messages.append({
|
||
"role": "user",
|
||
"content": user_content
|
||
})
|
||
|
||
# 调用 OpenRouter API
|
||
try:
|
||
headers = {
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
"HTTP-Referer": "https://github.com/your-repo",
|
||
"X-Title": "PaperExamParser"
|
||
}
|
||
|
||
payload = {
|
||
"model": self.model,
|
||
"messages": messages,
|
||
"max_tokens": 16000,
|
||
"temperature": 0
|
||
}
|
||
|
||
response = requests.post(
|
||
self.api_url,
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=300
|
||
)
|
||
|
||
response.raise_for_status()
|
||
result_data = response.json()
|
||
response_text = result_data["choices"][0]["message"]["content"]
|
||
|
||
# 提取 JSON
|
||
json_match = response_text
|
||
if "```json" in response_text:
|
||
json_match = response_text.split("```json")[1].split("```")[0].strip()
|
||
elif "```" in response_text:
|
||
json_match = response_text.split("```")[1].split("```")[0].strip()
|
||
|
||
result = json.loads(json_match)
|
||
|
||
logger.info(f"✅ 解析成功!共识别 {result.get('total_questions', 0)} 道题目")
|
||
|
||
return {
|
||
"success": True,
|
||
"result": result,
|
||
"raw_response": response_text
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.info(f"❌ 解析错误: {e}")
|
||
return {
|
||
"success": False,
|
||
"error": str(e)
|
||
}
|
||
|
||
|
||
# 便捷函数
|
||
def parse_exam(
|
||
file_path: str,
|
||
api_key: Optional[str] = None,
|
||
subject: Optional[str] = None,
|
||
grade: Optional[str] = None
|
||
) -> Dict:
|
||
"""
|
||
便捷的试卷解析函数
|
||
|
||
Args:
|
||
file_path: 试卷文件路径
|
||
api_key: Claude API Key
|
||
subject: 学科
|
||
grade: 年级
|
||
|
||
Returns:
|
||
解析结果
|
||
|
||
Example:
|
||
>>> result = parse_exam("math_exam.pdf", subject="数学", grade="高一")
|
||
>>> logger.info(result["result"]["total_questions"])
|
||
"""
|
||
parser = ClaudeExamParser(api_key=api_key)
|
||
return parser.parse_exam_file(file_path, subject, grade)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 测试代码
|
||
import sys
|
||
|
||
if len(sys.argv) < 2:
|
||
logger.info("用法: python claude_exam_parser.py <试卷文件路径> [学科] [年级]")
|
||
sys.exit(1)
|
||
|
||
file_path = sys.argv[1]
|
||
subject = sys.argv[2] if len(sys.argv) > 2 else None
|
||
grade = sys.argv[3] if len(sys.argv) > 3 else None
|
||
|
||
result = parse_exam(file_path, subject=subject, grade=grade)
|
||
|
||
if result["success"]:
|
||
logger.info("\n" + "="*60)
|
||
logger.info("解析结果:")
|
||
logger.info(json.dumps(result["result"], ensure_ascii=False, indent=2))
|
||
else:
|
||
logger.info(f"\n❌ 解析失败: {result['error']}")
|