新增作文区与 AI 解读开关,修复 CSV 导出。
系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user