fix(exam): 修复出题批卷 API 契约一致性问题
P0: - 修复 V2 降级路径响应格式,fallback 输出经过 enrich + dedup + balance - 补充主观题 format example,要求 LLM 生成 scoring_points 评分标准 P1: - 补充多选题、判断题 format example,明确 answer 为列表格式 - 统一批卷响应格式,所有题型增加 grading_status 字段 - 出题数量不足时返回 requested_types/actual_types/warnings - 修复 _call_llm 未配置时抛异常而非静默返回 0 分 P2: - API 层增加 question_types 和 difficulty 入参校验 - 修复 generate_questions_from_content 方法名和参数类型 bug
This commit is contained in:
@@ -32,6 +32,35 @@ from auth.gateway import (
|
||||
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
|
||||
|
||||
# 合法题型
|
||||
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
|
||||
|
||||
# 创建蓝图
|
||||
exam_bp = Blueprint('exam', __name__)
|
||||
|
||||
@@ -69,12 +98,16 @@ def api_generate_questions():
|
||||
"request_id": "uuid",
|
||||
"questions": [
|
||||
{
|
||||
"metadata": {"question_id": "...", "question_type": "...", ...},
|
||||
"source_trace": {"document_name": "...", "sources": [...], ...},
|
||||
"content": {"stem": "...", "data": {...}, "answer": "...", ...}
|
||||
"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
|
||||
}
|
||||
"""
|
||||
@@ -91,6 +124,17 @@ def api_generate_questions():
|
||||
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)
|
||||
|
||||
# 获取当前用户
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
@@ -149,6 +193,12 @@ def api_generate_smart():
|
||||
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)
|
||||
|
||||
# 获取当前用户
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
@@ -237,11 +287,23 @@ def api_grade_answers():
|
||||
{
|
||||
"success": true,
|
||||
"request_id": "uuid",
|
||||
"results": [...],
|
||||
"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
|
||||
|
||||
@@ -322,13 +322,17 @@ class QuestionGenerator:
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
降级出题方法:当知识点提取失败时,直接使用 chunks 出题
|
||||
|
||||
输出格式与正常路径一致(嵌套结构),确保 API 响应格式统一
|
||||
"""
|
||||
source_content = build_source_context(chunks)
|
||||
total_questions = sum(question_types.values())
|
||||
if not source_content or total_questions == 0:
|
||||
return []
|
||||
|
||||
# 构造出题 prompt
|
||||
q_type_str = ", ".join(f"{t}:{n}道" for t, n in question_types.items())
|
||||
|
||||
# 构造出题 prompt —— 使用与正常路径相同的嵌套格式
|
||||
prompt = f"""请根据以下参考资料生成题目。
|
||||
|
||||
## 参考资料
|
||||
@@ -337,41 +341,41 @@ class QuestionGenerator:
|
||||
|
||||
## 要求
|
||||
|
||||
- 题型及数量:{json.dumps(question_types, ensure_ascii=False)}
|
||||
- 题型及数量:{q_type_str}
|
||||
- 难度等级:{difficulty}/5
|
||||
- 每道题必须包含:题干、答案、解析
|
||||
- 答案必须基于参考资料
|
||||
|
||||
## 输出格式
|
||||
## 输出格式(JSON 数组)
|
||||
{self._get_format_examples()}
|
||||
|
||||
返回 JSON 数组,每个元素格式:
|
||||
{{"stem": "题干", "type": "single_choice/multiple_choice/true_false/fill_blank/subjective", "answer": "答案", "explanation": "解析", "options": [{{"key": "A", "content": "选项内容"}}]}}
|
||||
|
||||
只输出 JSON 数组,不要其他内容。"""
|
||||
请直接输出 JSON 数组:"""
|
||||
|
||||
try:
|
||||
# 使用 llm_utils 统一调用
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
messages = [
|
||||
{"role": "system", "content": "你是一个专业的出题专家,必须严格按照JSON格式输出。"},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
content = call_llm(
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.7,
|
||||
max_tokens=2000,
|
||||
max_tokens=4000,
|
||||
messages=messages
|
||||
)
|
||||
if not content:
|
||||
return []
|
||||
# 解析 JSON
|
||||
questions = parse_json_list_from_response(content)
|
||||
if questions:
|
||||
# 添加元数据
|
||||
for q in questions:
|
||||
q["source_trace"] = {
|
||||
"document_name": document_name,
|
||||
"chunks_count": len(chunks)
|
||||
}
|
||||
return questions
|
||||
raw_questions = parse_json_list_from_response(content)
|
||||
if not raw_questions:
|
||||
return []
|
||||
|
||||
# Schema 校验
|
||||
validated = validate_questions_schema(raw_questions)
|
||||
|
||||
# 补充溯源信息(与正常路径格式统一)
|
||||
enriched = self._enrich_with_source_trace(validated, chunks, document_name)
|
||||
return enriched
|
||||
except Exception as e:
|
||||
logger.error(f"[降级出题失败] {e}")
|
||||
|
||||
@@ -510,7 +514,7 @@ class QuestionGenerator:
|
||||
return result
|
||||
|
||||
def _get_format_examples(self) -> str:
|
||||
"""返回各题型格式示例"""
|
||||
"""返回各题型格式示例(覆盖全部 5 种题型)"""
|
||||
return '''
|
||||
### 单选题示例
|
||||
{
|
||||
@@ -524,6 +528,30 @@ class QuestionGenerator:
|
||||
"referenced_chunk_ids": ["chunk_001"]
|
||||
}
|
||||
|
||||
### 多选题示例
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"content": {
|
||||
"stem": "题干内容",
|
||||
"data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}, {"key": "C", "content": "选项C"}, {"key": "D", "content": "选项D"}]},
|
||||
"answer": ["A", "C"],
|
||||
"explanation": "解析..."
|
||||
},
|
||||
"referenced_chunk_ids": ["chunk_001"]
|
||||
}
|
||||
|
||||
### 判断题示例
|
||||
{
|
||||
"type": "true_false",
|
||||
"content": {
|
||||
"stem": "判断:某个陈述内容",
|
||||
"data": {},
|
||||
"answer": "对",
|
||||
"explanation": "解析..."
|
||||
},
|
||||
"referenced_chunk_ids": ["chunk_002"]
|
||||
}
|
||||
|
||||
### 填空题示例
|
||||
{
|
||||
"type": "fill_blank",
|
||||
@@ -535,6 +563,24 @@ class QuestionGenerator:
|
||||
},
|
||||
"referenced_chunk_ids": ["chunk_003"]
|
||||
}
|
||||
|
||||
### 主观题示例
|
||||
{
|
||||
"type": "subjective",
|
||||
"content": {
|
||||
"stem": "请简述某个概念的含义及其应用场景。",
|
||||
"data": {
|
||||
"scoring_points": [
|
||||
{"point": "要点1:概念定义", "weight": 0.4},
|
||||
{"point": "要点2:应用场景", "weight": 0.3},
|
||||
{"point": "要点3:举例说明", "weight": 0.3}
|
||||
]
|
||||
},
|
||||
"answer": "参考答案范文...",
|
||||
"explanation": "解析..."
|
||||
},
|
||||
"referenced_chunk_ids": ["chunk_004"]
|
||||
}
|
||||
'''
|
||||
|
||||
def _extract_knowledge_points(
|
||||
@@ -862,9 +908,14 @@ def generate_questions_from_content(
|
||||
question_types: Dict[str, int],
|
||||
difficulty: int = 3
|
||||
) -> List[Dict]:
|
||||
"""便捷函数:从内容生成题目"""
|
||||
"""便捷函数:从文本内容生成题目(将纯文本包装为单 chunk 后调用结构化出题)"""
|
||||
if not source_content or not source_content.strip():
|
||||
return []
|
||||
|
||||
# 将纯文本包装为一个 chunk
|
||||
chunks = [{"content": source_content, "section": "", "page": None, "chunk_id": "content_0"}]
|
||||
generator = QuestionGenerator()
|
||||
return generator.generate_questions(source_content, document_name, question_types, difficulty)
|
||||
return generator.generate_questions_structured(chunks, document_name, question_types, difficulty)
|
||||
|
||||
|
||||
def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
@@ -1114,9 +1165,14 @@ def generate_questions_structured_v2(
|
||||
logger.info(f" 全局唯一知识点: {len(all_knowledge_points)}")
|
||||
|
||||
if not all_knowledge_points:
|
||||
# 降级:直接使用 chunks 出题
|
||||
# 降级:直接使用 chunks 出题(格式已由 fallback 内部统一)
|
||||
logger.warning("[v2] 知识点提取失败,降级使用 chunks 出题")
|
||||
return generator._generate_questions_fallback(chunks, document_name, question_types, difficulty)
|
||||
fallback_questions = generator._generate_questions_fallback(chunks, document_name, question_types, difficulty)
|
||||
# 降级路径也执行去重和数量平衡
|
||||
deduped = _deduplicate_questions(fallback_questions)
|
||||
final = _balance_question_types(deduped, question_types)
|
||||
logger.info(f" [降级] 最终题目数: {len(final)}")
|
||||
return final
|
||||
|
||||
# AI 分配:哪些知识点出什么题型
|
||||
assignments = _ai_assign_question_types(all_knowledge_points, question_types)
|
||||
|
||||
@@ -97,8 +97,13 @@ def grade_objective(answer: Dict) -> Dict:
|
||||
"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 "正确!"
|
||||
"grading_status": "success",
|
||||
"details": {
|
||||
"correct": correct,
|
||||
"student_answer": student_answer,
|
||||
"correct_answer": correct_answer,
|
||||
"feedback": f"正确答案: {correct_answer}" if not correct else "正确!"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +124,8 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": 0,
|
||||
"max_score": max_score,
|
||||
"details": {"error": "答案格式错误"}
|
||||
"grading_status": "failed",
|
||||
"details": {"error": "答案格式错误,correct_answers 或 student_answer 为空"}
|
||||
}
|
||||
|
||||
# 计算每空分数
|
||||
@@ -149,9 +155,11 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": round(total_score, 1),
|
||||
"max_score": max_score,
|
||||
"grading_status": "success",
|
||||
"details": {
|
||||
"blank_scores": blank_scores,
|
||||
"correct_answers": correct_answers
|
||||
"total_blanks": len(correct_answers),
|
||||
"correct_blanks": sum(1 for s in blank_scores if s > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,22 +251,38 @@ class AnswerGrader:
|
||||
}
|
||||
|
||||
# 收集结果
|
||||
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)
|
||||
}
|
||||
try:
|
||||
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
|
||||
),
|
||||
"grading_status": "failed",
|
||||
"details": {"error": str(e)}
|
||||
}
|
||||
except TimeoutError:
|
||||
# 超时:为未完成的任务设置失败状态
|
||||
for future, qid in future_to_qid.items():
|
||||
if qid not in results_map:
|
||||
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
|
||||
),
|
||||
"grading_status": "failed",
|
||||
"details": {"error": "批阅超时"}
|
||||
}
|
||||
|
||||
@retry(times=2, delay=1)
|
||||
def _grade_subjective(self, answer: Dict) -> Dict:
|
||||
@@ -315,8 +339,7 @@ class AnswerGrader:
|
||||
def _call_llm(self, prompt: str) -> str:
|
||||
"""调用本地 LLM"""
|
||||
if not self.client:
|
||||
# 无 LLM 客户端,返回默认评分
|
||||
return json.dumps({"score": 0, "overall_feedback": "LLM 未配置"})
|
||||
raise ValueError("LLM 未配置,无法进行主观题评分")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "你是一个专业的阅卷老师,请严格按照JSON格式输出评分结果。"},
|
||||
@@ -347,6 +370,7 @@ class AnswerGrader:
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": score,
|
||||
"max_score": max_score,
|
||||
"grading_status": "success",
|
||||
"details": {
|
||||
"scoring_breakdown": result.get('scoring_breakdown', []),
|
||||
"highlights": result.get('highlights', []),
|
||||
@@ -360,6 +384,7 @@ class AnswerGrader:
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": 0,
|
||||
"max_score": max_score,
|
||||
"grading_status": "failed",
|
||||
"details": {"error": "评分结果解析失败"}
|
||||
}
|
||||
|
||||
|
||||
1505
exam_pkg/manager.py
1505
exam_pkg/manager.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user