| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- """
- 题库与测评路由:习题 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<ans>.+?)(?:\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())
|