init: RAG 知识库服务初始提交

- 后端 API(Flask + Gunicorn)
- RAG 引擎(混合检索 + 云端 Reranker + 引用溯源)
- 文档解析(MinerU + 多格式支持)
- Docker 生产部署配置
- 排除前端项目、敏感配置、模型文件
This commit is contained in:
lacerate551
2026-06-04 17:35:27 +08:00
commit 100d1a06eb
158 changed files with 64534 additions and 0 deletions

60
exam_pkg/__init__.py Normal file
View File

@@ -0,0 +1,60 @@
"""
考试系统模块
职责边界:
- RAG 服务负责:生成题目 + 批阅答案
- 后端服务负责:审核入库 + 状态管理
包含:
- generator: 出题生成器(本地 LLM 调用)
- grader: 批题器(本地 + LLM 评分)
- manager: 出题与批题核心逻辑
- api: Flask Blueprint API 路由
- local_db: 本地题库数据库(缓存)
"""
from exam_pkg.generator import (
QuestionGenerator,
build_semantic_query,
build_source_context,
safe_parse_questions,
validate_questions_schema,
generate_questions_from_content,
analyze_document_for_exam
)
from exam_pkg.manager import (
analyze_file_for_exam
)
from exam_pkg.grader import (
AnswerGrader,
grade_answers,
grade_objective,
grade_fill_blank
)
from exam_pkg.api import exam_bp
__all__ = [
# 生成器
'QuestionGenerator',
'build_semantic_query',
'build_source_context',
'safe_parse_questions',
'validate_questions_schema',
'generate_questions_from_content',
'analyze_document_for_exam',
# 管理器
'analyze_file_for_exam',
# 批题器
'AnswerGrader',
'grade_answers',
'grade_objective',
'grade_fill_blank',
# API
'exam_bp'
]

274
exam_pkg/api.py Normal file
View File

@@ -0,0 +1,274 @@
"""
出题与批题系统 API 蓝图
提供 REST API 接口:
- 出题:生成题目(返回 JSON 给后端)
- 批题:批阅答案(返回结果给后端)
职责边界:
- RAG 服务负责:生成题目 + 批阅答案
- 后端服务负责:审核入库 + 状态管理
使用方式:
from exam_pkg.api import exam_bp
app.register_blueprint(exam_bp, url_prefix='/exam')
"""
from flask import Blueprint, request, jsonify
import os
# 导入考试管理模块
from exam_pkg.manager import (
generate_questions_from_file,
grade_answers,
)
# 导入网关认证模块
from auth.gateway import (
require_gateway_auth, require_role, check_collection_permission, get_current_user
)
# 导入统一响应格式
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, NO_CONTENT, LLM_ERROR
from api.response_utils import success_response, error_response
# 创建蓝图
exam_bp = Blueprint('exam', __name__)
# ==================== 出题 API ====================
@exam_bp.route('/generate', methods=['POST'])
@require_gateway_auth
def api_generate_questions():
"""
🔥 新版出题接口
请求体:
{
"request_id": "uuid-optional", // 幂等性支持
"file_path": "public/产品手册.pdf",
"collection": "public_kb",
"question_types": {
"single_choice": 3,
"multiple_choice": 2,
"true_false": 2,
"fill_blank": 2,
"subjective": 1
},
"difficulty": 3,
"options": {
"include_explanation": true,
"max_source_chunks": 50
}
}
返回:
{
"success": true,
"request_id": "uuid",
"questions": [
{
"metadata": {"question_id": "...", "question_type": "...", ...},
"source_trace": {"document_name": "...", "sources": [...], ...},
"content": {"stem": "...", "data": {...}, "answer": "...", ...}
}
],
"total": 10,
"source_chunks_used": 15
}
"""
try:
data = request.json
file_path = data.get('file_path')
collection = data.get('collection')
question_types = data.get('question_types', {})
if not file_path or not collection:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 file_path 或 collection 参数", http_status=400)
if not question_types:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 question_types 参数", http_status=400)
# 获取当前用户
user = get_current_user()
if not user:
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
# 检查向量库访问权限
if not check_collection_permission(
role=user['role'],
department=user.get('department', ''),
collection_name=collection,
operation="read"
):
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
# 调用新版出题接口
result = generate_questions_from_file(
file_path=file_path,
collection=collection,
question_types=question_types,
difficulty=data.get('difficulty', 3),
options=data.get('options', {}),
request_id=data.get('request_id')
)
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
except Exception as e:
return error_response("EXAM_ERROR", EXAM_ERROR, str(e), http_status=500)
@exam_bp.route('/generate-smart', methods=['POST'])
@require_gateway_auth
def api_generate_smart():
"""
AI 智能出题 - 自动分析文件并决定题型和数量
与 /exam/generate 的区别:不需要传 question_typesAI 自动分析文档后决定
请求体:
{
"file_path": "public/产品手册.pdf",
"collection": "public_kb",
"difficulty": 3, // 可选,默认 3
"options": {} // 可选
}
返回:
与 /exam/generate 相同格式,额外包含 ai_analysis 字段
"""
try:
data = request.json
file_path = data.get('file_path')
collection = data.get('collection')
if not file_path or not collection:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 file_path 或 collection 参数", http_status=400)
# 获取当前用户
user = get_current_user()
if not user:
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
# 检查向量库访问权限
if not check_collection_permission(
role=user['role'],
department=user.get('department', ''),
collection_name=collection,
operation="read"
):
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
# 1. 调用 AI 分析文件,获取推荐的题型和数量
from exam_pkg.manager import analyze_file_for_exam
ai_analysis = analyze_file_for_exam(
file_path=file_path,
collection=collection
)
question_types = ai_analysis.get('question_types', {})
if not question_types or sum(question_types.values()) == 0:
return error_response("EXAM_ERROR", EXAM_ERROR, "AI 分析后未生成有效题型配置", http_status=500)
# 2. 使用 AI 推荐的题型调用出题接口
result = generate_questions_from_file(
file_path=file_path,
collection=collection,
question_types=question_types,
difficulty=data.get('difficulty', 3),
options=data.get('options', {}),
request_id=data.get('request_id')
)
# 3. 在返回结果中添加 AI 分析信息
result['ai_analysis'] = ai_analysis
return success_response(data=result, status_code=EXAM_SUCCESS, message="AI 智能出题成功")
except Exception as e:
return error_response("EXAM_ERROR", EXAM_ERROR, str(e), http_status=500)
# ==================== 批题 API ====================
@exam_bp.route('/grade', methods=['POST'])
@require_gateway_auth
def api_grade_answers():
"""
🔥 新版批题接口
请求体:
{
"request_id": "uuid-optional",
"answers": [
{
"question_id": "uuid",
"question_type": "single_choice",
"question_content": {"answer": "B"},
"student_answer": "A",
"max_score": 2
},
{
"question_id": "uuid",
"question_type": "fill_blank",
"question_content": {"answer": [["答案1"], ["答案2"]]},
"student_answer": ["学生答案1", "学生答案2"],
"max_score": 4
},
{
"question_id": "uuid",
"question_type": "subjective",
"question_content": {
"stem": "简述...",
"data": {"scoring_points": [...]},
"answer": "参考范文..."
},
"student_answer": "学生作答内容...",
"max_score": 10
}
]
}
返回:
{
"success": true,
"request_id": "uuid",
"results": [...],
"total_score": 10.5,
"total_max_score": 16.0,
"score_rate": 65.6
}
"""
try:
data = request.json
answers = data.get('answers', [])
if not answers:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少答案数据", http_status=400)
# 调用新版批题接口
result = grade_answers(
answers=answers,
request_id=data.get('request_id')
)
return success_response(data=result, status_code=GRADE_SUCCESS, message="批阅完成")
except Exception as e:
return error_response("GRADE_ERROR", GRADE_ERROR, str(e), http_status=500)
# ==================== 健康检查 ====================
@exam_bp.route('/health', methods=['GET'])
def api_health():
"""健康检查"""
return jsonify({
"status": "ok",
"service": "exam-api",
"version": "2.0"
})

1393
exam_pkg/generator.py Normal file

File diff suppressed because it is too large Load Diff

399
exam_pkg/grader.py Normal file
View File

@@ -0,0 +1,399 @@
"""
批题器 - 本地批阅逻辑(后续可迁移到 Dify 工作流)
核心功能:
1. 本地批阅选择题/判断题
2. 填空题模糊匹配
3. 主观题 LLM 评分
4. 并发批阅 + 限流 + 顺序保持
使用方式:
from exam_pkg.grader import AnswerGrader
grader = AnswerGrader()
results = grader.grade_answers(answers)
"""
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import wraps
from typing import List, Dict, Any, Optional
# 导入 LLM 工具函数
from core.llm_utils import call_llm
# 导入 LLM 配置
try:
from config import API_KEY, BASE_URL, MODEL
LLM_AVAILABLE = True
except ImportError:
API_KEY = None
BASE_URL = None
MODEL = None
LLM_AVAILABLE = False
# ==================== 装饰器 ====================
def retry(times: int = 2, delay: float = 1.0):
"""
🔥 P1 改进:重试装饰器
Args:
times: 重试次数
delay: 重试间隔(秒)
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for i in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
if i < times - 1:
time.sleep(delay)
raise last_error
return wrapper
return decorator
# ==================== 限流 ====================
# 🔥 P2 改进:限流信号量
MAX_CONCURRENT_GRADING = 3
grading_semaphore = threading.Semaphore(MAX_CONCURRENT_GRADING)
# ==================== 本地批阅函数 ====================
def grade_objective(answer: Dict) -> Dict:
"""
批阅客观题(选择/判断)
🔥 本地直接判断,无 LLM 调用
"""
q_type = answer['question_type']
question_content = answer.get('question_content', {})
correct_answer = question_content.get('answer')
student_answer = answer.get('student_answer')
max_score = answer.get('max_score', 2.0)
# 判断正确性
if q_type == 'single_choice':
correct = student_answer == correct_answer
elif q_type == 'multiple_choice':
# 多选题:答案顺序无关
correct = set(student_answer) == set(correct_answer) if isinstance(student_answer, list) else False
elif q_type == 'true_false':
correct = student_answer == correct_answer
else:
correct = False
return {
"question_id": answer.get('question_id'),
"score": max_score if correct else 0,
"max_score": max_score,
"correct": correct,
"feedback": f"正确答案: {correct_answer}" if not correct else "正确!"
}
def grade_fill_blank(answer: Dict) -> Dict:
"""
批阅填空题 - 支持同义词匹配
填空题答案格式:[["答案1", "同义词1", ...], ["答案2", ...], ...]
学生答案格式:["学生答案1", "学生答案2", ...]
"""
question_content = answer.get('question_content', {})
correct_answers = question_content.get('answer', []) # [[答案1, 同义词...], ...]
student_answers = answer.get('student_answer', [])
max_score = answer.get('max_score', 4.0)
if not correct_answers or not student_answers:
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"details": {"error": "答案格式错误"}
}
# 计算每空分数
score_per_blank = max_score / len(correct_answers)
blank_scores = []
total_score = 0
for i, correct_list in enumerate(correct_answers):
if i >= len(student_answers):
blank_scores.append(0)
continue
student_ans = student_answers[i]
# 检查是否匹配任一正确答案
matched = any(
fuzzy_match(student_ans, correct)
for correct in correct_list
)
blank_score = score_per_blank if matched else 0
blank_scores.append(blank_score)
total_score += blank_score
return {
"question_id": answer.get('question_id'),
"score": round(total_score, 1),
"max_score": max_score,
"details": {
"blank_scores": blank_scores,
"correct_answers": correct_answers
}
}
def fuzzy_match(student_answer: str, correct_answer: str) -> bool:
"""
模糊匹配(支持同义词)
当前实现:精确匹配(忽略前后空格、大小写)
TODO: 可以扩展为语义相似度匹配
"""
if not student_answer or not correct_answer:
return False
# 标准化:去空格、转小写
s = student_answer.strip().lower()
c = correct_answer.strip().lower()
return s == c
# ==================== AnswerGrader 类 ====================
class AnswerGrader:
"""本地批题器 - 使用本地 OpenAI 客户端"""
def __init__(self):
self.client = None
if LLM_AVAILABLE and API_KEY:
try:
from openai import OpenAI
self.client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
except ImportError:
pass
self.model = MODEL
def grade_answers(self, answers: List[Dict]) -> List[Dict]:
"""
批阅答案列表
🔥 P1 改进:
- 本地批阅选择题/判断题
- 填空题本地模糊匹配
- 主观题调用 LLM 评分
- 结果顺序保持
"""
results_map = {}
# 分离题型
local_questions = [] # 选择题、判断题
fill_blank_questions = [] # 填空题
llm_questions = [] # 主观题
for ans in answers:
q_type = ans.get('question_type')
if q_type in ['single_choice', 'multiple_choice', 'true_false']:
local_questions.append(ans)
elif q_type == 'fill_blank':
fill_blank_questions.append(ans)
else:
llm_questions.append(ans)
# 本地批阅选择题/判断题
for ans in local_questions:
result = grade_objective(ans)
results_map[ans.get('question_id')] = result
# 本地批阅填空题
for ans in fill_blank_questions:
result = grade_fill_blank(ans)
results_map[ans.get('question_id')] = result
# 🔥 P1 改进:并发调用 LLM 批阅主观题
if llm_questions:
self._grade_subjective_concurrently(llm_questions, results_map)
# 🔥 P1 改进:按原始顺序重组结果
results = [results_map.get(ans.get('question_id')) for ans in answers]
return results
def _grade_subjective_concurrently(self, questions: List[Dict], results_map: Dict):
"""并发批阅主观题"""
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_GRADING) as executor:
# 建立映射关系
future_to_qid = {
executor.submit(self._grade_subjective, ans): ans.get('question_id')
for ans in questions
}
# 收集结果
for future in as_completed(future_to_qid, timeout=60):
qid = future_to_qid[future]
try:
result = future.result(timeout=15)
results_map[qid] = result
except Exception as e:
# 失败时返回默认结果
results_map[qid] = {
"question_id": qid,
"score": 0,
"max_score": next(
(a.get('max_score', 10) for a in questions if a.get('question_id') == qid),
10
),
"error": str(e)
}
@retry(times=2, delay=1)
def _grade_subjective(self, answer: Dict) -> Dict:
"""
批阅主观题 - 调用 LLM 评分
🔥 P1 改进:带重试
"""
with grading_semaphore: # 限流
prompt = self._build_grading_prompt(answer)
response = self._call_llm(prompt)
return self._parse_grading_result(response, answer)
def _build_grading_prompt(self, answer: Dict) -> str:
"""构造评分 Prompt"""
question_content = answer.get('question_content', {})
scoring_points = question_content.get('data', {}).get('scoring_points', [])
return f"""请批阅以下简答题。
## 题目
{question_content.get('stem', '')}
## 参考答案
{question_content.get('answer', '')}
## 评分标准
{json.dumps(scoring_points, ensure_ascii=False, indent=2)}
## 学生答案
{answer.get('student_answer', '')}
## 满分
{answer.get('max_score', 10)}
## 输出约束
1. 必须输出合法 JSON
2. score 不能超过满分
3. achieved 为 0-1 之间的比例
## 输出格式JSON
{{
"score": 得分,
"scoring_breakdown": [
{{"point": "要点名称", "weight": 权重, "achieved": 实际得分比例, "comment": "评语"}}
],
"highlights": ["亮点1", "亮点2"],
"shortcomings": ["不足1"],
"overall_feedback": "整体评价"
}}
请直接输出 JSON"""
def _call_llm(self, prompt: str) -> str:
"""调用本地 LLM"""
if not self.client:
# 无 LLM 客户端,返回默认评分
return json.dumps({"score": 0, "overall_feedback": "LLM 未配置"})
messages = [
{"role": "system", "content": "你是一个专业的阅卷老师请严格按照JSON格式输出评分结果。"},
{"role": "user", "content": prompt}
]
result = call_llm(
client=self.client,
prompt=prompt,
model=self.model,
temperature=0.3,
max_tokens=1000,
messages=messages
)
if result is None:
raise Exception("LLM 调用失败")
return result
def _parse_grading_result(self, response: str, answer: Dict) -> Dict:
"""解析评分结果"""
max_score = answer.get('max_score', 10)
try:
# 尝试解析 JSON
result = json.loads(response)
score = min(result.get('score', 0), max_score) # 不能超过满分
return {
"question_id": answer.get('question_id'),
"score": score,
"max_score": max_score,
"details": {
"scoring_breakdown": result.get('scoring_breakdown', []),
"highlights": result.get('highlights', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
}
except (json.JSONDecodeError, KeyError, TypeError) as e:
# 解析失败,返回默认
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"details": {"error": "评分结果解析失败"}
}
# ==================== 批题入口函数 ====================
def grade_answers(answers: List[Dict], request_id: str = None) -> Dict:
"""
批阅答案入口函数
🔥 P1/P2 改进:
- 加 timeout + retry
- 结果顺序保持
- 限流控制
Args:
answers: 答案列表
request_id: 请求 ID幂等性支持
Returns:
批阅结果
"""
grader = AnswerGrader()
results = grader.grade_answers(answers)
# 计算总分
total_score = sum(r.get('score', 0) for r in results if r)
total_max = sum(r.get('max_score', 0) for r in results if r)
return {
"success": True,
"request_id": request_id,
"results": results,
"total_score": round(total_score, 1),
"total_max_score": total_max,
"score_rate": round(total_score / total_max * 100, 1) if total_max > 0 else 0
}

509
exam_pkg/local_db.py Normal file
View File

@@ -0,0 +1,509 @@
"""
本地出题批卷数据库 - 用于开发测试
使用 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))
# 关联题目
for order, qid in enumerate(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': 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}")

742
exam_pkg/manager.py Normal file
View File

@@ -0,0 +1,742 @@
"""
出题与批题系统管理器
核心功能:
1. 题目生成(调用 generator.py
2. 答案批阅(调用 grader.py
3. 题库管理(保存/加载/搜索)
职责边界:
- RAG 服务负责:生成题目 + 批阅答案
- 后端服务负责:审核入库 + 状态管理
使用方式:
from exam_pkg.manager import generate_questions_from_file, grade_answers
"""
import json
import os
import re
import uuid
import hashlib
from datetime import datetime
from typing import Optional, List, Dict, Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging
logger = logging.getLogger(__name__)
# 状态常量
EXAM_STATUS_DRAFT = "draft"
EXAM_STATUS_APPROVED = "approved"
# 导入新的生成器和批题器
from exam_pkg.generator import (
QuestionGenerator,
build_semantic_query,
build_source_context,
safe_parse_questions,
validate_questions_schema
)
from exam_pkg.grader import (
AnswerGrader,
grade_answers as grade_answers_v2,
grade_objective,
grade_fill_blank
)
# MinerU 解析器(可选)
try:
from parsers import parse_document, MINERU_AVAILABLE
if MINERU_AVAILABLE:
from parsers.mineru_parser import MinerUChunk
PARSE_AVAILABLE = MINERU_AVAILABLE
except ImportError:
PARSE_AVAILABLE = False
# 导入配置
# 题库目录
QUESTION_BANK_DIR = "./题库"
DRAFT_DIR = "./题库/草稿"
# ==================== 新版出题接口 ====================
def generate_questions_from_file(
file_path: str,
collection: str,
question_types: Dict[str, int],
difficulty: int = 3,
options: Dict = None,
request_id: str = None
) -> Dict:
"""
从文件生成题目(结构化出题 v2
🔥 改进版 Structure-aware RAG 出题架构:
- Phase 1: 文档结构分析
- Phase 2: 知识点规划(全局唯一)
- Phase 3: 精准出题(每个知识点单独检索)
- Phase 4: 质量校验
Args:
file_path: 文件路径
collection: 向量库名称
question_types: 题型及数量 {"single_choice": 3, "fill_blank": 2, ...}
difficulty: 难度 1-5
options: 可选配置
- max_source_chunks: 最大切片数(默认 30
request_id: 请求 ID幂等性支持
Returns:
{
"success": True,
"request_id": "...",
"questions": [...],
"total": 10,
"source_chunks_used": 15
}
"""
options = options or {}
# 1. 检索文件的所有切片
chunks = retrieve_file_chunks(
file_path=file_path,
collection=collection,
question_types=question_types,
top_k=options.get('max_source_chunks', 50)
)
logger.debug(f"generate_questions_from_file: chunks 数量 = {len(chunks)}")
# 2. 结构化出题 v2全局唯一知识点 + 精准出题
from exam_pkg.generator import generate_questions_structured_v2
questions = generate_questions_structured_v2(
chunks=chunks,
document_name=file_path,
question_types=question_types,
difficulty=difficulty
)
return {
"success": True,
"request_id": request_id,
"questions": questions,
"total": len(questions),
"source_chunks_used": len(chunks)
}
def analyze_file_for_exam(
file_path: str,
collection: str,
top_k: int = 50
) -> Dict[str, Any]:
"""
分析文件内容,返回 AI 推荐的题型和数量
Args:
file_path: 文件路径
collection: 向量库名称
top_k: 最大切片数(默认 50用于分析
Returns:
{
"total_knowledge_points": int,
"suitable_types": List[str],
"question_types": Dict[str, int],
"reason": str
}
"""
from exam_pkg.generator import analyze_document_for_exam
# 1. 检索文件切片(用于分析)
# 使用通用 query 检索,获取尽可能多的内容
query = "重点内容 关键概念 定义 流程 步骤"
chunks = retrieve_file_chunks_for_analysis(
file_path=file_path,
collection=collection,
query=query,
top_k=top_k
)
if not chunks:
return {
"total_knowledge_points": 0,
"suitable_types": [],
"question_types": {},
"reason": "未找到文件内容,请检查文件路径和向量库"
}
# 2. 调用 AI 分析
return analyze_document_for_exam(chunks)
def retrieve_file_chunks_for_analysis(
file_path: str,
collection,
query: str,
top_k: int = 50
) -> List[Dict]:
"""
检索文件切片(用于分析,不需要 question_types
Args:
file_path: 文件路径
collection: 向量库名称
query: 检索 query
top_k: 最大切片数
Returns:
切片列表
"""
try:
# 获取 RAG 引擎
from core.engine import get_engine
engine = get_engine()
if not engine:
logger.error("RAG 引擎未初始化")
return []
# 准备 collection 列表
collections = [collection] if isinstance(collection, str) else collection
# 提取文件名(向量库存储的是文件名,不含路径前缀)
filename = os.path.basename(file_path)
# 向量检索(尝试文件名和完整路径)
results = None
for source_filter in [filename, file_path]:
results = engine.search_knowledge(
query=query,
collections=collections,
source_filter=source_filter,
top_k=top_k
)
if results.get('documents') and results['documents'][0]:
break
if not results or not results.get('documents') or not results['documents'][0]:
logger.warning(f"未检索到相关切片: file={file_path}, collection={collection}")
return []
# 整理结果search_knowledge 返回格式: {ids: [[...]], documents: [[...]], metadatas: [[...]], distances: [[...]]})
chunks = []
for i, (doc, meta, score) in enumerate(zip(
results['documents'][0],
results['metadatas'][0],
results['distances'][0]
)):
chunks.append({
"chunk_id": results['ids'][0][i],
"content": doc,
"source": meta.get('source', ''),
"page": meta.get('page'),
"section": meta.get('section', ''),
"score": score
})
return chunks
except Exception as e:
logger.error(f"检索文件切片失败: {e}")
return []
def retrieve_file_chunks(
file_path: str,
collection, # 支持字符串或列表
question_types: Dict[str, int],
top_k: int = None
) -> List[Dict]:
"""
检索文件相关切片
Args:
file_path: 文件路径
collection: 向量库名称(支持字符串或列表,列表时按优先级顺序检索)
question_types: 题型及数量
top_k: 最大切片数
Returns:
切片列表
"""
# 动态计算 top_k题型数量 × 3上限 50
if top_k is None:
total_questions = sum(question_types.values())
top_k = min(50, total_questions * 3)
# 根据题型构建语义 query
query = build_semantic_query(question_types)
# 提取文件名(向量库存储的是文件名,不含路径前缀)
filename = os.path.basename(file_path)
# 统一处理为列表
if isinstance(collection, str):
collections = [collection]
else:
collections = list(collection) if collection else []
if not collections:
logger.error("[ERROR] 未指定向量库")
return []
try:
from core.engine import get_engine
engine = get_engine()
# 按优先级遍历 collections找到文件即停止
for coll in collections:
# 尝试两种格式:文件名和完整路径
for source_filter in [filename, file_path]:
results = engine.search_knowledge(
query=query,
collections=[coll],
source_filter=source_filter,
top_k=top_k
)
# 检查是否有结果
if results.get('documents') and results['documents'][0]:
logger.debug(f"在向量库 [{coll}] 中找到文件 [{filename}]")
break
else:
continue
break # 外层循环跳出
chunks = []
if results.get('documents') and results['documents'][0]:
for i, (doc, meta, score) in enumerate(zip(
results['documents'][0],
results['metadatas'][0],
results['distances'][0]
)):
chunks.append({
"chunk_id": results['ids'][0][i],
"content": doc,
"source": meta.get('source'),
"page": meta.get('page'),
"section": meta.get('section', ''),
"score": score
})
logger.debug(f"retrieve_file_chunks: 检索到 {len(chunks)} 个切片")
return chunks
except Exception as e:
logger.error(f"检索切片失败: {e}")
return []
# ==================== 新版批题接口 ====================
def grade_answers(answers: List[Dict], request_id: str = None) -> Dict:
"""
批阅答案入口函数
🔥 改进:
- 本地批阅选择题/判断题
- 填空题模糊匹配
- 主观题 LLM 评分
- 并发 + 限流 + 顺序保持
"""
return grade_answers_v2(answers, request_id)
def get_source_chapters_for_question(
file_path: str,
question_topic: str,
top_k: int = 3
) -> List[Dict[str, Any]]:
"""
获取与问题主题相关的章节内容(用于出题或批阅参考)
Args:
file_path: 文档文件路径(支持 PDF/DOCX/PPTX
question_topic: 问题主题或关键词
top_k: 返回的章节数量
Returns:
[
{
"title": "章节标题",
"content": "完整内容",
"section_path": "章节路径",
"page_range": "1-2"
}
]
"""
if not PARSE_AVAILABLE:
raise ImportError("MinerU 解析器不可用")
result = parse_document(
file_path,
output_base=".data/mineru_temp",
images_output=".data/images",
cleanup_after_image_move=True
)
chunks = result.get('chunks', [])
# 构建章节内容列表
chapter_contents = []
for chunk in chunks:
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
if not content.strip():
continue
chapter_contents.append({
"title": chunk.title if hasattr(chunk, 'title') else '',
"content": content.strip(),
"section_path": chunk.section_path if hasattr(chunk, 'section_path') else '',
"page_range": f"{chunk.page_start}-{chunk.page_end}" if hasattr(chunk, 'page_start') else '',
"source_file": chunk.source_file if hasattr(chunk, 'source_file') else os.path.basename(file_path)
})
# 使用关键词检索相关章节
keywords = question_topic.split() if question_topic else []
# 简单的关键词匹配筛选
relevant = []
for c in chapter_contents:
content_lower = c['content'].lower()
if any(kw.lower() in content_lower for kw in keywords):
relevant.append(c)
return relevant[:top_k]
def sanitize_filename(name: str) -> str:
# 移除或替换非法字符
illegal_chars = r'[<>:"/\\|?*\x00-\x1f]'
safe_name = re.sub(illegal_chars, '_', name)
# 移除首尾空格和点
safe_name = safe_name.strip('. ')
# 限制长度
if len(safe_name) > 100:
safe_name = safe_name[:100]
# 如果清理后为空,使用时间戳
if not safe_name:
safe_name = datetime.now().strftime('%Y%m%d_%H%M%S')
return safe_name
def save_exam(exam: dict, name: str = None) -> str:
"""
保存试卷到对应目录
- draft 状态:保存到 题库/草稿/ 目录
- approved 状态:保存到 题库/ 目录
Args:
exam: 试卷JSON
name: 文件名(不含扩展名),默认使用试卷名称
Returns:
保存的文件路径
"""
# 根据状态确定保存目录
status = exam.get("status", EXAM_STATUS_DRAFT)
if status == EXAM_STATUS_APPROVED:
save_dir = QUESTION_BANK_DIR
else:
save_dir = DRAFT_DIR
# 确保目录存在
os.makedirs(save_dir, exist_ok=True)
# 确保草稿目录存在
os.makedirs(DRAFT_DIR, exist_ok=True)
# 确定文件名:优先使用试卷名称
if name is None:
# 使用试卷名称作为文件名
exam_name = exam.get("name") or exam.get("topic", "未命名试卷")
name = sanitize_filename(exam_name)
# 检查是否已存在同名文件,如果存在则添加时间戳
filepath = os.path.join(save_dir, f"{name}.json")
if os.path.exists(filepath):
timestamp = datetime.now().strftime('_%Y%m%d_%H%M%S')
name = f"{name}{timestamp}"
filepath = os.path.join(save_dir, f"{name}.json")
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(exam, f, ensure_ascii=False, indent=2)
logger.info(f"试卷已保存: {filepath} (状态: {status})")
return filepath
def load_exam(filepath: str) -> dict:
"""加载试卷"""
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
# ==================== 题库管理函数 ====================
def list_exams(status: str = None, page: int = 1, limit: int = 20) -> dict:
"""
获取试卷列表
Args:
status: 状态过滤draft/pending_review/approved/rejected
page: 页码
limit: 每页数量
Returns:
{
"exams": [...],
"total": int,
"page": int
}
"""
# 确保目录存在
os.makedirs(QUESTION_BANK_DIR, exist_ok=True)
os.makedirs(DRAFT_DIR, exist_ok=True)
exams = []
# 从题库目录和草稿目录加载试卷
for directory in [QUESTION_BANK_DIR, DRAFT_DIR]:
if not os.path.exists(directory):
continue
for filename in os.listdir(directory):
if not filename.endswith('.json'):
continue
filepath = os.path.join(directory, filename)
try:
exam = load_exam(filepath)
# 状态过滤
if status and exam.get("status") != status:
continue
# 只返回摘要信息
exams.append({
"exam_id": exam.get("exam_id", filename[:-5]),
"name": exam.get("name") or exam.get("topic", "未命名试卷"),
"topic": exam.get("topic", ""),
"status": exam.get("status", "approved"),
"total_count": exam.get("total_count", 0),
"total_score": exam.get("total_score", 0),
"created_at": exam.get("created_at", ""),
"created_by": exam.get("created_by", ""),
"filename": filename,
"filepath": filepath
})
except Exception as e:
logger.error(f"加载试卷失败 {filename}: {e}")
continue
# 按创建时间倒序
exams.sort(key=lambda x: x.get("created_at", ""), reverse=True)
# 分页
total = len(exams)
start = (page - 1) * limit
end = start + limit
return {
"exams": exams[start:end],
"total": total,
"page": page
}
def _find_exam_filepath(exam_id: str) -> Optional[str]:
"""
查找试卷文件路径(在题库和草稿目录中查找)
Args:
exam_id: 试卷ID
Returns:
文件路径如果不存在返回None
"""
# 确保目录存在
os.makedirs(QUESTION_BANK_DIR, exist_ok=True)
os.makedirs(DRAFT_DIR, exist_ok=True)
# 先检查题库目录
for directory in [DRAFT_DIR, QUESTION_BANK_DIR]:
for filename in os.listdir(directory):
if not filename.endswith('.json'):
continue
filepath = os.path.join(directory, filename)
try:
exam = load_exam(filepath)
if exam.get("exam_id") == exam_id:
return filepath
except (json.JSONDecodeError, OSError, KeyError):
continue
return None
def get_exam_by_id(exam_id: str) -> Optional[dict]:
"""
根据 ID 获取试卷
Args:
exam_id: 试卷ID
Returns:
试卷JSON如果不存在返回None
"""
filepath = _find_exam_filepath(exam_id)
if filepath:
return load_exam(filepath)
return None
def update_exam(exam_id: str, exam_data: dict) -> Optional[dict]:
"""
更新试卷(合并更新,保留未传字段)
Args:
exam_id: 试卷ID
exam_data: 新的试卷数据(部分字段)
Returns:
更新后的试卷如果不存在返回None
"""
# 查找试卷文件路径
filepath = _find_exam_filepath(exam_id)
if not filepath:
return None
# 加载现有试卷数据
existing_exam = load_exam(filepath)
# 合并数据:现有数据 + 新数据(新数据覆盖同名字段)
merged = {**existing_exam, **exam_data}
# 保留不可修改的字段
merged["exam_id"] = exam_id
merged["created_at"] = existing_exam.get("created_at")
merged["created_by"] = existing_exam.get("created_by")
# 保留状态(除非明确传入新状态)
if "status" not in exam_data:
merged["status"] = existing_exam.get("status", EXAM_STATUS_DRAFT)
# 更新时间戳
merged["updated_at"] = datetime.now().isoformat()
# 重新计算统计
merged["total_count"] = (
len(merged.get("choice_questions", [])) +
len(merged.get("blank_questions", [])) +
len(merged.get("short_answer_questions", []))
)
# 保存到原位置
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(merged, f, ensure_ascii=False, indent=2)
return merged
def delete_exam(exam_id: str) -> bool:
"""
删除试卷
Args:
exam_id: 试卷ID
Returns:
是否删除成功
"""
filepath = _find_exam_filepath(exam_id)
if filepath and os.path.exists(filepath):
os.remove(filepath)
return True
return False
# ==================== 题目搜索 ====================
def search_questions(keyword: str, question_type: str = None,
difficulty: int = None, limit: int = 50) -> dict:
"""
搜索题目
Args:
keyword: 搜索关键词
question_type: 题型过滤choice/blank/short_answer
difficulty: 难度过滤
limit: 返回数量限制
Returns:
{
"questions": [...],
"total": int
}
"""
os.makedirs(QUESTION_BANK_DIR, exist_ok=True)
results = []
keyword_lower = keyword.lower()
for filename in os.listdir(QUESTION_BANK_DIR):
if not filename.endswith('.json'):
continue
filepath = os.path.join(QUESTION_BANK_DIR, filename)
try:
exam = load_exam(filepath)
exam_id = exam.get("exam_id", filename[:-5])
exam_name = exam.get("topic", filename[:-5])
# 搜索选择题
if not question_type or question_type == "choice":
for q in exam.get("choice_questions", []):
if difficulty and q.get("difficulty") != difficulty:
continue
content = q.get("content", "").lower()
if keyword_lower in content:
results.append({
"exam_id": exam_id,
"exam_name": exam_name,
"type": "choice",
"question": q
})
# 搜索填空题
if not question_type or question_type == "blank":
for q in exam.get("blank_questions", []):
if difficulty and q.get("difficulty") != difficulty:
continue
content = q.get("content", "").lower()
if keyword_lower in content:
results.append({
"exam_id": exam_id,
"exam_name": exam_name,
"type": "blank",
"question": q
})
# 搜索简答题
if not question_type or question_type == "short_answer":
for q in exam.get("short_answer_questions", []):
if difficulty and q.get("difficulty") != difficulty:
continue
content = q.get("content", "").lower()
if keyword_lower in content:
results.append({
"exam_id": exam_id,
"exam_name": exam_name,
"type": "short_answer",
"question": q
})
except Exception as e:
logger.error(f"搜索试卷失败 {filename}: {e}")
continue
return {
"questions": results[:limit],
"total": len(results)
}