智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from docx import Document
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from database import get_db
|
||||
from models.exercise import Exercise, ExerciseAttempt
|
||||
from models.resource import Resource
|
||||
from models.user import User
|
||||
from schemas.exercise import ExerciseCreate, ExerciseAIGenerate, ExerciseAttemptCreate, ExerciseAttemptOut, ExerciseOut, ExerciseUpdate
|
||||
from services.credits import credits_payload, spend_credits
|
||||
from services.limiter import limiter
|
||||
from services.auth import get_current_user, get_optional_current_user
|
||||
from services.ai_service import AIService
|
||||
|
||||
router = APIRouter(prefix="/api/exercises", tags=["互动练习"])
|
||||
ai_service = AIService()
|
||||
|
||||
|
||||
def _safe_filename(name: str, suffix: str) -> str:
|
||||
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "课堂练习"
|
||||
return f"{stem[:80]}{suffix}"
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if value in (None, ""):
|
||||
return []
|
||||
return [value]
|
||||
|
||||
|
||||
def _exercise_or_404(exercise_id: int, current_user: User | None, db: Session) -> Exercise:
|
||||
ex = db.query(Exercise).filter(Exercise.id == exercise_id).first()
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
if (not current_user or ex.user_id != current_user.id) and not has_public_resource(db, exercise_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该练习")
|
||||
return ex
|
||||
|
||||
|
||||
def has_public_resource(db: Session, exercise_id: int) -> bool:
|
||||
return db.query(Resource).filter(
|
||||
Resource.content_ref == f"exercise:{exercise_id}",
|
||||
Resource.status == "active",
|
||||
Resource.is_public == 1,
|
||||
).first() is not None
|
||||
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@router.post("/ai-generate", response_model=dict)
|
||||
async def ai_generate_exercise(
|
||||
request: Request,
|
||||
data: ExerciseAIGenerate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = spend_credits(db, current_user, "exercise_generate", f"生成互动练习:{data.prompt[:80]}")
|
||||
db.commit()
|
||||
result = await ai_service.generate_exercise(
|
||||
prompt=data.prompt, exercise_type=data.exercise_type,
|
||||
subject=data.subject, knowledge_points=data.knowledge_points, count=data.count,
|
||||
)
|
||||
return {"success": True, "data": result, "credits": credits_payload(account, "exercise_generate")}
|
||||
|
||||
|
||||
@router.post("/", response_model=ExerciseOut, status_code=201)
|
||||
def create_exercise(data: ExerciseCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
ex = Exercise(user_id=current_user.id, **data.model_dump())
|
||||
db.add(ex)
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
return ex
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ExerciseOut])
|
||||
def list_exercises(
|
||||
exercise_type: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(Exercise).filter(Exercise.user_id == current_user.id)
|
||||
if exercise_type:
|
||||
query = query.filter(Exercise.exercise_type == exercise_type)
|
||||
if subject:
|
||||
query = query.filter(Exercise.subject == subject)
|
||||
return query.order_by(Exercise.updated_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{exercise_id}", response_model=ExerciseOut)
|
||||
def get_exercise(
|
||||
exercise_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return _exercise_or_404(exercise_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{exercise_id}/export-docx")
|
||||
def export_exercise_docx(
|
||||
exercise_id: int,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ex = _exercise_or_404(exercise_id, current_user, db)
|
||||
document = Document()
|
||||
document.add_heading(ex.title or "课堂练习", level=1)
|
||||
meta_parts = [ex.subject, ex.exercise_type, f"{len(ex.questions or [])}题"]
|
||||
if ex.knowledge_points:
|
||||
meta_parts.append("知识点:" + "、".join(str(item) for item in ex.knowledge_points if str(item).strip()))
|
||||
document.add_paragraph(" / ".join(str(part) for part in meta_parts if part))
|
||||
|
||||
for index, question in enumerate(ex.questions or [], start=1):
|
||||
if not isinstance(question, dict):
|
||||
document.add_paragraph(f"{index}. {question}")
|
||||
continue
|
||||
q_type = question.get("type") or ex.exercise_type or "练习"
|
||||
difficulty = question.get("difficulty")
|
||||
heading = f"{index}. [{q_type}]"
|
||||
if difficulty:
|
||||
heading += f"({difficulty})"
|
||||
document.add_paragraph(heading)
|
||||
document.add_paragraph(str(question.get("question") or question.get("content") or question.get("stem") or ""))
|
||||
options = _as_list(question.get("options"))
|
||||
for option_index, option in enumerate(options):
|
||||
prefix = chr(65 + option_index) if option_index < 26 else str(option_index + 1)
|
||||
document.add_paragraph(f"{prefix}. {option}")
|
||||
answer = question.get("answer")
|
||||
if answer not in (None, ""):
|
||||
document.add_paragraph(f"答案:{answer}")
|
||||
explanation = question.get("explanation") or question.get("analysis")
|
||||
if explanation:
|
||||
document.add_paragraph(f"解析:{explanation}")
|
||||
|
||||
buffer = BytesIO()
|
||||
document.save(buffer)
|
||||
buffer.seek(0)
|
||||
filename = _safe_filename(f"{ex.title or '课堂练习'}-练习", ".docx")
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{exercise_id}", response_model=ExerciseOut)
|
||||
def update_exercise(
|
||||
exercise_id: int,
|
||||
data: ExerciseUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(ex, key, value)
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
return ex
|
||||
|
||||
|
||||
@router.post("/{exercise_id}/remix", response_model=ExerciseOut, status_code=201)
|
||||
def remix_exercise(
|
||||
exercise_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
source = db.query(Exercise).filter(Exercise.id == exercise_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
if source.user_id != current_user.id and not has_public_resource(db, exercise_id):
|
||||
raise HTTPException(status_code=403, detail="无权改编该练习")
|
||||
|
||||
clone = Exercise(
|
||||
user_id=current_user.id,
|
||||
title=f"{source.title}(改编)",
|
||||
exercise_type=source.exercise_type or "choice",
|
||||
subject=source.subject or "",
|
||||
knowledge_points=list(source.knowledge_points or []),
|
||||
questions=list(source.questions or []),
|
||||
settings=dict(source.settings or {}),
|
||||
)
|
||||
db.add(clone)
|
||||
db.commit()
|
||||
db.refresh(clone)
|
||||
return clone
|
||||
|
||||
|
||||
@router.delete("/{exercise_id}", status_code=204)
|
||||
def delete_exercise(exercise_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
ex = db.query(Exercise).filter(Exercise.id == exercise_id, Exercise.user_id == current_user.id).first()
|
||||
if not ex:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
db.query(Resource).filter(
|
||||
Resource.user_id == current_user.id,
|
||||
Resource.content_ref == f"exercise:{exercise_id}",
|
||||
Resource.status == "active",
|
||||
).update({"status": "archived"}, synchronize_session=False)
|
||||
db.delete(ex)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{exercise_id}/attempt", response_model=dict, status_code=201)
|
||||
def submit_attempt(exercise_id: int, data: ExerciseAttemptCreate, db: Session = Depends(get_db)):
|
||||
exercise = db.query(Exercise).filter(Exercise.id == exercise_id).first()
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
attempt = ExerciseAttempt(
|
||||
exercise_id=exercise_id,
|
||||
student_name=data.student_name,
|
||||
answers=data.answers,
|
||||
score=data.score,
|
||||
total=data.total,
|
||||
duration=data.duration,
|
||||
)
|
||||
db.add(attempt)
|
||||
db.commit()
|
||||
db.refresh(attempt)
|
||||
return {"success": True, "id": attempt.id}
|
||||
|
||||
|
||||
@router.get("/{exercise_id}/attempts", response_model=list[ExerciseAttemptOut])
|
||||
def list_attempts(
|
||||
exercise_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
exercise = db.query(Exercise).filter(
|
||||
Exercise.id == exercise_id,
|
||||
Exercise.user_id == current_user.id,
|
||||
).first()
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="练习不存在")
|
||||
return (
|
||||
db.query(ExerciseAttempt)
|
||||
.filter(ExerciseAttempt.exercise_id == exercise_id)
|
||||
.order_by(ExerciseAttempt.created_at.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
Reference in New Issue
Block a user