- Implemented enhanced PDF parsing with V2 parser for better question extraction - Added improved question formatting and display components - Integrated question navigation and progress tracking features - Added JSON export functionality for paper questions - Enhanced image extraction and association with questions - Improved database configuration with environment variables - Updated UI with responsive question cards and navigation sidebar - Added mathematical symbol rendering and question type detection Co-authored-by: Qwen-Coder <[email protected]>
648 lines
22 KiB
Python
648 lines
22 KiB
Python
"""
|
||
题目数据格式化工具
|
||
用于标准化题目数据的JSON输出格式
|
||
"""
|
||
|
||
from typing import Dict, List, Any, Optional
|
||
from datetime import datetime
|
||
import json
|
||
import re
|
||
|
||
class QuestionFormatter:
|
||
"""题目数据格式化器"""
|
||
|
||
def __init__(self):
|
||
# 题目类型映射
|
||
self.type_mapping = {
|
||
'choice': 'single_choice',
|
||
'single_choice': 'single_choice',
|
||
'multiple_choice': 'multiple_choice',
|
||
'fill_blank': 'fill_blank',
|
||
'calculation': 'calculation',
|
||
'essay': 'essay',
|
||
'solve': 'solve',
|
||
'prove': 'prove',
|
||
'true_false': 'true_false',
|
||
'general': 'essay'
|
||
}
|
||
|
||
# 学科映射
|
||
self.subject_mapping = {
|
||
'math': '数学',
|
||
'chinese': '语文',
|
||
'english': '英语',
|
||
'physics': '物理',
|
||
'chemistry': '化学',
|
||
'biology': '生物',
|
||
'history': '历史',
|
||
'geography': '地理',
|
||
'politics': '政治'
|
||
}
|
||
|
||
# 难度等级映射
|
||
self.difficulty_mapping = {
|
||
1: '简单',
|
||
2: '较易',
|
||
3: '中等',
|
||
4: '较难',
|
||
5: '困难'
|
||
}
|
||
|
||
def format_question(self, raw_question: Dict, paper_info: Optional[Dict] = None) -> Dict:
|
||
"""
|
||
格式化单个题目数据
|
||
|
||
Args:
|
||
raw_question: 原始题目数据
|
||
paper_info: 试卷信息(可选)
|
||
|
||
Returns:
|
||
标准化的题目数据
|
||
"""
|
||
try:
|
||
# 基础信息
|
||
formatted = {
|
||
"id": raw_question.get("id"),
|
||
"number": self._extract_question_number(raw_question),
|
||
"type": self._normalize_question_type(raw_question),
|
||
"subject": self._normalize_subject(raw_question, paper_info),
|
||
"grade": raw_question.get("grade", ""),
|
||
"chapter": raw_question.get("chapter", ""),
|
||
"difficulty": self._normalize_difficulty(raw_question),
|
||
"score": self._extract_score(raw_question),
|
||
"estimated_time": self._estimate_time(raw_question)
|
||
}
|
||
|
||
# 题目内容
|
||
formatted.update({
|
||
"stem": self._clean_question_text(raw_question.get("question_content") or raw_question.get("text", "")),
|
||
"options": self._format_options(raw_question.get("options", {})),
|
||
"correct_answer": self._clean_answer(raw_question.get("correct_answer", "")),
|
||
"explanation": self._clean_text(raw_question.get("explanation", "")),
|
||
"analysis": self._clean_text(raw_question.get("analysis", "")),
|
||
"hints": self._extract_hints(raw_question)
|
||
})
|
||
|
||
# 知识点
|
||
formatted["knowledge_points"] = self._format_knowledge_points(raw_question.get("knowledge_points", []))
|
||
|
||
# 媒体资源
|
||
formatted.update({
|
||
"images": self._format_images(raw_question.get("images", [])),
|
||
"formulas": self._format_formulas(raw_question.get("formula", [])),
|
||
"audio": raw_question.get("audio", ""),
|
||
"video": raw_question.get("video", "")
|
||
})
|
||
|
||
# 来源信息
|
||
formatted.update({
|
||
"source": self._format_source(raw_question, paper_info),
|
||
"metadata": self._extract_metadata(raw_question)
|
||
})
|
||
|
||
# 统计信息
|
||
formatted["statistics"] = {
|
||
"created_at": self._format_datetime(raw_question.get("created_at")),
|
||
"updated_at": self._format_datetime(raw_question.get("updated_at")),
|
||
"view_count": raw_question.get("view_count", 0),
|
||
"attempt_count": raw_question.get("attempt_count", 0),
|
||
"correct_count": raw_question.get("correct_count", 0),
|
||
"accuracy_rate": self._calculate_accuracy(raw_question)
|
||
}
|
||
|
||
# 变体信息(子题)
|
||
formatted["variants"] = self._extract_variants(raw_question)
|
||
|
||
# 验证数据完整性
|
||
formatted["validation"] = self._validate_question(formatted)
|
||
|
||
return formatted
|
||
|
||
except Exception as e:
|
||
print(f"格式化题目失败: {e}")
|
||
return self._create_error_question(raw_question, str(e))
|
||
|
||
def format_paper_questions(self, paper_data: Dict) -> Dict:
|
||
"""
|
||
格式化试卷的所有题目
|
||
|
||
Args:
|
||
paper_data: 包含试卷信息和题目列表的数据
|
||
|
||
Returns:
|
||
标准化的试卷题目数据
|
||
"""
|
||
questions = paper_data.get("questions", [])
|
||
paper_info = {
|
||
"id": paper_data.get("paper_id"),
|
||
"title": paper_data.get("title", ""),
|
||
"subject": paper_data.get("subject", ""),
|
||
"grade": paper_data.get("grade", "")
|
||
}
|
||
|
||
formatted_questions = []
|
||
processing_errors = []
|
||
|
||
for i, raw_question in enumerate(questions):
|
||
try:
|
||
formatted = self.format_question(raw_question, paper_info)
|
||
formatted_questions.append(formatted)
|
||
except Exception as e:
|
||
processing_errors.append({
|
||
"index": i,
|
||
"question_id": raw_question.get("id"),
|
||
"error": str(e),
|
||
"raw_data": raw_question
|
||
})
|
||
|
||
return {
|
||
"paper_info": paper_info,
|
||
"questions": formatted_questions,
|
||
"statistics": self._calculate_paper_stats(formatted_questions),
|
||
"processing_errors": processing_errors,
|
||
"export_info": {
|
||
"exported_at": datetime.now().isoformat(),
|
||
"total_questions": len(formatted_questions),
|
||
"error_count": len(processing_errors),
|
||
"format_version": "2.0"
|
||
}
|
||
}
|
||
|
||
def _extract_question_number(self, question: Dict) -> str:
|
||
"""提取题号"""
|
||
number = question.get("question_number") or question.get("number") or question.get("index")
|
||
if number:
|
||
return str(number)
|
||
|
||
# 尝试从题干中提取题号
|
||
text = question.get("question_content") or question.get("text", "")
|
||
import re
|
||
match = re.search(r'^(\d+)[.).、]', text)
|
||
if match:
|
||
return match.group(1)
|
||
|
||
return ""
|
||
|
||
def _normalize_question_type(self, question: Dict) -> str:
|
||
"""标准化题目类型"""
|
||
raw_type = question.get("question_type") or question.get("type") or ""
|
||
|
||
# 如果有选项,默认为选择题
|
||
if question.get("options") and len(question.get("options", {})) > 0:
|
||
return "single_choice"
|
||
|
||
return self.type_mapping.get(raw_type, "essay")
|
||
|
||
def _normalize_subject(self, question: Dict, paper_info: Optional[Dict] = None) -> str:
|
||
"""标准化学科"""
|
||
subject = question.get("subject")
|
||
if subject:
|
||
return self.subject_mapping.get(subject.lower(), subject)
|
||
|
||
if paper_info and paper_info.get("subject"):
|
||
return self.subject_mapping.get(paper_info["subject"].lower(), paper_info["subject"])
|
||
|
||
return "未分类"
|
||
|
||
def _normalize_difficulty(self, question: Dict) -> Dict:
|
||
"""标准化难度信息"""
|
||
raw_difficulty = question.get("difficulty", 3)
|
||
|
||
try:
|
||
difficulty_value = int(raw_difficulty)
|
||
except (ValueError, TypeError):
|
||
difficulty_value = 3
|
||
|
||
return {
|
||
"level": max(1, min(5, difficulty_value)),
|
||
"label": self.difficulty_mapping.get(difficulty_value, "中等"),
|
||
"stars": "★" * max(1, min(5, difficulty_value))
|
||
}
|
||
|
||
def _extract_score(self, question: Dict) -> Dict:
|
||
"""提取分值信息"""
|
||
text = question.get("question_content") or question.get("text", "")
|
||
import re
|
||
|
||
# 查找分值模式
|
||
match = re.search(r'(\d+\.?\d*)\s*分', text)
|
||
if match:
|
||
score_value = float(match.group(1))
|
||
return {
|
||
"total": score_value,
|
||
"formatted": f"{score_value}分",
|
||
"extracted_from_text": True
|
||
}
|
||
|
||
# 查找其他分值模式
|
||
score_field = question.get("score")
|
||
if score_field:
|
||
try:
|
||
score_value = float(score_field)
|
||
return {
|
||
"total": score_value,
|
||
"formatted": f"{score_value}分",
|
||
"extracted_from_text": False
|
||
}
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
return {
|
||
"total": 0,
|
||
"formatted": "未标分",
|
||
"extracted_from_text": False
|
||
}
|
||
|
||
def _estimate_time(self, question: Dict) -> Dict:
|
||
"""估算答题时间"""
|
||
question_type = self._normalize_question_type(question)
|
||
text_length = len(question.get("question_content") or question.get("text", ""))
|
||
options_count = len(question.get("options", {}))
|
||
difficulty = self._normalize_difficulty(question)["level"]
|
||
|
||
# 基础时间(分钟)
|
||
base_times = {
|
||
"single_choice": 1,
|
||
"multiple_choice": 2,
|
||
"fill_blank": 2,
|
||
"calculation": 5,
|
||
"essay": 10,
|
||
"solve": 8,
|
||
"prove": 12,
|
||
"true_false": 0.5
|
||
}
|
||
|
||
base_time = base_times.get(question_type, 3)
|
||
|
||
# 根据难度调整
|
||
difficulty_multiplier = 0.5 + (difficulty * 0.3)
|
||
|
||
# 根据文本长度调整
|
||
text_multiplier = 1 + (text_length / 500)
|
||
|
||
estimated_time = base_time * difficulty_multiplier * text_multiplier
|
||
|
||
return {
|
||
"minutes": round(estimated_time, 1),
|
||
"seconds": round(estimated_time * 60),
|
||
"formatted": f"{round(estimated_time, 1)}分钟"
|
||
}
|
||
|
||
def _clean_question_text(self, text: str) -> str:
|
||
"""清理题目文本"""
|
||
if not text:
|
||
return ""
|
||
|
||
# 移除题号前缀
|
||
import re
|
||
text = re.sub(r'^\d+\s*[.).、]\s*', '', text)
|
||
|
||
# 移除分值信息
|
||
text = re.sub(r'^\d+\.?\d*\s*分[::]\s*', '', text)
|
||
|
||
# 清理多余空格
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
|
||
return text
|
||
|
||
def _format_options(self, options: Dict) -> List[Dict]:
|
||
"""格式化选项"""
|
||
if not options:
|
||
return []
|
||
|
||
formatted_options = []
|
||
for key, value in options.items():
|
||
if not value or not value.strip():
|
||
continue
|
||
|
||
formatted_options.append({
|
||
"label": key.upper(),
|
||
"text": self._clean_text(value),
|
||
"is_correct": False, # 需要根据答案判断
|
||
"explanation": ""
|
||
})
|
||
|
||
# 按标签排序
|
||
formatted_options.sort(key=lambda x: x["label"])
|
||
return formatted_options
|
||
|
||
def _clean_answer(self, answer: str) -> str:
|
||
"""清理答案"""
|
||
if not answer:
|
||
return ""
|
||
|
||
# 移除多余空格
|
||
answer = " ".join(answer.split())
|
||
|
||
# 标准化多答案格式
|
||
if "," in answer:
|
||
answers = [a.strip() for a in answer.split(",")]
|
||
return ", ".join(sorted(answers))
|
||
|
||
return answer
|
||
|
||
def _clean_text(self, text: str) -> str:
|
||
"""清理普通文本"""
|
||
if not text:
|
||
return ""
|
||
|
||
# 清理多余空格和换行
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
return text
|
||
|
||
def _extract_hints(self, question: Dict) -> List[str]:
|
||
"""提取提示信息"""
|
||
hints = []
|
||
|
||
# 从解析中提取提示
|
||
explanation = question.get("explanation", "")
|
||
if "提示" in explanation or "注意" in explanation:
|
||
import re
|
||
hint_matches = re.findall(r'[提示注意][::]\s*([^。!?\n]*?)[。!?\n]', explanation)
|
||
hints.extend(hint_matches)
|
||
|
||
return hints
|
||
|
||
def _format_knowledge_points(self, knowledge_points: List) -> List[Dict]:
|
||
"""格式化知识点"""
|
||
if not knowledge_points:
|
||
return []
|
||
|
||
formatted = []
|
||
for point in knowledge_points:
|
||
if isinstance(point, str):
|
||
formatted.append({
|
||
"name": point,
|
||
"category": "",
|
||
"weight": 1.0
|
||
})
|
||
elif isinstance(point, dict):
|
||
formatted.append({
|
||
"name": point.get("name", ""),
|
||
"category": point.get("category", ""),
|
||
"weight": point.get("weight", 1.0)
|
||
})
|
||
|
||
return formatted
|
||
|
||
def _format_images(self, images: List) -> List[Dict]:
|
||
"""格式化图片信息"""
|
||
if not images:
|
||
return []
|
||
|
||
formatted = []
|
||
for i, image in enumerate(images):
|
||
if isinstance(image, str):
|
||
formatted.append({
|
||
"id": f"img_{i+1}",
|
||
"url": image,
|
||
"alt": f"题目图片{i+1}",
|
||
"type": "image"
|
||
})
|
||
elif isinstance(image, dict):
|
||
formatted.append({
|
||
"id": image.get("id", f"img_{i+1}"),
|
||
"url": image.get("url") or image.get("uri") or image.get("key", ""),
|
||
"alt": image.get("alt", f"题目图片{i+1}"),
|
||
"type": "image",
|
||
"width": image.get("width"),
|
||
"height": image.get("height"),
|
||
"format": image.get("format", "unknown")
|
||
})
|
||
|
||
return formatted
|
||
|
||
def _format_formulas(self, formulas: List) -> List[Dict]:
|
||
"""格式化公式信息"""
|
||
if not formulas:
|
||
return []
|
||
|
||
formatted = []
|
||
for i, formula in enumerate(formulas):
|
||
if isinstance(formula, dict):
|
||
formatted.append({
|
||
"id": formula.get("id", f"formula_{i+1}"),
|
||
"latex": formula.get("latex", ""),
|
||
"position": formula.get("bbox", []),
|
||
"type": "formula"
|
||
})
|
||
|
||
return formatted
|
||
|
||
def _format_source(self, question: Dict, paper_info: Optional[Dict] = None) -> Dict:
|
||
"""格式化来源信息"""
|
||
source = {
|
||
"type": "auto_extracted",
|
||
"method": question.get("source", "unknown"),
|
||
"paper": {
|
||
"id": paper_info.get("id") if paper_info else None,
|
||
"title": paper_info.get("title", "") if paper_info else "",
|
||
"page": question.get("page", 1)
|
||
},
|
||
"extraction_confidence": 0.8, # 提取置信度
|
||
"verified": False # 是否已人工验证
|
||
}
|
||
|
||
return source
|
||
|
||
def _extract_metadata(self, question: Dict) -> Dict:
|
||
"""提取元数据"""
|
||
return {
|
||
"extraction_method": question.get("auto_extracted", False) and "automatic" or "manual",
|
||
"original_format": "pdf",
|
||
"language": "zh-CN",
|
||
"tags": question.get("tags", []),
|
||
"notes": question.get("notes", ""),
|
||
"page_spans": question.get("page_spans", []),
|
||
"source_text": question.get("source_text", "")
|
||
}
|
||
|
||
def _format_datetime(self, dt) -> str:
|
||
"""格式化日期时间"""
|
||
if not dt:
|
||
return ""
|
||
|
||
if isinstance(dt, str):
|
||
return dt
|
||
|
||
if hasattr(dt, 'isoformat'):
|
||
return dt.isoformat()
|
||
|
||
return str(dt)
|
||
|
||
def _calculate_accuracy(self, question: Dict) -> float:
|
||
"""计算正确率"""
|
||
attempt_count = question.get("attempt_count", 0)
|
||
correct_count = question.get("correct_count", 0)
|
||
|
||
if attempt_count == 0:
|
||
return 0.0
|
||
|
||
return round(correct_count / attempt_count, 2)
|
||
|
||
def _extract_variants(self, question: Dict) -> List[Dict]:
|
||
"""提取子题/变体"""
|
||
variants = []
|
||
text = question.get("question_content") or question.get("text", "")
|
||
|
||
# 查找子题模式 (1), (2), (3) 等
|
||
import re
|
||
sub_matches = re.findall(r'\((\d+)\)\s*([^()]*?)(?=\(\d+\)|$)', text)
|
||
|
||
for num, content in sub_matches:
|
||
if content.strip():
|
||
variants.append({
|
||
"number": num,
|
||
"type": "subquestion",
|
||
"content": self._clean_text(content),
|
||
"score": None # 可以尝试提取子题分值
|
||
})
|
||
|
||
return variants
|
||
|
||
def _validate_question(self, question: Dict) -> Dict:
|
||
"""验证题目数据完整性"""
|
||
validation = {
|
||
"is_valid": True,
|
||
"errors": [],
|
||
"warnings": [],
|
||
"completeness_score": 1.0
|
||
}
|
||
|
||
# 必需字段检查
|
||
required_fields = ["stem", "type"]
|
||
for field in required_fields:
|
||
if not question.get(field):
|
||
validation["errors"].append(f"缺少必需字段: {field}")
|
||
validation["is_valid"] = False
|
||
|
||
# 题目类型特定检查
|
||
if question["type"] in ["single_choice", "multiple_choice"]:
|
||
if not question.get("options"):
|
||
validation["errors"].append("选择题缺少选项")
|
||
validation["is_valid"] = False
|
||
elif not question.get("correct_answer"):
|
||
validation["warnings"].append("选择题缺少正确答案")
|
||
|
||
# 内容质量检查
|
||
stem_length = len(question.get("stem", ""))
|
||
if stem_length < 5:
|
||
validation["warnings"].append("题干内容过短")
|
||
elif stem_length > 2000:
|
||
validation["warnings"].append("题干内容过长")
|
||
|
||
# 计算完整性分数
|
||
total_fields = 8 # 总共评估的字段数
|
||
filled_fields = sum(1 for field in ["stem", "options", "correct_answer", "explanation", "knowledge_points", "difficulty", "score", "images"]
|
||
if question.get(field))
|
||
|
||
validation["completeness_score"] = filled_fields / total_fields
|
||
|
||
return validation
|
||
|
||
def _calculate_paper_stats(self, questions: List[Dict]) -> Dict:
|
||
"""计算试卷统计信息"""
|
||
if not questions:
|
||
return {}
|
||
|
||
stats = {
|
||
"total_questions": len(questions),
|
||
"question_types": {},
|
||
"difficulty_distribution": {"简单": 0, "较易": 0, "中等": 0, "较难": 0, "困难": 0},
|
||
"total_score": 0,
|
||
"estimated_time": 0,
|
||
"knowledge_points": [],
|
||
"has_images": 0,
|
||
"average_accuracy": 0.0
|
||
}
|
||
|
||
all_accuracy = []
|
||
all_knowledge_points = set()
|
||
|
||
for question in questions:
|
||
# 题目类型统计
|
||
q_type = question.get("type", "unknown")
|
||
stats["question_types"][q_type] = stats["question_types"].get(q_type, 0) + 1
|
||
|
||
# 难度分布
|
||
difficulty = question.get("difficulty", {}).get("label", "中等")
|
||
stats["difficulty_distribution"][difficulty] = stats["difficulty_distribution"].get(difficulty, 0) + 1
|
||
|
||
# 分值统计
|
||
score = question.get("score", {}).get("total", 0)
|
||
stats["total_score"] += score
|
||
|
||
# 时间估算
|
||
time_minutes = question.get("estimated_time", {}).get("minutes", 0)
|
||
stats["estimated_time"] += time_minutes
|
||
|
||
# 知识点统计
|
||
for kp in question.get("knowledge_points", []):
|
||
kp_name = kp.get("name", kp) if isinstance(kp, dict) else kp
|
||
all_knowledge_points.add(kp_name)
|
||
|
||
# 图片统计
|
||
if question.get("images"):
|
||
stats["has_images"] += 1
|
||
|
||
# 正确率统计
|
||
accuracy = question.get("statistics", {}).get("accuracy_rate", 0)
|
||
if accuracy > 0:
|
||
all_accuracy.append(accuracy)
|
||
|
||
stats["knowledge_points"] = list(all_knowledge_points)
|
||
|
||
if all_accuracy:
|
||
stats["average_accuracy"] = round(sum(all_accuracy) / len(all_accuracy), 2)
|
||
|
||
# 格式化时间
|
||
total_minutes = stats["estimated_time"]
|
||
if total_minutes >= 60:
|
||
hours = int(total_minutes // 60)
|
||
minutes = int(total_minutes % 60)
|
||
stats["estimated_time_formatted"] = f"{hours}小时{minutes}分钟"
|
||
else:
|
||
stats["estimated_time_formatted"] = f"{int(total_minutes)}分钟"
|
||
|
||
return stats
|
||
|
||
def _create_error_question(self, raw_question: Dict, error_msg: str) -> Dict:
|
||
"""创建错误题目对象"""
|
||
return {
|
||
"id": raw_question.get("id"),
|
||
"number": self._extract_question_number(raw_question),
|
||
"type": "error",
|
||
"stem": f"[数据解析错误] {error_msg}",
|
||
"options": [],
|
||
"correct_answer": "",
|
||
"explanation": "",
|
||
"validation": {
|
||
"is_valid": False,
|
||
"errors": [error_msg],
|
||
"warnings": [],
|
||
"completeness_score": 0.0
|
||
},
|
||
"source": {
|
||
"type": "error",
|
||
"method": "error",
|
||
"error_message": error_msg
|
||
}
|
||
}
|
||
|
||
def export_to_json_file(self, data: Dict, file_path: str, indent: int = 2) -> bool:
|
||
"""导出到JSON文件"""
|
||
try:
|
||
with open(file_path, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=indent, default=str)
|
||
return True
|
||
except Exception as e:
|
||
print(f"导出JSON文件失败: {e}")
|
||
return False
|
||
|
||
def export_to_json_string(self, data: Dict, indent: int = 2) -> str:
|
||
"""导出为JSON字符串"""
|
||
try:
|
||
return json.dumps(data, ensure_ascii=False, indent=indent, default=str)
|
||
except Exception as e:
|
||
print(f"导出JSON字符串失败: {e}")
|
||
return json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent) |