init: RAG 知识库服务初始提交
- 后端 API(Flask + Gunicorn) - RAG 引擎(混合检索 + 云端 Reranker + 引用溯源) - 文档解析(MinerU + 多格式支持) - Docker 生产部署配置 - 排除前端项目、敏感配置、模型文件
This commit is contained in:
60
exam_pkg/__init__.py
Normal file
60
exam_pkg/__init__.py
Normal 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
274
exam_pkg/api.py
Normal 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_types,AI 自动分析文档后决定
|
||||
|
||||
请求体:
|
||||
{
|
||||
"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
1393
exam_pkg/generator.py
Normal file
File diff suppressed because it is too large
Load Diff
399
exam_pkg/grader.py
Normal file
399
exam_pkg/grader.py
Normal 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
509
exam_pkg/local_db.py
Normal 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
742
exam_pkg/manager.py
Normal 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user