Files
rag/exam_pkg/local_db.py

511 lines
17 KiB
Python

"""
本地出题批卷数据库 - 用于开发测试
使用 SQLite 存储:
1. 题目表(含溯源信息)
2. 试卷表
3. 学生答卷表
4. 批阅报告表
"""
import os
import sys
import json
import uuid
from datetime import datetime
from typing import List, Dict, Optional
import logging
logger = logging.getLogger(__name__)
from data.db import get_connection, init_databases
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
class ExamLocalDB:
"""本地出题批卷数据库"""
def __init__(self):
"""初始化数据库连接(表结构由 data/db.py 统一管理)"""
init_databases()
# ==================== 题目管理 ====================
def add_question(self, question: Dict, source_file: str, source_collection: str) -> str:
"""
添加题目到数据库
Args:
question: 题目信息
source_file: 来源文件路径
source_collection: 来源向量库
Returns:
题目ID
"""
question_id = question.get('id') or str(uuid.uuid4())
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO questions
(id, question_type, content, options, correct_answer, analysis,
knowledge_points, difficulty, score, source_file, source_collection,
source_snippet, source_hash, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
question_id,
self._detect_question_type(question),
question.get('content', ''),
json.dumps(question.get('options', []), ensure_ascii=False) if question.get('options') else None,
self._get_correct_answer(question),
question.get('analysis', ''),
json.dumps(question.get('knowledge_points', []), ensure_ascii=False),
question.get('difficulty', 3),
self._get_score(question),
source_file,
source_collection,
json.dumps(question.get('sources', []), ensure_ascii=False),
question.get('source_hash'),
'approved',
datetime.now().isoformat()
))
return question_id
def add_questions_batch(self, questions: List[Dict], source_file: str, source_collection: str) -> int:
"""批量添加题目"""
count = 0
for q in questions:
self.add_question(q, source_file, source_collection)
count += 1
return count
def get_question(self, question_id: str) -> Optional[Dict]:
"""获取单个题目"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM questions WHERE id = ?', (question_id,))
row = cursor.fetchone()
if row:
return self._row_to_question(row)
return None
def get_questions_by_file(self, source_file: str) -> List[Dict]:
"""根据来源文件获取题目"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM questions WHERE source_file = ?', (source_file,))
rows = cursor.fetchall()
return [self._row_to_question(row) for row in rows]
def delete_questions_by_file(self, source_file: str) -> int:
"""删除指定文件的所有题目"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM questions WHERE source_file = ?', (source_file,))
deleted = cursor.rowcount
return deleted
def list_questions(self, question_type: str = None, limit: int = 100) -> List[Dict]:
"""列出题目"""
with get_connection("exam") as conn:
cursor = conn.cursor()
if question_type:
cursor.execute(
'SELECT * FROM questions WHERE question_type = ? ORDER BY created_at DESC LIMIT ?',
(question_type, limit)
)
else:
cursor.execute('SELECT * FROM questions ORDER BY created_at DESC LIMIT ?', (limit,))
rows = cursor.fetchall()
return [self._row_to_question(row) for row in rows]
# ==================== 试卷管理 ====================
def create_exam(self, name: str, question_ids: List[str], created_by: str = 'admin',
description: str = '', duration: int = 60) -> Dict:
"""
创建试卷
Args:
name: 试卷名称
question_ids: 题目ID列表
created_by: 创建者
description: 描述
duration: 考试时长(分钟)
Returns:
试卷信息
"""
exam_id = str(uuid.uuid4())
# 计算总分和题目数
questions = []
total_score = 0
for qid in question_ids:
q = self.get_question(qid)
if q:
questions.append(q)
total_score += q['score']
with get_connection("exam") as conn:
cursor = conn.cursor()
# 插入试卷
cursor.execute('''
INSERT INTO exams (id, name, description, total_score, total_count, duration, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (exam_id, name, description, total_score, len(questions), duration, 'published', created_by))
# 仅关联实际存在的题目,避免无效 ID 触发外键约束。
valid_question_ids = [q['id'] for q in questions]
for order, qid in enumerate(valid_question_ids):
cursor.execute('''
INSERT INTO exam_questions (exam_id, question_id, question_order)
VALUES (?, ?, ?)
''', (exam_id, qid, order))
return {
'id': exam_id,
'name': name,
'total_score': total_score,
'total_count': len(questions),
'duration': duration,
'question_ids': valid_question_ids
}
def get_exam(self, exam_id: str) -> Optional[Dict]:
"""获取试卷详情"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM exams WHERE id = ?', (exam_id,))
row = cursor.fetchone()
if not row:
return None
exam = {
'id': row['id'],
'name': row['name'],
'description': row['description'],
'total_score': row['total_score'],
'total_count': row['total_count'],
'duration': row['duration'],
'status': row['status'],
'created_at': row['created_at'],
'created_by': row['created_by']
}
# 获取关联的题目
cursor.execute('''
SELECT question_id FROM exam_questions
WHERE exam_id = ? ORDER BY question_order
''', (exam_id,))
question_ids = [r['question_id'] for r in cursor.fetchall()]
exam['question_ids'] = question_ids
exam['questions'] = [self.get_question(qid) for qid in question_ids]
return exam
def list_exams(self, limit: int = 20) -> List[Dict]:
"""列出试卷"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, name, total_score, total_count, status, created_at
FROM exams ORDER BY created_at DESC LIMIT ?
''', (limit,))
rows = cursor.fetchall()
return [{
'id': r['id'],
'name': r['name'],
'total_score': r['total_score'],
'total_count': r['total_count'],
'status': r['status'],
'created_at': r['created_at']
} for r in rows]
# ==================== 批卷功能 ====================
def submit_answer(self, exam_id: str, student_id: str, question_id: str,
student_answer: str) -> str:
"""
提交学生答案
Args:
exam_id: 试卷ID
student_id: 学生ID
question_id: 题目ID
student_answer: 学生答案
Returns:
答案记录ID
"""
answer_id = str(uuid.uuid4())
# 获取题目信息
question = self.get_question(question_id)
if not question:
raise ValueError(f"题目不存在: {question_id}")
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO student_answers
(id, exam_id, student_id, question_id, question_type,
student_answer, max_score, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
answer_id, exam_id, student_id, question_id,
question['question_type'], student_answer, question['score'],
datetime.now().isoformat()
))
return answer_id
def grade_answer(self, answer_id: str, score: int, feedback: str,
score_details: Dict = None) -> Dict:
"""
批阅单个答案
Args:
answer_id: 答案ID
score: 得分
feedback: 反馈
score_details: 评分详情
Returns:
批阅结果
"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE student_answers
SET score = ?, feedback = ?, score_details = ?, graded_at = ?
WHERE id = ?
''', (
score, feedback,
json.dumps(score_details, ensure_ascii=False) if score_details else None,
datetime.now().isoformat(), answer_id
))
return {
'answer_id': answer_id,
'score': score,
'feedback': feedback
}
def get_student_answers(self, exam_id: str, student_id: str) -> List[Dict]:
"""获取学生的答卷"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM student_answers
WHERE exam_id = ? AND student_id = ?
''', (exam_id, student_id))
rows = cursor.fetchall()
return [self._row_to_answer(row) for row in rows]
def generate_grade_report(self, exam_id: str, student_id: str) -> Dict:
"""
生成批阅报告
Args:
exam_id: 试卷ID
student_id: 学生ID
Returns:
批阅报告
"""
# 获取学生答案
answers = self.get_student_answers(exam_id, student_id)
if not answers:
return {'error': '没有找到学生答案'}
# 计算总分
total_score = sum(a['score'] for a in answers)
max_score = sum(a['max_score'] for a in answers)
score_rate = round(total_score / max_score * 100, 2) if max_score > 0 else 0
# 生成报告
report_id = str(uuid.uuid4())
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO grade_reports
(id, exam_id, student_id, total_score, max_score, score_rate)
VALUES (?, ?, ?, ?, ?, ?)
''', (report_id, exam_id, student_id, total_score, max_score, score_rate))
return {
'report_id': report_id,
'exam_id': exam_id,
'student_id': student_id,
'total_score': total_score,
'max_score': max_score,
'score_rate': score_rate,
'answers': answers,
'graded_at': datetime.now().isoformat()
}
# ==================== 工具方法 ====================
def _detect_question_type(self, question: Dict) -> str:
"""检测题型"""
if 'options' in question and question['options']:
return 'choice'
elif 'reference_answer' in question:
return 'short_answer'
else:
return 'blank'
def _get_correct_answer(self, question: Dict) -> str:
"""获取正确答案(统一为字符串)"""
if 'reference_answer' in question:
return json.dumps(question['reference_answer'], ensure_ascii=False)
return question.get('answer', '')
def _get_score(self, question: Dict) -> int:
"""获取分值"""
if 'score' in question:
return question['score']
if 'reference_answer' in question:
return question['reference_answer'].get('total_score', 10)
return 2 # 默认选择题2分
def _row_to_question(self, row) -> Dict:
"""数据库行转题目字典"""
return {
'id': row['id'],
'question_type': row['question_type'],
'content': row['content'],
'options': json.loads(row['options']) if row['options'] else [],
'correct_answer': row['correct_answer'],
'analysis': row['analysis'],
'knowledge_points': json.loads(row['knowledge_points']) if row['knowledge_points'] else [],
'difficulty': row['difficulty'],
'score': row['score'],
'source_file': row['source_file'],
'source_collection': row['source_collection'],
'source_snippet': json.loads(row['source_snippet']) if row['source_snippet'] else [],
'source_hash': row['source_hash'],
'status': row['status'],
'created_at': row['created_at'],
'created_by': row['created_by']
}
def _row_to_answer(self, row) -> Dict:
"""数据库行转答案字典"""
return {
'id': row['id'],
'exam_id': row['exam_id'],
'student_id': row['student_id'],
'question_id': row['question_id'],
'question_type': row['question_type'],
'student_answer': row['student_answer'],
'score': row['score'],
'max_score': row['max_score'],
'feedback': row['feedback'],
'score_details': json.loads(row['score_details']) if row['score_details'] else {},
'submitted_at': row['submitted_at'],
'graded_at': row['graded_at']
}
# ==================== 统计功能 ====================
def get_stats(self) -> Dict:
"""获取数据库统计"""
with get_connection("exam") as conn:
cursor = conn.cursor()
stats = {}
cursor.execute('SELECT COUNT(*) FROM questions')
stats['total_questions'] = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM exams')
stats['total_exams'] = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM student_answers')
stats['total_answers'] = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM grade_reports')
stats['total_reports'] = cursor.fetchone()[0]
# 按题型统计
cursor.execute('SELECT question_type, COUNT(*) FROM questions GROUP BY question_type')
stats['by_type'] = dict(cursor.fetchall())
# 按来源文件统计
cursor.execute('SELECT source_file, COUNT(*) FROM questions GROUP BY source_file')
stats['by_source'] = dict(cursor.fetchall())
return stats
def clear_all(self):
"""清空所有数据(慎用)"""
with get_connection("exam") as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM grade_reports')
cursor.execute('DELETE FROM student_answers')
cursor.execute('DELETE FROM exam_questions')
cursor.execute('DELETE FROM exams')
cursor.execute('DELETE FROM questions')
logger.info("已清空所有数据")
# ==================== 命令行测试 ====================
if __name__ == '__main__':
db = ExamLocalDB()
print("\n" + "=" * 50)
print("本地出题批卷数据库测试")
print("=" * 50)
# 显示统计
stats = db.get_stats()
print(f"\n当前统计:")
print(f" 题目总数: {stats['total_questions']}")
print(f" 试卷总数: {stats['total_exams']}")
print(f" 答卷总数: {stats['total_answers']}")
print(f" 报告总数: {stats['total_reports']}")
if stats['by_type']:
print(f"\n按题型统计:")
for t, c in stats['by_type'].items():
print(f" {t}: {c}")
if stats['by_source']:
print(f"\n按来源文件统计:")
for f, c in stats['by_source'].items():
print(f" {f}: {c}")