"""作答记录与 AI 功能。 设计原则(笔记本而非刷题软件):提交答案只是**记录**,判分是用户主动触发的 独立动作。所以记录答案不需要配 AI、不消耗 AI 额度、也不按 AI 限流。 """ from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db import get_db from app.deps import get_current_user from app.models.practice import JudgedBy, PracticeRecord from app.models.question import Question from app.models.user import User from app.ratelimit import AI_LIMIT, limiter, user_key from app.schemas.ai import ( AttemptCreate, AttemptOut, ExplainOut, JudgeResult, SelfJudge, TagsOut, ) from app.services.grading import can_grade_locally, grade_objective from app.services.llm import ai_tasks from app.services.llm.client import LLMConfigError from app.services.tags import get_or_create_tags router = APIRouter(prefix="/api", tags=["ai"]) async def _owned_question(db: AsyncSession, user_id: str, question_id: str) -> Question: q = await db.scalar( select(Question) .where(Question.id == question_id, Question.user_id == user_id) .options(selectinload(Question.tags)) ) if q is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="笔记不存在") return q async def _owned_attempt(db: AsyncSession, user_id: str, attempt_id: str) -> PracticeRecord: record = await db.scalar( select(PracticeRecord).where( PracticeRecord.id == attempt_id, PracticeRecord.user_id == user_id ) ) if record is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="作答记录不存在") return record def _llm_error(): return HTTPException( status.HTTP_400_BAD_REQUEST, detail="AI 未配置,请先到设置页填写接口信息", ) @router.post( "/questions/{question_id}/attempts", response_model=AttemptOut, status_code=status.HTTP_201_CREATED, ) async def create_attempt( question_id: str, payload: AttemptCreate, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): """记录一次作答,不判分。 刻意不调用 AI:没配 AI 也能正常记录,答案不会因此丢失。 """ q = await _owned_question(db, current.id, question_id) record = PracticeRecord( user_id=current.id, question_id=q.id, user_answer=payload.user_answer, is_correct=None, # 尚未判分 judged_by=JudgedBy.self_, ) db.add(record) await db.commit() await db.refresh(record) return record @router.get("/questions/{question_id}/attempts", response_model=list[AttemptOut]) async def list_attempts( question_id: str, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): """某条笔记的历史作答,最近的在前。""" await _owned_question(db, current.id, question_id) rows = ( await db.scalars( select(PracticeRecord) .where( PracticeRecord.user_id == current.id, PracticeRecord.question_id == question_id, ) .order_by(PracticeRecord.created_at.desc()) ) ).all() return list(rows) @router.post("/attempts/{attempt_id}/judge", response_model=JudgeResult) @limiter.limit(AI_LIMIT, key_func=user_key) async def judge_attempt( request: Request, attempt_id: str, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): """按需判分:有标准答案就本地精确判(免费),否则交给 AI。""" record = await _owned_attempt(db, current.id, attempt_id) q = await _owned_question(db, current.id, record.question_id) answer = record.user_answer or [] if can_grade_locally(q): record.is_correct = grade_objective(q, answer) record.judged_by = JudgedBy.auto await db.commit() return JudgeResult( attempt_id=record.id, is_correct=record.is_correct, judged_by="auto", correct_answer=q.correct_answer, ) try: llm = await ai_tasks.load_llm(db, current.id) result = await ai_tasks.judge_answer(db, llm, current.id, q, answer) except LLMConfigError: raise _llm_error() record.is_correct = result["is_correct"] record.judged_by = JudgedBy.ai record.ai_feedback_markdown = result["feedback_markdown"] await db.commit() return JudgeResult( attempt_id=record.id, is_correct=record.is_correct, judged_by="ai", feedback_markdown=result["feedback_markdown"], key_points_missed=result["key_points_missed"], correct_answer=q.correct_answer, ) @router.post("/attempts/{attempt_id}/self-judge", response_model=JudgeResult) async def self_judge_attempt( attempt_id: str, payload: SelfJudge, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): """自评:我自己知道对错,不必花 AI 额度。""" record = await _owned_attempt(db, current.id, attempt_id) record.is_correct = payload.is_correct record.judged_by = JudgedBy.self_ await db.commit() return JudgeResult( attempt_id=record.id, is_correct=payload.is_correct, judged_by="self" ) @router.post("/questions/{question_id}/ai/explain", response_model=ExplainOut) @limiter.limit(AI_LIMIT, key_func=user_key) async def explain( request: Request, question_id: str, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): q = await _owned_question(db, current.id, question_id) try: llm = await ai_tasks.load_llm(db, current.id) text = await ai_tasks.explain_question(db, llm, current.id, q) except LLMConfigError: raise _llm_error() # 只写 AI 讲解字段,绝不碰用户自己的 my_note_markdown q.explanation_markdown = text await db.commit() return ExplainOut(explanation_markdown=text) @router.post("/questions/{question_id}/ai/summarize-tags", response_model=TagsOut) @limiter.limit(AI_LIMIT, key_func=user_key) async def summarize_tags( request: Request, question_id: str, db: AsyncSession = Depends(get_db), current: User = Depends(get_current_user), ): q = await _owned_question(db, current.id, question_id) try: llm = await ai_tasks.load_llm(db, current.id) names = await ai_tasks.summarize_tags(db, llm, current.id, q) except LLMConfigError: raise _llm_error() if names: existing = {t.name for t in q.tags} new_names = [n for n in names if n not in existing] if new_names: q.tags.extend(await get_or_create_tags(db, current.id, new_names)) await db.commit() await db.refresh(q, ["tags"]) return TagsOut(tags=[t.name for t in q.tags])