""" 题库与测评路由:习题 CRUD、作答提交、规则判题与大模型兜底、错题与权重更新。 """ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException import httpx import json import re from sqlmodel import Session, select from app.core.config import settings from app.db import get_session from app.deps import get_current_user, require_role from app.models import Exercise, ExerciseAttempt, User, UserRole, WrongBookItem from app.integrations.neo4j import Neo4jClient, get_neo4j_client from app.schemas import ( AttemptPublic, AttemptSubmitRequest, ExerciseBatchRequest, ExerciseCreateRequest, ExercisePublic, ) from app.utils.exercise_constants import RESOLUTION_EMPTY_PLACEHOLDER, normalize_resolution_for_store from app.utils.knowledge_codes import split_knowledge_codes from app.utils.neo4j_exercise import enrich_exercise_from_neo4j router = APIRouter(prefix="/assessment", tags=["assessment"]) def _normalize_answer_text(s: str) -> str: s = (s or "").strip() if not s: return "" s = s.replace("=", "=").replace(",", ",") s = re.sub(r"\s+", "", s) return s def _normalize_boolean_judgment(s: str) -> str | None: """ Map common true/false answer forms to canonical "T" / "F". Used when question_type == "判断". """ raw = _normalize_answer_text(s) if not raw: return None low = raw.lower() if raw in {"对", "是", "√", "正确"} or low in {"t", "true", "y", "yes", "1", "right"}: return "T" if raw in {"错", "否", "×", "错误"} or low in {"f", "false", "n", "no", "0", "wrong"}: return "F" if len(raw) == 1 and low == "x": return "F" return None def _extract_standard_answer(body: str) -> str | None: if not body: return None m = re.search( r"(标准答案|参考答案|正确答案)\s*[::]\s*(?P.+?)(?:\n\s*(评分标准|评分|解析|题解)\s*[::]|$)", body, flags=re.I | re.S, ) if not m: return None ans = (m.group("ans") or "").strip() return ans if ans else None def _extract_expected_answer(ex: Exercise) -> str | None: """ Prefer teacher-provided `correct_answer`. Fallback to parsing `body` ("标准答案/正确答案/参考答案...") for legacy data. """ if ex.correct_answer: s = str(ex.correct_answer).strip() return s if s else None return _extract_standard_answer(ex.body) def _try_rule_based_grade(ex: Exercise, student_answer: str) -> tuple[bool | None, float | None]: expected = _extract_expected_answer(ex) if not expected: return None, None expected_candidates = [c.strip() for c in re.split(r"[|/;;\n]+", expected) if c.strip()] stu = _normalize_answer_text(student_answer) if not stu: return False, 0.0 if (getattr(ex, "question_type", None) or "").strip() == "判断": st_j = _normalize_boolean_judgment(student_answer) if st_j: for cand in expected_candidates: exp_j = _normalize_boolean_judgment(cand) if exp_j and st_j == exp_j: return True, 1.0 # 学生已是明确的“对/错”表述,但标准答案无法解析为布尔:退回字符串规则 if any(_normalize_boolean_judgment(c) for c in expected_candidates): return False, 0.0 for cand in expected_candidates: cand_norm = _normalize_answer_text(cand) if cand_norm and cand_norm == stu: return True, 1.0 if cand_norm and (cand_norm in stu or stu in cand_norm): return True, 0.8 return False, 0.0 def _extract_json_from_text(text: str) -> dict | None: if not text: return None i = text.find("{") j = text.rfind("}") if i < 0 or j <= i: return None try: return json.loads(text[i : j + 1]) except Exception: return None def _judge_with_deepseek(ex: Exercise, student_answer: str) -> tuple[bool, float, str]: api_key = settings.deepseek_api_key.strip() if not api_key: return False, 0.0, "DeepSeek 未配置 API Key" messages = [ { "role": "system", "content": "你是中学数学自动判题助理。请严格按要求输出JSON,不要输出多余文本。", }, { "role": "user", "content": ( "请判断学生的作答是否正确,并给出置信度分数score。\n" f"题目标题:{ex.title}\n" f"题目内容:{ex.body}\n" f"学生答案:{student_answer}\n\n" "输出JSON格式(必须严格可解析):" "{\"is_correct\": true/false, \"score\": 0.0-1.0, \"reason\": \"一句话简要\"}" ), }, ] payload = {"model": settings.deepseek_model, "messages": messages, "temperature": 0.0} url = f"{settings.deepseek_base_url.rstrip('/')}/chat/completions" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} try: with httpx.Client(timeout=settings.llm_request_timeout_seconds) as client: res = client.post(url, json=payload, headers=headers) res.raise_for_status() data = res.json() content = data["choices"][0]["message"]["content"] except Exception as e: return False, 0.0, f"DeepSeek调用失败: {e}" obj = _extract_json_from_text(content) if not isinstance(obj, dict): return False, 0.0, "DeepSeek返回非JSON" is_correct = obj.get("is_correct", False) if isinstance(is_correct, str): is_correct = is_correct.strip().lower() in {"true", "1", "yes", "y"} score = obj.get("score", 0.0) try: score_f = float(score) except Exception: score_f = 0.0 score_f = max(0.0, min(1.0, score_f)) reason = str(obj.get("reason", "")).strip() return bool(is_correct), score_f, reason def _grade_attempt(ex: Exercise, student_answer: str) -> tuple[bool, float]: """判题入口:先规则匹配标答,无法判定时再调用大模型返回 JSON。""" # 规则判题(教师提供 correct_answer 或题干内标准答案) rule_ok, rule_score = _try_rule_based_grade(ex, student_answer) if rule_ok is not None: return bool(rule_ok), float(rule_score or 0.0) is_correct, score, _reason = _judge_with_deepseek(ex, student_answer) return bool(is_correct), float(score) @router.post("/exercises", response_model=ExercisePublic) def create_exercise( payload: ExerciseCreateRequest, session: Session = Depends(get_session), neo4j: Neo4jClient = Depends(get_neo4j_client), _=Depends(require_role(UserRole.teacher)), ): ex = Exercise(**payload.model_dump()) if getattr(ex, "weight", None) is None: ex.weight = 0 ex.resolution = normalize_resolution_for_store(getattr(ex, "resolution", None)) enrich_exercise_from_neo4j(ex, neo4j) session.add(ex) session.commit() session.refresh(ex) return ex @router.get("/exercises", response_model=list[ExercisePublic]) def list_exercises(knowledge_code: str | None = None, session: Session = Depends(get_session)): exs = list(session.exec(select(Exercise)).all()) if not knowledge_code: return exs codes = set(split_knowledge_codes(knowledge_code)) if not codes: return exs out: list[Exercise] = [] for ex in exs: ex_codes = set(split_knowledge_codes(ex.knowledge_code)) if ex_codes & codes: out.append(ex) return out @router.post("/exercises/batch", response_model=list[ExercisePublic]) def get_exercises_batch(payload: ExerciseBatchRequest, session: Session = Depends(get_session)): """按 ID 列表批量返回习题,保持请求顺序;缺失 ID 时返回 404。""" if not payload.ids: return [] seen: set[str] = set() ordered_ids: list[UUID] = [] for eid in payload.ids: key = str(eid) if key in seen: continue seen.add(key) ordered_ids.append(eid) rows = list(session.exec(select(Exercise).where(Exercise.id.in_(ordered_ids))).all()) by_id = {ex.id: ex for ex in rows} out: list[Exercise] = [] for eid in ordered_ids: ex = by_id.get(eid) if not ex: raise HTTPException(status_code=404, detail=f"Exercise not found: {eid}") out.append(ex) return out @router.get("/exercises/{exercise_id}", response_model=ExercisePublic) def get_exercise(exercise_id: UUID, session: Session = Depends(get_session)): ex = session.get(Exercise, exercise_id) if not ex: raise HTTPException(status_code=404, detail="Exercise not found") return ex @router.post("/exercises/{exercise_id}/attempts", response_model=AttemptPublic) def submit_attempt( exercise_id: UUID, payload: AttemptSubmitRequest, session: Session = Depends(get_session), current_user: User = Depends(get_current_user), ): """学生提交作答:判题 → 写作答记录 → 更新题目 weight → 错误时写入错题本。""" ex = session.get(Exercise, exercise_id) if not ex: raise HTTPException(status_code=404, detail="Exercise not found") is_correct, score = _grade_attempt(ex, payload.answer) # 个性化权重:错题升权、做对降权(最低 0),驱动后续推荐优先级。 cur_w = int(getattr(ex, "weight", 0) or 0) if is_correct is False: ex.weight = cur_w + 1 else: ex.weight = max(0, cur_w - 1) attempt = ExerciseAttempt( user_id=current_user.id, exercise_id=exercise_id, answer=payload.answer, is_correct=is_correct, score=score, ) session.add(attempt) # 将本次错误按知识点写入错题本,便于个性推荐与学习分析统计。 if is_correct is False: correct_answer = _extract_expected_answer(ex) codes = split_knowledge_codes(ex.knowledge_code) for code in codes: session.add( WrongBookItem( user_id=current_user.id, exercise_id=exercise_id, knowledge_code=code, wrong_answer=payload.answer, correct_answer=correct_answer, ) ) session.commit() session.refresh(attempt) return attempt @router.get("/attempts/me", response_model=list[AttemptPublic]) def my_attempts(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)): return list(session.exec(select(ExerciseAttempt).where(ExerciseAttempt.user_id == current_user.id)).all())