Files
rag/exam_pkg/api.py
lacerate551 183a57e7f1 refactor(api): 统一响应格式迁移 + 异步任务系统 + 状态码体系完善
- 将全部路由文件(12个)的 jsonify 响应迁移至 success_response/error_response 统一格式
- 修复 sync_routes.py error_response 参数错误(P0)
- 新增异步任务系统:task_registry + task_routes
- 新增状态码:TASK_NOT_FOUND(4014)、TASK_CONFLICT(4015)、REINDEX_ERROR(5040)
- 修正 task_routes/exam_pkg 中语义不匹配的状态码
- 更新 curl 测试手册、后端对接规范文档
- 添加缓存性能报告和 Redis 迁移计划
2026-06-05 22:56:00 +08:00

472 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
出题与批题系统 API 蓝图
提供 REST API 接口:
- 出题:生成题目(异步任务,返回 task_id
- 批题:批阅答案(异步任务,返回 task_id
职责边界:
- RAG 服务负责:生成题目 + 批阅答案
- 后端服务负责:审核入库 + 状态管理
使用方式:
from exam_pkg.api import exam_bp
app.register_blueprint(exam_bp, url_prefix='/exam')
异步任务流程:
1. POST /exam/generate → 返回 {"task_id": "xxx", ...}
2. GET /tasks/xxx → 轮询状态,直到 completed
3. result 字段包含完整出题/批阅结果
"""
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, UNAUTHORIZED, FORBIDDEN, NO_CONTENT, LLM_ERROR
from api.response_utils import success_response, error_response
# 合法题型
VALID_QUESTION_TYPES = {'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective'}
def validate_question_types(question_types: dict) -> str:
"""
校验 question_types 参数
Returns:
None 表示校验通过,字符串表示错误信息
"""
if not isinstance(question_types, dict):
return "question_types 必须为对象"
for q_type, count in question_types.items():
if q_type not in VALID_QUESTION_TYPES:
return f"不支持的题型: {q_type},合法值: {', '.join(sorted(VALID_QUESTION_TYPES))}"
if not isinstance(count, int) or count < 0:
return f"题型 {q_type} 的数量必须为非负整数,当前值: {count}"
return None
def validate_difficulty(difficulty) -> str:
"""校验 difficulty 参数"""
if not isinstance(difficulty, int) or difficulty < 1 or difficulty > 5:
return "difficulty 必须为 1-5 的整数"
return None
MAX_EXCLUDE_STEMS = 100
def validate_exclude_stems(exclude_stems) -> str:
"""校验 exclude_stems 参数(可选)"""
if exclude_stems is None:
return None
if not isinstance(exclude_stems, list):
return "exclude_stems 必须为字符串数组"
if len(exclude_stems) > MAX_EXCLUDE_STEMS:
return f"exclude_stems 数量不能超过 {MAX_EXCLUDE_STEMS},当前 {len(exclude_stems)}"
for i, stem in enumerate(exclude_stems):
if not isinstance(stem, str) or not stem.strip():
return f"exclude_stems 第 {i+1} 项必须为非空字符串"
return None
# 创建蓝图
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,
"exclude_stems": ["已有题目的题干1", "已有题目的题干2"], // 可选,排除已有题目避免重复
"options": {
"include_explanation": true,
"max_source_chunks": 50
}
}
返回:
{
"success": true,
"request_id": "uuid",
"questions": [
{
"question_type": "single_choice",
"difficulty": 3,
"content": {"stem": "...", "data": {...}, "answer": "...", "explanation": "..."},
"source_trace": {"document_name": "...", "chunk_ids": [...], "sources": [...]}
}
],
"total": 10,
"requested_types": {"single_choice": 3, "fill_blank": 2},
"actual_types": {"single_choice": 3, "fill_blank": 2},
"warnings": ["fill_blank: 请求 3 道,实际生成 2 道"],
"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)
# 校验题型和数量
type_error = validate_question_types(question_types)
if type_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, type_error, http_status=400)
# 校验难度
difficulty = data.get('difficulty', 3)
diff_error = validate_difficulty(difficulty)
if diff_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
# 校验总题数上限
MAX_TOTAL_QUESTIONS = 20
total_requested = sum(question_types.values())
if total_requested > MAX_TOTAL_QUESTIONS:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"总题数不能超过 {MAX_TOTAL_QUESTIONS} 道,当前请求 {total_requested}",
http_status=400)
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems)
if stems_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, stems_error, http_status=400)
# 获取当前用户
user = get_current_user()
if not user:
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
# 检查向量库访问权限
if not check_collection_permission(
role=user['role'],
department=user.get('department', ''),
collection_name=collection,
operation="read"
):
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
# 调用新版出题接口(异步任务)
from core.task_registry import get_registry
import logging as _logging
_logger = _logging.getLogger(__name__)
registry = get_registry()
total_questions = sum(question_types.values())
task = registry.create_task(
'exam_generate',
f"出题: {os.path.basename(file_path)} ({total_questions}题)",
total=total_questions
)
def _do_generate(task, fp, coll, q_types, diff, opts, req_id, excl):
"""后台执行出题"""
registry.update_progress(task.id, stage='检索知识', message='正在检索相关文档切片...')
result = generate_questions_from_file(
file_path=fp,
collection=coll,
question_types=q_types,
difficulty=diff,
options=opts,
request_id=req_id,
exclude_stems=excl
)
registry.update_progress(task.id, stage='完成', message=f"生成 {result.get('total', 0)} 道题")
return result
registry.start_task(
task.id, _do_generate,
file_path, collection, question_types,
data.get('difficulty', 3), data.get('options', {}),
data.get('request_id'), data.get('exclude_stems')
)
return success_response(
data={
'task_id': task.id,
'message': f'出题任务已启动 ({total_questions}题),通过 GET /tasks/{task.id} 查询结果'
},
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)
# 校验难度
difficulty = data.get('difficulty', 3)
diff_error = validate_difficulty(difficulty)
if diff_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems)
if stems_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, stems_error, http_status=400)
# 获取当前用户
user = get_current_user()
if not user:
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
# 检查向量库访问权限
if not check_collection_permission(
role=user['role'],
department=user.get('department', ''),
collection_name=collection,
operation="read"
):
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
# AI 智能出题(异步任务)
from core.task_registry import get_registry
import logging as _logging
_logger = _logging.getLogger(__name__)
registry = get_registry()
task = registry.create_task(
'exam_generate',
f"AI智能出题: {os.path.basename(file_path)}"
)
def _do_smart_generate(task, fp, coll, diff, opts, req_id, excl):
"""后台执行 AI 智能出题"""
registry.update_progress(task.id, stage='AI分析', message='正在分析文档内容...')
from exam_pkg.manager import analyze_file_for_exam
ai_analysis = analyze_file_for_exam(file_path=fp, collection=coll)
q_types = ai_analysis.get('question_types', {})
if not q_types or sum(q_types.values()) == 0:
raise ValueError("AI 分析后未生成有效题型配置")
total = sum(q_types.values())
registry.update_progress(task.id, total=total, stage='生成题目',
message=f"AI 推荐 {total} 道题,正在生成...")
result = generate_questions_from_file(
file_path=fp, collection=coll,
question_types=q_types, difficulty=diff,
options=opts, request_id=req_id, exclude_stems=excl
)
result['ai_analysis'] = ai_analysis
registry.update_progress(task.id, stage='完成', message=f"生成 {result.get('total', 0)} 道题")
return result
registry.start_task(
task.id, _do_smart_generate,
file_path, collection,
data.get('difficulty', 3), data.get('options', {}),
data.get('request_id'), data.get('exclude_stems')
)
return success_response(
data={
'task_id': task.id,
'message': 'AI 智能出题任务已启动,通过 GET /tasks/' + task.id + ' 查询结果'
},
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",
"content": {"answer": "B"},
"student_answer": "A",
"max_score": 2
},
{
"question_id": "uuid",
"question_type": "fill_blank",
"content": {"answer": [["答案1", "同义词"], ["答案2"]]},
"student_answer": ["学生答案1", "学生答案2"],
"max_score": 4
},
{
"question_id": "uuid",
"question_type": "subjective",
"content": {
"stem": "简述...",
"data": {"scoring_points": [...]},
"answer": "参考范文..."
},
"student_answer": "学生作答内容...",
"max_score": 10
}
]
}
说明:
- content 字段与出题接口返回的 content 结构一致,后端可直接透传
- 客观题content 只需包含 answer
- 填空题content.answer 格式为 [["答案1", "同义词"], ...] 每空一个列表
- 主观题content 需包含 stem + answer + data.scoring_points
返回:
{
"success": true,
"request_id": "uuid",
"results": [
{
"question_id": "uuid",
"score": 2,
"max_score": 2,
"grading_status": "success",
"details": {"correct": true, "student_answer": "A", "correct_answer": "B", "feedback": "..."}
}
],
"total_score": 10.5,
"total_max_score": 16.0,
"score_rate": 65.6
}
grading_status 取值:
- "success": 评分成功
- "failed": 评分失败LLM 超时/解析失败等score 为 0
"""
try:
data = request.json
answers = data.get('answers', [])
if not answers:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少答案数据", http_status=400)
if not isinstance(answers, list):
return error_response("INVALID_PARAMS", BAD_REQUEST, "answers 必须为数组", http_status=400)
# 校验每道题的 question_type
for i, ans in enumerate(answers):
q_type = ans.get('question_type')
if q_type not in VALID_QUESTION_TYPES:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"{i+1} 题的 question_type 无效: {q_type},合法值: {', '.join(sorted(VALID_QUESTION_TYPES))}",
http_status=400)
# 调用批题接口(异步任务)
from core.task_registry import get_registry
registry = get_registry()
task = registry.create_task(
'exam_grade',
f"批阅: {len(answers)} 道题",
total=len(answers)
)
def _do_grade(task, ans_list, req_id):
"""后台执行批阅"""
registry.update_progress(task.id, stage='批阅中', message='正在逐题评分...')
result = grade_answers(answers=ans_list, request_id=req_id)
registry.update_progress(task.id, stage='完成',
message=f"批阅完成,得分率 {result.get('score_rate', 0):.1f}%")
return result
registry.start_task(task.id, _do_grade, answers, data.get('request_id'))
return success_response(
data={
'task_id': task.id,
'message': f'批阅任务已启动 ({len(answers)}题),通过 GET /tasks/{task.id} 查询结果'
},
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"
})