"""练习:章节组卷与提交判分(学习数据按用户隔离)。""" 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) ], )