assessment.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. """
  2. 题库与测评路由:习题 CRUD、作答提交、规则判题与大模型兜底、错题与权重更新。
  3. """
  4. from uuid import UUID
  5. from fastapi import APIRouter, Depends, HTTPException
  6. import httpx
  7. import json
  8. import re
  9. from sqlmodel import Session, select
  10. from app.core.config import settings
  11. from app.db import get_session
  12. from app.deps import get_current_user, require_role
  13. from app.models import Exercise, ExerciseAttempt, User, UserRole, WrongBookItem
  14. from app.integrations.neo4j import Neo4jClient, get_neo4j_client
  15. from app.schemas import (
  16. AttemptPublic,
  17. AttemptSubmitRequest,
  18. ExerciseBatchRequest,
  19. ExerciseCreateRequest,
  20. ExercisePublic,
  21. )
  22. from app.utils.exercise_constants import RESOLUTION_EMPTY_PLACEHOLDER, normalize_resolution_for_store
  23. from app.utils.knowledge_codes import split_knowledge_codes
  24. from app.utils.neo4j_exercise import enrich_exercise_from_neo4j
  25. router = APIRouter(prefix="/assessment", tags=["assessment"])
  26. def _normalize_answer_text(s: str) -> str:
  27. s = (s or "").strip()
  28. if not s:
  29. return ""
  30. s = s.replace("=", "=").replace(",", ",")
  31. s = re.sub(r"\s+", "", s)
  32. return s
  33. def _normalize_boolean_judgment(s: str) -> str | None:
  34. """
  35. Map common true/false answer forms to canonical "T" / "F".
  36. Used when question_type == "判断".
  37. """
  38. raw = _normalize_answer_text(s)
  39. if not raw:
  40. return None
  41. low = raw.lower()
  42. if raw in {"对", "是", "√", "正确"} or low in {"t", "true", "y", "yes", "1", "right"}:
  43. return "T"
  44. if raw in {"错", "否", "×", "错误"} or low in {"f", "false", "n", "no", "0", "wrong"}:
  45. return "F"
  46. if len(raw) == 1 and low == "x":
  47. return "F"
  48. return None
  49. def _extract_standard_answer(body: str) -> str | None:
  50. if not body:
  51. return None
  52. m = re.search(
  53. r"(标准答案|参考答案|正确答案)\s*[::]\s*(?P<ans>.+?)(?:\n\s*(评分标准|评分|解析|题解)\s*[::]|$)",
  54. body,
  55. flags=re.I | re.S,
  56. )
  57. if not m:
  58. return None
  59. ans = (m.group("ans") or "").strip()
  60. return ans if ans else None
  61. def _extract_expected_answer(ex: Exercise) -> str | None:
  62. """
  63. Prefer teacher-provided `correct_answer`.
  64. Fallback to parsing `body` ("标准答案/正确答案/参考答案...") for legacy data.
  65. """
  66. if ex.correct_answer:
  67. s = str(ex.correct_answer).strip()
  68. return s if s else None
  69. return _extract_standard_answer(ex.body)
  70. def _try_rule_based_grade(ex: Exercise, student_answer: str) -> tuple[bool | None, float | None]:
  71. expected = _extract_expected_answer(ex)
  72. if not expected:
  73. return None, None
  74. expected_candidates = [c.strip() for c in re.split(r"[|/;;\n]+", expected) if c.strip()]
  75. stu = _normalize_answer_text(student_answer)
  76. if not stu:
  77. return False, 0.0
  78. if (getattr(ex, "question_type", None) or "").strip() == "判断":
  79. st_j = _normalize_boolean_judgment(student_answer)
  80. if st_j:
  81. for cand in expected_candidates:
  82. exp_j = _normalize_boolean_judgment(cand)
  83. if exp_j and st_j == exp_j:
  84. return True, 1.0
  85. # 学生已是明确的“对/错”表述,但标准答案无法解析为布尔:退回字符串规则
  86. if any(_normalize_boolean_judgment(c) for c in expected_candidates):
  87. return False, 0.0
  88. for cand in expected_candidates:
  89. cand_norm = _normalize_answer_text(cand)
  90. if cand_norm and cand_norm == stu:
  91. return True, 1.0
  92. if cand_norm and (cand_norm in stu or stu in cand_norm):
  93. return True, 0.8
  94. return False, 0.0
  95. def _extract_json_from_text(text: str) -> dict | None:
  96. if not text:
  97. return None
  98. i = text.find("{")
  99. j = text.rfind("}")
  100. if i < 0 or j <= i:
  101. return None
  102. try:
  103. return json.loads(text[i : j + 1])
  104. except Exception:
  105. return None
  106. def _judge_with_deepseek(ex: Exercise, student_answer: str) -> tuple[bool, float, str]:
  107. api_key = settings.deepseek_api_key.strip()
  108. if not api_key:
  109. return False, 0.0, "DeepSeek 未配置 API Key"
  110. messages = [
  111. {
  112. "role": "system",
  113. "content": "你是中学数学自动判题助理。请严格按要求输出JSON,不要输出多余文本。",
  114. },
  115. {
  116. "role": "user",
  117. "content": (
  118. "请判断学生的作答是否正确,并给出置信度分数score。\n"
  119. f"题目标题:{ex.title}\n"
  120. f"题目内容:{ex.body}\n"
  121. f"学生答案:{student_answer}\n\n"
  122. "输出JSON格式(必须严格可解析):"
  123. "{\"is_correct\": true/false, \"score\": 0.0-1.0, \"reason\": \"一句话简要\"}"
  124. ),
  125. },
  126. ]
  127. payload = {"model": settings.deepseek_model, "messages": messages, "temperature": 0.0}
  128. url = f"{settings.deepseek_base_url.rstrip('/')}/chat/completions"
  129. headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  130. try:
  131. with httpx.Client(timeout=settings.llm_request_timeout_seconds) as client:
  132. res = client.post(url, json=payload, headers=headers)
  133. res.raise_for_status()
  134. data = res.json()
  135. content = data["choices"][0]["message"]["content"]
  136. except Exception as e:
  137. return False, 0.0, f"DeepSeek调用失败: {e}"
  138. obj = _extract_json_from_text(content)
  139. if not isinstance(obj, dict):
  140. return False, 0.0, "DeepSeek返回非JSON"
  141. is_correct = obj.get("is_correct", False)
  142. if isinstance(is_correct, str):
  143. is_correct = is_correct.strip().lower() in {"true", "1", "yes", "y"}
  144. score = obj.get("score", 0.0)
  145. try:
  146. score_f = float(score)
  147. except Exception:
  148. score_f = 0.0
  149. score_f = max(0.0, min(1.0, score_f))
  150. reason = str(obj.get("reason", "")).strip()
  151. return bool(is_correct), score_f, reason
  152. def _grade_attempt(ex: Exercise, student_answer: str) -> tuple[bool, float]:
  153. """判题入口:先规则匹配标答,无法判定时再调用大模型返回 JSON。"""
  154. # 规则判题(教师提供 correct_answer 或题干内标准答案)
  155. rule_ok, rule_score = _try_rule_based_grade(ex, student_answer)
  156. if rule_ok is not None:
  157. return bool(rule_ok), float(rule_score or 0.0)
  158. is_correct, score, _reason = _judge_with_deepseek(ex, student_answer)
  159. return bool(is_correct), float(score)
  160. @router.post("/exercises", response_model=ExercisePublic)
  161. def create_exercise(
  162. payload: ExerciseCreateRequest,
  163. session: Session = Depends(get_session),
  164. neo4j: Neo4jClient = Depends(get_neo4j_client),
  165. _=Depends(require_role(UserRole.teacher)),
  166. ):
  167. ex = Exercise(**payload.model_dump())
  168. if getattr(ex, "weight", None) is None:
  169. ex.weight = 0
  170. ex.resolution = normalize_resolution_for_store(getattr(ex, "resolution", None))
  171. enrich_exercise_from_neo4j(ex, neo4j)
  172. session.add(ex)
  173. session.commit()
  174. session.refresh(ex)
  175. return ex
  176. @router.get("/exercises", response_model=list[ExercisePublic])
  177. def list_exercises(knowledge_code: str | None = None, session: Session = Depends(get_session)):
  178. exs = list(session.exec(select(Exercise)).all())
  179. if not knowledge_code:
  180. return exs
  181. codes = set(split_knowledge_codes(knowledge_code))
  182. if not codes:
  183. return exs
  184. out: list[Exercise] = []
  185. for ex in exs:
  186. ex_codes = set(split_knowledge_codes(ex.knowledge_code))
  187. if ex_codes & codes:
  188. out.append(ex)
  189. return out
  190. @router.post("/exercises/batch", response_model=list[ExercisePublic])
  191. def get_exercises_batch(payload: ExerciseBatchRequest, session: Session = Depends(get_session)):
  192. """按 ID 列表批量返回习题,保持请求顺序;缺失 ID 时返回 404。"""
  193. if not payload.ids:
  194. return []
  195. seen: set[str] = set()
  196. ordered_ids: list[UUID] = []
  197. for eid in payload.ids:
  198. key = str(eid)
  199. if key in seen:
  200. continue
  201. seen.add(key)
  202. ordered_ids.append(eid)
  203. rows = list(session.exec(select(Exercise).where(Exercise.id.in_(ordered_ids))).all())
  204. by_id = {ex.id: ex for ex in rows}
  205. out: list[Exercise] = []
  206. for eid in ordered_ids:
  207. ex = by_id.get(eid)
  208. if not ex:
  209. raise HTTPException(status_code=404, detail=f"Exercise not found: {eid}")
  210. out.append(ex)
  211. return out
  212. @router.get("/exercises/{exercise_id}", response_model=ExercisePublic)
  213. def get_exercise(exercise_id: UUID, session: Session = Depends(get_session)):
  214. ex = session.get(Exercise, exercise_id)
  215. if not ex:
  216. raise HTTPException(status_code=404, detail="Exercise not found")
  217. return ex
  218. @router.post("/exercises/{exercise_id}/attempts", response_model=AttemptPublic)
  219. def submit_attempt(
  220. exercise_id: UUID,
  221. payload: AttemptSubmitRequest,
  222. session: Session = Depends(get_session),
  223. current_user: User = Depends(get_current_user),
  224. ):
  225. """学生提交作答:判题 → 写作答记录 → 更新题目 weight → 错误时写入错题本。"""
  226. ex = session.get(Exercise, exercise_id)
  227. if not ex:
  228. raise HTTPException(status_code=404, detail="Exercise not found")
  229. is_correct, score = _grade_attempt(ex, payload.answer)
  230. # 个性化权重:错题升权、做对降权(最低 0),驱动后续推荐优先级。
  231. cur_w = int(getattr(ex, "weight", 0) or 0)
  232. if is_correct is False:
  233. ex.weight = cur_w + 1
  234. else:
  235. ex.weight = max(0, cur_w - 1)
  236. attempt = ExerciseAttempt(
  237. user_id=current_user.id,
  238. exercise_id=exercise_id,
  239. answer=payload.answer,
  240. is_correct=is_correct,
  241. score=score,
  242. )
  243. session.add(attempt)
  244. # 将本次错误按知识点写入错题本,便于个性推荐与学习分析统计。
  245. if is_correct is False:
  246. correct_answer = _extract_expected_answer(ex)
  247. codes = split_knowledge_codes(ex.knowledge_code)
  248. for code in codes:
  249. session.add(
  250. WrongBookItem(
  251. user_id=current_user.id,
  252. exercise_id=exercise_id,
  253. knowledge_code=code,
  254. wrong_answer=payload.answer,
  255. correct_answer=correct_answer,
  256. )
  257. )
  258. session.commit()
  259. session.refresh(attempt)
  260. return attempt
  261. @router.get("/attempts/me", response_model=list[AttemptPublic])
  262. def my_attempts(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
  263. return list(session.exec(select(ExerciseAttempt).where(ExerciseAttempt.user_id == current_user.id)).all())