215 lines
6.9 KiB
Python
215 lines
6.9 KiB
Python
"""作答记录与 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])
|