"""API routes for intelligent evaluation (智能评估). 领域逻辑(状态机)在 intelligent_eval/lifecycle.py;本层只做 HTTP 翻译: NotFound→404、TransitionError→409。 """ from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field from sqlmodel import Session from agenteval.intelligent_eval import lifecycle from agenteval.intelligent_eval.lifecycle import ( IntelligentEvalChannelError, IntelligentEvalNotFoundError, IntelligentEvalTransitionError, ) from agenteval.intelligent_eval.report import ReportModel, render_report_markdown from agenteval.intelligent_eval.repository import IntelligentEvalSessionRepository from agenteval.web.deps import get_db router = APIRouter() class CreateEvalRequest(BaseModel): name: str = Field(min_length=1) target_id: str = Field(min_length=1) goal: str = Field(min_length=1) seeds: dict[str, Any] = Field(default_factory=dict) intent: str = "" role_description: str = "" time_window_hours: int = Field(default=24, ge=1) class SubmitPlanRequest(BaseModel): plan: dict[str, Any] class RejectRequest(BaseModel): feedback: str = Field(min_length=1) class CreateSessionRequest(BaseModel): persona: dict[str, Any] = Field(default_factory=dict) goal: str = Field(min_length=1) dimension: str | None = None class SendMessageRequest(BaseModel): content: str = Field(min_length=1) class CloseSessionRequest(BaseModel): verdict: dict[str, Any] class SubmitReportRequest(BaseModel): report: ReportModel def _translate(exc: Exception) -> HTTPException: if isinstance(exc, IntelligentEvalNotFoundError): return HTTPException(status_code=404, detail=str(exc)) if isinstance(exc, IntelligentEvalChannelError): return HTTPException(status_code=502, detail=str(exc)) return HTTPException(status_code=409, detail=exc.reason) def _eval_response(ev, session: Session) -> dict: data = ev.model_dump(mode="json") sessions = IntelligentEvalSessionRepository(session).list_by_eval(ev.id) data["session_count"] = len(sessions) data["completed_sessions"] = sum(1 for s in sessions if s.status.value == "completed") return data @router.post("") async def create_eval(request: CreateEvalRequest, session: Session = Depends(get_db)) -> dict: ev = lifecycle.create_eval( session, name=request.name, target_id=request.target_id, goal=request.goal, seeds=request.seeds, intent=request.intent, role_description=request.role_description, time_window_hours=request.time_window_hours, ) return _eval_response(ev, session) @router.get("") async def list_evals(session: Session = Depends(get_db)) -> dict: evals = lifecycle.list_evals(session) return {"intelligent_evals": [_eval_response(ev, session) for ev in evals]} @router.get("/{eval_id}") async def get_eval(eval_id: str, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.get_eval(session, eval_id) except IntelligentEvalNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _eval_response(ev, session) @router.put("/{eval_id}/plan") async def submit_plan(eval_id: str, request: SubmitPlanRequest, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.submit_plan(session, eval_id, request.plan) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return _eval_response(ev, session) @router.post("/{eval_id}/approve") async def approve(eval_id: str, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.approve(session, eval_id) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return _eval_response(ev, session) @router.post("/{eval_id}/reject") async def reject(eval_id: str, request: RejectRequest, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.reject(session, eval_id, request.feedback) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return _eval_response(ev, session) @router.post("/{eval_id}/cancel") async def cancel(eval_id: str, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.cancel(session, eval_id) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return _eval_response(ev, session) @router.put("/{eval_id}/report") async def submit_report(eval_id: str, request: SubmitReportRequest, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.submit_report(session, eval_id, request.report.model_dump()) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return _eval_response(ev, session) @router.get("/{eval_id}/report") async def get_report(eval_id: str, session: Session = Depends(get_db)) -> dict: try: ev = lifecycle.get_eval(session, eval_id) except IntelligentEvalNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc if ev.report is None: raise HTTPException(status_code=404, detail="report not submitted yet") return ev.report @router.get("/{eval_id}/report/markdown", response_class=PlainTextResponse) async def get_report_markdown(eval_id: str, session: Session = Depends(get_db)) -> PlainTextResponse: try: ev = lifecycle.get_eval(session, eval_id) except IntelligentEvalNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc if ev.report is None: raise HTTPException(status_code=404, detail="report not submitted yet") markdown = render_report_markdown(ev.report, name=ev.name, eval_id=ev.id) return PlainTextResponse(markdown, media_type="text/markdown; charset=utf-8") @router.post("/{eval_id}/sessions") async def create_session( eval_id: str, request: CreateSessionRequest, session: Session = Depends(get_db) ) -> dict: try: obj = lifecycle.open_session( session, eval_id=eval_id, persona=request.persona, goal=request.goal, dimension=request.dimension, ) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return obj.model_dump(mode="json") @router.get("/{eval_id}/sessions") async def list_sessions(eval_id: str, session: Session = Depends(get_db)) -> dict: try: sessions = lifecycle.list_sessions(session, eval_id) except IntelligentEvalNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"sessions": [s.model_dump(mode="json") for s in sessions]} def _get_owned_session(eval_id: str, session_id: str, session: Session): obj = lifecycle.get_session_by_id(session, session_id) if obj.eval_id != eval_id: raise IntelligentEvalNotFoundError(f"intelligent eval session {session_id} not found") return obj @router.post("/{eval_id}/sessions/{session_id}/messages") async def send_message( eval_id: str, session_id: str, request: SendMessageRequest, session: Session = Depends(get_db) ) -> dict: try: _get_owned_session(eval_id, session_id, session) return await lifecycle.conduct_turn(session, session_id=session_id, content=request.content) except ( IntelligentEvalNotFoundError, IntelligentEvalTransitionError, IntelligentEvalChannelError, ) as exc: raise _translate(exc) from exc @router.post("/{eval_id}/sessions/{session_id}/close") async def close_session( eval_id: str, session_id: str, request: CloseSessionRequest, session: Session = Depends(get_db) ) -> dict: try: _get_owned_session(eval_id, session_id, session) obj = lifecycle.close_session(session, session_id=session_id, verdict=request.verdict) except (IntelligentEvalNotFoundError, IntelligentEvalTransitionError) as exc: raise _translate(exc) from exc return obj.model_dump(mode="json") @router.get("/{eval_id}/sessions/{session_id}/messages") async def list_messages(eval_id: str, session_id: str, session: Session = Depends(get_db)) -> dict: try: _get_owned_session(eval_id, session_id, session) messages = lifecycle.list_messages(session, session_id) except IntelligentEvalNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"messages": [m.model_dump(mode="json") for m in messages]}