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请求参数
This commit is contained in:
lacerate551
2026-06-22 18:51:11 +08:00
parent b966be5417
commit a31ee4bba0
8 changed files with 475 additions and 75 deletions

View File

@@ -37,6 +37,21 @@ except ImportError:
MODEL = None
LLM_AVAILABLE = False
# 推理模型识别(与 generator.py 共享同一套关键词)
_REASONING_MODEL_KEYWORDS = ('mimo', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'o1', 'o3')
def _is_reasoning_model(model_name: str) -> bool:
if not model_name:
return False
name_lower = model_name.lower()
return any(kw in name_lower for kw in _REASONING_MODEL_KEYWORDS)
def _get_effective_max_tokens(base_max: int, model_name: str) -> int:
"""推理模型需要 1.5x token 预算给思考链"""
if _is_reasoning_model(model_name):
return max(base_max, int(base_max * 1.5))
return base_max
# ==================== 装饰器 ====================
@@ -169,19 +184,56 @@ def grade_fill_blank(answer: Dict) -> Dict:
def fuzzy_match(student_answer: str, correct_answer: str) -> bool:
"""
模糊匹配(支持同义词)
模糊匹配(支持同义词和小编辑距离容错
当前实现:精确匹配(忽略前后空格、大小写)
TODO: 可以扩展为语义相似度匹配
策略:
1. 精确匹配(去空格、转小写、统一标点)
2. 编辑距离容错≥4字答案允许≤2字符差异
"""
if not student_answer or not correct_answer:
return False
# 标准化:去空格、转小写
s = student_answer.strip().lower()
c = correct_answer.strip().lower()
# 标准化:去空格、转小写、统一标点
def _normalize(text: str) -> str:
t = text.strip().lower()
# 统一常见中文标点变体
t = t.replace('', '(').replace('', ')').replace('', ',')
t = t.replace('', ';').replace('', ':').replace('"', '"').replace('"', '"')
return t
return s == c
s = _normalize(student_answer)
c = _normalize(correct_answer)
if s == c:
return True
# 编辑距离容错答案≥4字时允许≤2字符差异
if len(s) >= 4 and len(c) >= 4:
dist = _edit_distance(s, c)
if dist <= 2:
return True
return False
def _edit_distance(s1: str, s2: str) -> int:
"""计算两个字符串的编辑距离Levenshtein"""
if len(s1) < len(s2):
return _edit_distance(s2, s1)
if len(s2) == 0:
return len(s1)
prev_row = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
curr_row = [i + 1]
for j, c2 in enumerate(s2):
# 插入、删除、替换
insertions = prev_row[j + 1] + 1
deletions = curr_row[j] + 1
substitutions = prev_row[j] + (c1 != c2)
curr_row.append(min(insertions, deletions, substitutions))
prev_row = curr_row
return prev_row[-1]
# ==================== AnswerGrader 类 ====================
@@ -237,7 +289,21 @@ class AnswerGrader:
# 🔥 P1 改进:并发调用 LLM 批阅主观题
if llm_questions:
self._grade_subjective_concurrently(llm_questions, results_map)
try:
self._grade_subjective_concurrently(llm_questions, results_map)
except Exception as e:
logger.error(f"主观题并发批阅整体异常: {e}")
# 兜底:为所有未完成的主观题设置失败状态
for ans in llm_questions:
qid = ans.get('question_id')
if qid not in results_map:
results_map[qid] = {
"question_id": qid,
"score": 0,
"max_score": ans.get('max_score', 10),
"grading_status": "failed",
"details": {"error": f"批阅系统异常: {str(e)}"}
}
# 🔥 P1 改进:按原始顺序重组结果
results = [results_map.get(ans.get('question_id')) for ans in answers]
@@ -369,7 +435,7 @@ class AnswerGrader:
prompt=prompt,
model=self.model,
temperature=0.3,
max_tokens=1000,
max_tokens=_get_effective_max_tokens(2000, self.model),
messages=messages
)
if result is None: