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