feat(exam): 出题批卷 API 增强 — 格式统一、校验、跨调用去重

- grader: 多策略 JSON 提取 + 3 次重试 + prompt 优化,修复 LLM 输出不稳定问题
- grader: question_content → content 统一字段名,与出题接口一致
- api: 新增入参校验(题型枚举、difficulty、总题数上限 20、grade question_type)
- api/generator/manager: 新增 exclude_stems 参数,支持跨调用去重
This commit is contained in:
lacerate551
2026-06-05 14:06:11 +08:00
parent b6631c626b
commit 3f1980ba78
4 changed files with 204 additions and 57 deletions

View File

@@ -15,6 +15,8 @@
"""
import json
import logging
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -24,6 +26,8 @@ from typing import List, Dict, Any, Optional
# 导入 LLM 工具函数
from core.llm_utils import call_llm
logger = logging.getLogger(__name__)
# 导入 LLM 配置
try:
from config import API_KEY, BASE_URL, MODEL
@@ -77,7 +81,7 @@ def grade_objective(answer: Dict) -> Dict:
🔥 本地直接判断,无 LLM 调用
"""
q_type = answer['question_type']
question_content = answer.get('question_content', {})
question_content = answer.get('content', {})
correct_answer = question_content.get('answer')
student_answer = answer.get('student_answer')
max_score = answer.get('max_score', 2.0)
@@ -114,7 +118,7 @@ def grade_fill_blank(answer: Dict) -> Dict:
填空题答案格式:[["答案1", "同义词1", ...], ["答案2", ...], ...]
学生答案格式:["学生答案1", "学生答案2", ...]
"""
question_content = answer.get('question_content', {})
question_content = answer.get('content', {})
correct_answers = question_content.get('answer', []) # [[答案1, 同义词...], ...]
student_answers = answer.get('student_answer', [])
max_score = answer.get('max_score', 4.0)
@@ -284,33 +288,48 @@ class AnswerGrader:
"details": {"error": "批阅超时"}
}
@retry(times=2, delay=1)
@retry(times=3, delay=1)
def _grade_subjective(self, answer: Dict) -> Dict:
"""
批阅主观题 - 调用 LLM 评分
🔥 P1 改进:带重试
🔥 改进3次重试 + 解析失败自动重试
"""
with grading_semaphore: # 限流
prompt = self._build_grading_prompt(answer)
response = self._call_llm(prompt)
return self._parse_grading_result(response, answer)
try:
return self._parse_grading_result(response, answer)
except (json.JSONDecodeError, ValueError, TypeError) as e:
# 解析失败抛异常 → 触发 @retry 重试
logger.warning(f"[主观题评分] 解析失败将重试: {e}, 原始响应前200字: {str(response)[:200]}")
raise
def _build_grading_prompt(self, answer: Dict) -> str:
"""构造评分 Prompt"""
question_content = answer.get('question_content', {})
question_content = answer.get('content', {})
scoring_points = question_content.get('data', {}).get('scoring_points', [])
stem = question_content.get('stem', '')
reference_answer = question_content.get('answer', '')
# 如果缺少评分标准,在 prompt 中补充提示
scoring_section = ""
if scoring_points:
scoring_section = json.dumps(scoring_points, ensure_ascii=False, indent=2)
else:
scoring_section = "(未提供评分标准,请根据参考答案自行判断要点)"
return f"""请批阅以下简答题。
## 题目
{question_content.get('stem', '')}
{stem if stem else '(未提供题目)'}
## 参考答案
{question_content.get('answer', '')}
{reference_answer if reference_answer else '(未提供参考答案)'}
## 评分标准
{json.dumps(scoring_points, ensure_ascii=False, indent=2)}
{scoring_section}
## 学生答案
{answer.get('student_answer', '')}
@@ -319,19 +338,20 @@ class AnswerGrader:
{answer.get('max_score', 10)}
## 输出约束
1. 必须输出合法 JSON
2. score 不能超过满分
3. achieved 为 0-1 之间的比例
1. 必须输出合法 JSON,不要包含任何占位符或中文说明
2. score 为数字,不能超过满分
3. achieved 为 0-1 之间的数字
4. 所有字段必须填入实际评分值
## 输出格式JSON
## 输出格式示例JSON
{{
"score": 得分,
"score": 7,
"scoring_breakdown": [
{{"point": "要点名称", "weight": 权重, "achieved": 实际得分比例, "comment": "评语"}}
{{"point": "核心概念正确", "weight": 0.5, "achieved": 0.8, "comment": "基本概念描述准确"}}
],
"highlights": ["亮点1", "亮点2"],
"shortcomings": ["不足1"],
"overall_feedback": "整体评价"
"highlights": ["回答条理清晰"],
"shortcomings": ["缺少具体应用场景"],
"overall_feedback": "整体回答较好,但不够全面"
}}
请直接输出 JSON"""
@@ -357,36 +377,96 @@ class AnswerGrader:
raise Exception("LLM 调用失败")
return result
def _extract_json(self, response: str) -> dict:
"""
多策略从 LLM 响应中提取 JSON 对象
策略优先级:
1. markdown 代码块提取 ```json ... ```
2. 直接 json.loads
3. 正则匹配第一个 {...} 块
"""
if not response:
raise ValueError("LLM 返回为空")
# 策略1提取 markdown 代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', response)
if json_match:
json_str = json_match.group(1).strip()
try:
result = json.loads(json_str)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass # 继续下一策略
# 策略2直接解析整个响应
try:
result = json.loads(response.strip())
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass # 继续下一策略
# 策略3正则匹配最外层 JSON 对象
brace_match = re.search(r'\{[\s\S]*\}', response)
if brace_match:
try:
result = json.loads(brace_match.group(0))
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# 全部策略失败
raise json.JSONDecodeError(
f"无法从 LLM 响应中提取有效 JSON响应前300字: {response[:300]}",
response, 0
)
def _parse_grading_result(self, response: str, answer: Dict) -> Dict:
"""解析评分结果"""
"""
解析评分结果
解析失败时抛出异常(由调用方的 @retry 处理重试)
"""
max_score = answer.get('max_score', 10)
try:
# 尝试解析 JSON
result = json.loads(response)
score = min(result.get('score', 0), max_score) # 不能超过满分
# 检查主观题内容完整性
question_content = answer.get('content', {})
warnings = []
if not question_content.get('stem'):
warnings.append("缺少题目(stem)")
if not question_content.get('answer'):
warnings.append("缺少参考答案(answer)")
if not question_content.get('data', {}).get('scoring_points'):
warnings.append("缺少评分标准(scoring_points),评分结果仅供参考")
return {
"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', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
}
except (json.JSONDecodeError, KeyError, TypeError) as e:
# 解析失败,返回默认
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {"error": "评分结果解析失败"}
}
# 多策略提取 JSON失败抛异常 → 触发重试)
result = self._extract_json(response)
# 校验关键字段
score = result.get('score')
if score is None or not isinstance(score, (int, float)):
raise ValueError(f"score 字段缺失或类型错误: {score}")
score = min(float(score), max_score) # 不能超过满分
details = {
"scoring_breakdown": result.get('scoring_breakdown', []),
"highlights": result.get('highlights', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
if warnings:
details["warnings"] = warnings
return {
"question_id": answer.get('question_id'),
"score": score,
"max_score": max_score,
"grading_status": "success",
"details": details
}
# ==================== 批题入口函数 ====================