nex_math/backend/routers/practice.py

218 lines
6.8 KiB
Python

"""练习:章节组卷与提交判分(学习数据按用户隔离)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_learning_user
from models import (
AttemptItem,
AttemptSession,
Chapter,
ErrorEntry,
Knowledge,
Question,
Textbook,
UserKnowledge,
User,
)
from schemas import (
AnswerIn,
KnowledgeOut,
PerQuestionOut,
PracticeSessionOut,
QuestionOut,
SubmitIn,
SubmitResultOut,
)
from services.ai_teacher import explain as teacher_explain
from services.mastery_engine import apply_answers, snapshot, status_for
from services.question_engine import chapter_question_ids, load_questions
from services.knowledge_service import question_knowledge_names
router = APIRouter(prefix="/practice", tags=["practice"])
def _to_question_out(q: Question) -> QuestionOut:
return QuestionOut(
id=q.id,
stem=q.stem,
options=json.loads(q.options),
knowledge_name=q.knowledge_name,
)
@router.get("/chapter/{chapter_id}", response_model=PracticeSessionOut)
def chapter_session(
chapter_id: int,
limit: int = 5,
variant: int = 0,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
chapter = db.get(Chapter, chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
textbook = db.get(Textbook, chapter.textbook_id)
question_ids = chapter_question_ids(
db, user.id, chapter.id, limit=max(1, min(limit, 10)), variant=variant
)
questions = load_questions(db, question_ids)
return PracticeSessionOut(
mode="chapter",
title=f"{textbook.name if textbook else '教材'} · {chapter.name}",
chapter_id=chapter.id,
questions=[_to_question_out(q) for q in questions],
)
@router.post("/submit", response_model=SubmitResultOut)
def submit_answers(
payload: SubmitIn,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
mode = payload.mode
if mode != "chapter":
raise HTTPException(status_code=400, detail="未知的练习模式")
seen: set[int] = set()
for answer in payload.answers:
if answer.question_id in seen:
raise HTTPException(status_code=400, detail="同一道题提交了多次")
seen.add(answer.question_id)
questions = load_questions(db, list(seen))
by_id = {q.id: q for q in questions}
if len(by_id) != len(seen):
raise HTTPException(status_code=400, detail="部分题目不存在")
knowledge_map = {
knowledge.name: user_knowledge
for user_knowledge, knowledge in (
db.query(UserKnowledge, Knowledge)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(UserKnowledge.user_id == user.id)
.all()
)
}
if payload.chapter_id is None:
raise HTTPException(status_code=400, detail="章节测试需要指定 chapter_id")
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
textbook = db.get(Textbook, chapter.textbook_id)
title = f"{textbook.name if textbook else '教材'} · {chapter.name}"
if any(q.chapter_id != chapter.id for q in questions):
raise HTTPException(
status_code=400,
detail="提交的题目不属于所选章节,请重新开始测试",
)
score = 0
answered_mastery: list[tuple[str, bool]] = []
per_question: list[PerQuestionOut] = []
attempt = AttemptSession(
user_id=user.id,
mode=mode,
title=title,
total=len(payload.answers),
correct=0,
chapter_id=chapter.id,
)
db.add(attempt)
db.flush()
for index, answer in enumerate(payload.answers, start=1):
q = by_id[answer.question_id]
correct = answer.selected == q.correct_index
if correct:
score += 1
tags = question_knowledge_names(db, q)
for tag in tags:
answered_mastery.append((tag, correct))
teacher_text = teacher_explain(
q,
answer.selected,
correct,
knowledge_map.get(q.knowledge_name),
)
q.used_count = (q.used_count or 0) + 1
db.add(
AttemptItem(
attempt_id=attempt.id,
user_id=user.id,
question_index=index,
question_id=q.id,
stem=q.stem,
options=q.options,
correct_index=q.correct_index,
selected=answer.selected,
correct=correct,
knowledge_name=tags[0] if tags else q.knowledge_name,
knowledge_names=json.dumps(tags or [q.knowledge_name], ensure_ascii=False),
explanation=teacher_text,
)
)
if not correct:
diagnosis = q.explanation or "需要复习该知识点"
db.add(
ErrorEntry(
user_id=user.id,
attempt_id=attempt.id,
source_label=f"{title} · {index}",
knowledge_name=tags[0] if tags else q.knowledge_name,
knowledge_names=json.dumps(
tags or [q.knowledge_name], ensure_ascii=False
),
diagnosis=diagnosis,
question_id=q.id,
stem=q.stem,
options=q.options,
selected=answer.selected,
correct_index=q.correct_index,
explanation=teacher_text,
)
)
per_question.append(
PerQuestionOut(
question_id=q.id,
correct=correct,
selected=answer.selected,
correct_index=q.correct_index,
explanation=teacher_text,
)
)
apply_answers(db, user.id, mode, answered_mastery)
attempt.correct = score
db.commit()
accuracy = score / len(payload.answers) * 100 if payload.answers else 0.0
return SubmitResultOut(
mode=mode,
title=title,
total=len(payload.answers),
score=score,
accuracy=round(accuracy, 1),
per_question=per_question,
knowledge=[
KnowledgeOut(
id=k.id,
knowledge_id=k.knowledge_id,
name=k.name,
mastery=k.mastery,
position=k.position,
status=status_for(k.mastery),
domain=k.domain,
category=k.category,
)
for k in snapshot(db, user.id)
],
)