"""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.read_model import IntelligentEvalReadModel from agenteval.intelligent_eval.report import ReportModel, render_report_markdown 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: """Serialize one stable intelligent-evaluation read projection.""" projection = IntelligentEvalReadModel(session).list_item(ev) return projection.model_dump(mode="json") @router.post("") async def create_eval(request: CreateEvalRequest, session: Session = Depends(get_db)) -> dict: try: 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, ) except IntelligentEvalNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _eval_response(ev, session) @router.get("") async def list_evals(session: Session = Depends(get_db)) -> dict: evals = lifecycle.list_evals(session) reader = IntelligentEvalReadModel(session) return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]} @router.get("/{eval_id}") async def get_eval(eval_id: str, session: Session = Depends(get_db)) -> dict: projection = IntelligentEvalReadModel(session).detail_by_id(eval_id) if projection is None: raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") return projection.model_dump(mode="json") @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: report = IntelligentEvalReadModel(session).report_by_eval(eval_id) if report is None: raise HTTPException(status_code=404, detail="report not submitted yet") return report[1] @router.get("/{eval_id}/report/markdown", response_class=PlainTextResponse) async def get_report_markdown(eval_id: str, session: Session = Depends(get_db)) -> PlainTextResponse: report = IntelligentEvalReadModel(session).report_by_eval(eval_id) if report is None: raise HTTPException(status_code=404, detail="report not submitted yet") name, payload = report markdown = render_report_markdown(payload, name=name, eval_id=eval_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: sessions = IntelligentEvalReadModel(session).sessions_by_eval(eval_id) if sessions is None: raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") return {"sessions": [s.model_dump(mode="json") for s in sessions]} @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: return await lifecycle.conduct_turn( session, eval_id=eval_id, 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: obj = lifecycle.close_session( session, eval_id=eval_id, 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: messages = IntelligentEvalReadModel(session).messages_by_session(eval_id, session_id) if messages is None: raise HTTPException(status_code=404, detail=f"intelligent eval session {session_id} not found") return {"messages": [m.model_dump(mode="json") for m in messages]}