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:
lacerate551
2026-06-05 12:06:49 +08:00
parent 42434781f7
commit b6631c626b
4 changed files with 957 additions and 793 deletions

View File

@@ -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)