- 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请求参数
484 lines
17 KiB
Python
484 lines
17 KiB
Python
"""
|
||
出题与批题系统 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_types,AI 自动分析文档后决定
|
||
|
||
请求体:
|
||
{
|
||
"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"
|
||
})
|