Files
rag/exam_pkg/api.py
lacerate551 a31ee4bba0 feat(exam_pkg): 优化出题系统稳定性与推理模型适配
- core/llm_utils: MiMo模型自动注入thinking=disabled参数,全局生效
- config: 新增LLM_DISABLE_THINKING配置项(默认true)
- generator: 推理模型自适应max_tokens(1.5x)、429限流重试+指数退避、v2管线补题机制
- generator: analyze_document_for_exam新增max_total参数控制AI出题上限
- generator: validate_questions_schema兼容type和question_type字段
- grader: 主观题max_tokens从1000提升至2000、fuzzy_match增加编辑距离容错
- manager: results变量初始化防NameError、透传max_total参数
- api: /exam/generate-smart支持max_total请求参数
2026-06-22 18:51:11 +08:00

484 lines
17 KiB
Python
Raw Permalink 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
"max_total": 20, // 可选AI出题总数上限。不传则不限制
"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)
# 可选AI出题总数上限不传则不限制
max_total = data.get('max_total')
if max_total is not None:
try:
max_total = int(max_total)
if max_total <= 0:
return error_response("INVALID_PARAMS", BAD_REQUEST, "max_total 必须为正整数", http_status=400)
except (ValueError, TypeError):
return error_response("INVALID_PARAMS", BAD_REQUEST, "max_total 必须为整数", 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, max_t):
"""后台执行 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, max_total=max_t)
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'),
max_total
)
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"
})