"""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]} @router.get("/tasks/next") async def get_next_task(session: Session = Depends(get_db)) -> dict: """Get next pending task for OpenClaw workers. Returns the highest-priority pending task, or None if no tasks available. """ from agenteval.intelligent_eval import task_queue task = task_queue.get_next_task(session) if task is None: return {"task": None} # Load eval details from agenteval.storage.db import IntelligentEvalDB eval_db = session.get(IntelligentEvalDB, task.eval_id) if eval_db is None: return {"task": None} return { "task": { "id": task.id, "eval_id": task.eval_id, "priority": task.priority, "reason": task.reason, "eval": { "id": eval_db.id, "name": eval_db.name, "status": eval_db.status, "plan": eval_db.get_plan(), "started_at": eval_db.started_at.isoformat() if eval_db.started_at else None, }, } } @router.post("/tasks/{task_id}/assign") async def assign_task(task_id: str, cron_id: str, session: Session = Depends(get_db)) -> dict: """Assign a task to a cron worker.""" from agenteval.intelligent_eval import task_queue success = task_queue.assign_task(task_id, cron_id, session) if not success: raise HTTPException(status_code=404, detail="task not found or already assigned") return {"success": True} @router.post("/tasks/{task_id}/complete") async def complete_task( task_id: str, success: bool, error: str | None = None, session: Session = Depends(get_db), ) -> dict: """Mark a task as completed or failed.""" from agenteval.intelligent_eval import task_queue completed = task_queue.complete_task(task_id, success, error, session) if not completed: raise HTTPException(status_code=404, detail="task not found") return {"success": True} class DecisionLogRequest(BaseModel): decision_type: str = Field(min_length=1) # execute_session / wait / start_analysis reason: str = Field(min_length=1) context: dict[str, Any] = Field(default_factory=dict) cron_id: str = Field(min_length=1) @router.post("/{eval_id}/decision-logs") async def create_decision_log( eval_id: str, request: DecisionLogRequest, session: Session = Depends(get_db), ) -> dict: """Create a decision log entry for an intelligent eval.""" from agenteval.storage.db import IntelligentEvalDecisionLogDB # Verify eval exists from agenteval.storage.db import IntelligentEvalDB eval_db = session.get(IntelligentEvalDB, eval_id) if eval_db is None: raise HTTPException(status_code=404, detail=f"intelligent eval {eval_id} not found") # Create decision log log = IntelligentEvalDecisionLogDB( eval_id=eval_id, decision_type=request.decision_type, reason=request.reason, cron_id=request.cron_id, ) log.set_context(request.context) session.add(log) session.commit() session.refresh(log) return { "id": log.id, "eval_id": log.eval_id, "decision_type": log.decision_type, "reason": log.reason, "context": log.get_context(), "cron_id": log.cron_id, "created_at": log.created_at.isoformat() if log.created_at else None, }