Files
item_bank/app/api/ai.py
T
2026-08-01 16:50:55 +08:00

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])