43483bf56f
- 手机/平板响应式布局,支持拍照与相册上传 - 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲 - 系统设置可配置 Ollama 或 OpenAI 兼容 API - 更新 frontend/dist 与使用说明
335 lines
12 KiB
Python
335 lines
12 KiB
Python
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.orm import Session, joinedload
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import SessionLocal, get_db
|
|
from app.core.deps import get_current_user
|
|
from app.models.user import Subject, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
|
|
from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate
|
|
from app.services import llm as llm_service
|
|
from app.services import ocr as ocr_service
|
|
from app.services.student_access import get_student_for_user
|
|
|
|
router = APIRouter(tags=["wrong_questions"])
|
|
|
|
|
|
def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
|
|
return WrongQuestionOut(
|
|
id=wq.id,
|
|
student_id=wq.student_id,
|
|
subject_id=wq.subject_id,
|
|
subject_name=wq.subject.name if wq.subject else None,
|
|
category=wq.category,
|
|
image_path=wq.image_path,
|
|
ocr_raw_text=wq.ocr_raw_text,
|
|
question_text=wq.question_text,
|
|
solution_text=wq.solution_text,
|
|
status=wq.status,
|
|
created_at=wq.created_at,
|
|
)
|
|
|
|
|
|
def _process_wrong_question(question_id: uuid.UUID):
|
|
db = SessionLocal()
|
|
try:
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None:
|
|
return
|
|
|
|
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
|
|
try:
|
|
ocr_text = ocr_service.run_ocr(str(image_full))
|
|
wq.ocr_raw_text = ocr_text or None
|
|
wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed
|
|
db.commit()
|
|
except Exception:
|
|
wq.status = WrongQuestionStatus.failed
|
|
db.commit()
|
|
return
|
|
|
|
if not ocr_text:
|
|
return
|
|
|
|
subject_name = wq.subject.name if wq.subject else "综合"
|
|
school_level = wq.student.school_level if wq.student else None
|
|
olympiad = wq.category == WrongQuestionCategory.olympiad
|
|
ai_cfg = llm_service.load_ai_config(db)
|
|
|
|
import asyncio
|
|
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
question_text = loop.run_until_complete(
|
|
llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
|
|
)
|
|
solution_text = loop.run_until_complete(
|
|
llm_service.generate_solution(
|
|
ai_cfg,
|
|
subject_name,
|
|
question_text,
|
|
school_level,
|
|
olympiad=olympiad,
|
|
)
|
|
)
|
|
wq.question_text = question_text
|
|
wq.solution_text = solution_text
|
|
wq.status = WrongQuestionStatus.solved
|
|
db.commit()
|
|
except Exception:
|
|
wq.status = WrongQuestionStatus.ocr_done
|
|
db.commit()
|
|
finally:
|
|
loop.close()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/students/{student_id}/wrong-questions", response_model=list[WrongQuestionOut])
|
|
def list_wrong_questions(
|
|
student_id: uuid.UUID,
|
|
subject_id: int | None = Query(None),
|
|
category: WrongQuestionCategoryEnum | None = Query(None),
|
|
q: str | None = Query(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
get_student_for_user(db, student_id, current_user.id)
|
|
query = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject))
|
|
.filter(WrongQuestion.student_id == student_id)
|
|
)
|
|
if subject_id is not None:
|
|
query = query.filter(WrongQuestion.subject_id == subject_id)
|
|
if category is not None:
|
|
query = query.filter(WrongQuestion.category == category.value)
|
|
if q:
|
|
pattern = f"%{q}%"
|
|
query = query.filter(
|
|
(WrongQuestion.question_text.ilike(pattern))
|
|
| (WrongQuestion.solution_text.ilike(pattern))
|
|
| (WrongQuestion.ocr_raw_text.ilike(pattern))
|
|
)
|
|
items = query.order_by(WrongQuestion.created_at.desc()).all()
|
|
return [_wq_to_out(w) for w in items]
|
|
|
|
|
|
@router.post(
|
|
"/students/{student_id}/wrong-questions",
|
|
response_model=WrongQuestionOut,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def upload_wrong_question(
|
|
student_id: uuid.UUID,
|
|
background_tasks: BackgroundTasks,
|
|
subject_id: int = Form(...),
|
|
file: UploadFile = File(...),
|
|
category: WrongQuestionCategoryEnum = Form(WrongQuestionCategoryEnum.regular),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
get_student_for_user(db, student_id, current_user.id)
|
|
subject = db.get(Subject, subject_id)
|
|
if subject is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
|
|
|
|
content = await file.read()
|
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
|
|
|
|
wq = WrongQuestion(
|
|
student_id=student_id,
|
|
subject_id=subject_id,
|
|
image_path="",
|
|
category=WrongQuestionCategory(category.value),
|
|
status=WrongQuestionStatus.pending,
|
|
)
|
|
db.add(wq)
|
|
db.flush()
|
|
|
|
rel_path = ocr_service.save_upload_file(
|
|
str(current_user.id), str(wq.id), file.filename or "image.jpg", content
|
|
)
|
|
wq.image_path = rel_path
|
|
db.commit()
|
|
db.refresh(wq)
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject))
|
|
.filter(WrongQuestion.id == wq.id)
|
|
.first()
|
|
)
|
|
|
|
background_tasks.add_task(_process_wrong_question, wq.id)
|
|
return _wq_to_out(wq)
|
|
|
|
|
|
@router.get("/wrong-questions/{question_id}", response_model=WrongQuestionOut)
|
|
def get_wrong_question(
|
|
question_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None or wq.student.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
|
return _wq_to_out(wq)
|
|
|
|
|
|
@router.patch("/wrong-questions/{question_id}", response_model=WrongQuestionOut)
|
|
def update_wrong_question(
|
|
question_id: uuid.UUID,
|
|
data: WrongQuestionUpdate,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None or wq.student.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
|
|
|
if data.subject_id is not None:
|
|
if db.get(Subject, data.subject_id) is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
|
|
wq.subject_id = data.subject_id
|
|
if data.question_text is not None:
|
|
wq.question_text = data.question_text
|
|
if data.solution_text is not None:
|
|
wq.solution_text = data.solution_text
|
|
|
|
db.commit()
|
|
db.refresh(wq)
|
|
return _wq_to_out(wq)
|
|
|
|
|
|
@router.delete("/wrong-questions/{question_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_wrong_question(
|
|
question_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None or wq.student.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
|
|
|
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
|
|
db.delete(wq)
|
|
db.commit()
|
|
if image_full.exists():
|
|
image_full.unlink()
|
|
|
|
|
|
@router.post("/wrong-questions/{question_id}/retry-ocr", response_model=WrongQuestionOut)
|
|
def retry_ocr(
|
|
question_id: uuid.UUID,
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None or wq.student.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
|
|
|
wq.status = WrongQuestionStatus.pending
|
|
db.commit()
|
|
background_tasks.add_task(_process_wrong_question, wq.id)
|
|
return _wq_to_out(wq)
|
|
|
|
|
|
@router.post("/wrong-questions/{question_id}/regenerate-solution", response_model=WrongQuestionOut)
|
|
async def regenerate_solution(
|
|
question_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.subject), joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None or wq.student.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
|
|
|
if not wq.question_text and not wq.ocr_raw_text:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少题目内容")
|
|
|
|
subject_name = wq.subject.name if wq.subject else "综合"
|
|
school_level = wq.student.school_level if wq.student else None
|
|
olympiad = wq.category == WrongQuestionCategory.olympiad
|
|
question_text = wq.question_text or wq.ocr_raw_text or ""
|
|
ai_cfg = llm_service.load_ai_config(db)
|
|
|
|
try:
|
|
if not wq.question_text and wq.ocr_raw_text:
|
|
wq.question_text = await llm_service.format_question(
|
|
ai_cfg, subject_name, wq.ocr_raw_text, school_level
|
|
)
|
|
question_text = wq.question_text
|
|
wq.solution_text = await llm_service.generate_solution(
|
|
ai_cfg,
|
|
subject_name,
|
|
question_text,
|
|
school_level,
|
|
olympiad=olympiad,
|
|
)
|
|
wq.status = WrongQuestionStatus.solved
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI 调用失败: {exc}"
|
|
) from exc
|
|
|
|
db.commit()
|
|
db.refresh(wq)
|
|
return _wq_to_out(wq)
|
|
|
|
|
|
@router.get("/wrong-questions/{question_id}/image")
|
|
def get_wrong_question_image(
|
|
question_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
wq = (
|
|
db.query(WrongQuestion)
|
|
.options(joinedload(WrongQuestion.student))
|
|
.filter(WrongQuestion.id == question_id)
|
|
.first()
|
|
)
|
|
if wq is None or wq.student.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
|
|
|
|
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
|
|
if not image_full.exists():
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
|
|
return FileResponse(image_full)
|