218 lines
6.8 KiB
Python
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)
|
|
],
|
|
)
|