Files
secondary-school-grade-archive/backend/app/routers/compositions.py
T
dekun 1cb3c7fad5 新增作文区与 AI 解读开关,修复 CSV 导出。
系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 17:42:17 +08:00

262 lines
8.9 KiB
Python

import re
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
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 Composition, CompositionInputMode, CompositionStatus, SystemSettings, User
from app.schemas import CompositionCreate, CompositionInputModeEnum, CompositionOcrOut, CompositionOut
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=["compositions"])
def _ocr_service_url(db: Session) -> str | None:
row = db.get(SystemSettings, 1)
if row and row.ocr_service_url:
return row.ocr_service_url.strip() or None
return ocr_service.resolve_ocr_service_url()
def _to_out(item: Composition) -> CompositionOut:
return CompositionOut(
id=item.id,
student_id=item.student_id,
topic=item.topic,
input_mode=CompositionInputModeEnum(item.input_mode.value),
writing_plan=item.writing_plan,
sample_essay=item.sample_essay,
error_message=item.error_message,
status=item.status,
created_at=item.created_at,
updated_at=item.updated_at,
)
def _safe_filename(name: str) -> str:
cleaned = re.sub(r'[\\/:*?"<>|]', "_", name).strip() or "composition"
return cleaned[:40]
async def _generate_composition(composition_id: uuid.UUID):
db = SessionLocal()
try:
item = (
db.query(Composition)
.options(joinedload(Composition.student))
.filter(Composition.id == composition_id)
.first()
)
if item is None:
return
student = item.student
item.status = CompositionStatus.generating
item.error_message = None
db.commit()
ai_cfg = llm_service.load_ai_config(db)
try:
plan, essay = await llm_service.generate_composition(
ai_cfg,
item.topic,
student.school_level if student else None,
student.grade if student else None,
)
item.writing_plan = plan or None
item.sample_essay = essay or None
if not item.writing_plan and not item.sample_essay:
raise ValueError("AI 未返回有效内容")
item.status = CompositionStatus.done
item.error_message = None
except Exception as exc:
item.status = CompositionStatus.failed
item.error_message = str(exc)[:500]
item.updated_at = datetime.now(timezone.utc)
db.commit()
finally:
db.close()
@router.get("/students/{student_id}/compositions", response_model=list[CompositionOut])
def list_compositions(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
items = (
db.query(Composition)
.filter(Composition.student_id == student_id)
.order_by(Composition.created_at.desc())
.all()
)
return [_to_out(item) for item in items]
@router.post(
"/students/{student_id}/compositions",
response_model=CompositionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_composition(
student_id: uuid.UUID,
data: CompositionCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
item = Composition(
student_id=student_id,
topic=data.topic.strip(),
input_mode=CompositionInputMode(data.input_mode.value),
status=CompositionStatus.pending,
)
db.add(item)
db.commit()
db.refresh(item)
background_tasks.add_task(_generate_composition, item.id)
return _to_out(item)
@router.post("/students/{student_id}/compositions/ocr", response_model=CompositionOcrOut)
async def ocr_composition_topic(
student_id: uuid.UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
get_student_for_user(db, student_id, current_user.id)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
tmp_dir = Path(settings.UPLOAD_DIR) / "ocr-tmp"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / f"{uuid.uuid4()}.jpg"
tmp_path.write_bytes(content)
ocr_url = _ocr_service_url(db)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(ocr_service.run_ocr_with_regions, str(tmp_path), ocr_url)
result = future.result(timeout=settings.OCR_TIMEOUT_SECONDS)
text = (result.get("text") or "").strip()
if not text:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OCR 未识别到文字")
return CompositionOcrOut(text=text)
except FuturesTimeout:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"OCR 识别超时(>{settings.OCR_TIMEOUT_SECONDS}秒)",
) from None
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"OCR 识别失败:{exc}",
) from exc
finally:
tmp_path.unlink(missing_ok=True)
@router.get("/compositions/{composition_id}", response_model=CompositionOut)
def get_composition(
composition_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
return _to_out(item)
@router.post("/compositions/{composition_id}/regenerate", response_model=CompositionOut)
async def regenerate_composition(
composition_id: uuid.UUID,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
item.status = CompositionStatus.pending
item.error_message = None
item.writing_plan = None
item.sample_essay = None
db.commit()
background_tasks.add_task(_generate_composition, item.id)
db.refresh(item)
return _to_out(item)
@router.get("/compositions/{composition_id}/download")
def download_composition(
composition_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
if item.status != CompositionStatus.done:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作文尚未生成完成")
body = llm_service.composition_markdown(item.topic, item.writing_plan, item.sample_essay)
filename = f"{_safe_filename(item.topic)}.md"
from urllib.parse import quote
encoded = quote(filename)
return Response(
content=body.encode("utf-8"),
media_type="text/markdown; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="composition.md"; filename*=UTF-8\'\'{encoded}'
},
)
@router.delete("/compositions/{composition_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_composition(
composition_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
item = (
db.query(Composition)
.join(Composition.student)
.filter(Composition.id == composition_id)
.first()
)
if item is None or item.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作文记录不存在")
db.delete(item)
db.commit()