"""FastAPI web backend for AgentEvalTool.""" import asyncio import logging from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from agenteval.config import get_settings from agenteval.storage.db import get_session, init_db from agenteval.version import get_build_info, get_version from agenteval.web.deps import require_api_key from agenteval.web.routers import ( auth, campaigns, exploration, files, intelligent_evals, model_configs, openclaw_cron_pool, proxy, reports, runs, scenarios, stats, targets, ) from agenteval.web.websocket import ws_manager async def _intelligent_eval_scan_loop() -> None: """Scan executing intelligent evals and enqueue worker tasks every minute. v1.1.0 缺陷修复:`scan_and_enqueue_tasks` 此前没有调度点,OpenClaw Worker 每分钟唤醒却永远取不到任务。平台启动后每 60s 扫描一次 executing 的评估, 与 Worker 的每分钟唤醒对齐。失败不阻断(下次循环继续)。 """ _logger = logging.getLogger("agenteval") while True: try: session = get_session() try: from agenteval.intelligent_eval.task_queue import scan_and_enqueue_tasks n = scan_and_enqueue_tasks(session) if n: _logger.info("智能评估扫描:入队 %d 个 Worker 任务", n) finally: session.close() except Exception as exc: logging.getLogger("agenteval").warning("智能评估扫描失败(忽略): %s", exc) await asyncio.sleep(60) @asynccontextmanager async def lifespan(_: FastAPI): init_db() scan_task = asyncio.create_task(_intelligent_eval_scan_loop()) # 恢复耐久 Campaign/智能作业,并清理无法安全重放的中断运行(尽力而为,不阻断启动)。 try: session = get_session() try: from agenteval.evaluation.campaign_runner import campaign_runtime from agenteval.evaluation.intelligence_jobs import recover_campaign_intelligence_jobs interrupted_jobs, resumed_jobs = recover_campaign_intelligence_jobs(session) recovery = campaign_runtime.recover() if recovery.interrupted_runs: logging.getLogger("agenteval").warning( "启动清理:%d 个中断的运行已标记为 failed", recovery.interrupted_runs ) if interrupted_jobs: logging.getLogger("agenteval").warning( "启动清理:%d 条中断的分析/对比已标记为 failed", interrupted_jobs, ) if recovery.resumed_campaigns: logging.getLogger("agenteval").warning( "启动恢复:%d 个进行中的评估活动已续跑", recovery.resumed_campaigns ) if resumed_jobs: logging.getLogger("agenteval").warning( "启动恢复:%d 条排队中的活动分析/对比已续跑", resumed_jobs, ) finally: session.close() except Exception as exc: logging.getLogger("agenteval").warning("启动清理失败(忽略): %s", exc) yield # 优雅停止后台扫描,再停其他进程内任务。 scan_task.cancel() try: await scan_task except asyncio.CancelledError: pass # 优雅停止所有进程内任务:先停活动调度循环,再停在跑的评测运行, # 最后停活动智能作业(分析 / 周期对比)和 judge 复核。 try: from agenteval.evaluation.campaign_runner import campaign_runtime from agenteval.evaluation.intelligence_jobs import shutdown_campaign_intelligence_jobs from agenteval.exploration.judge import judge_registry from agenteval.web.routers.runs import run_registry await campaign_runtime.shutdown() await run_registry.shutdown_all() await shutdown_campaign_intelligence_jobs() await judge_registry.shutdown_all() except Exception as exc: logging.getLogger("agenteval").warning("活动调度停止失败(忽略): %s", exc) app = FastAPI( title="AgentEvalTool", description="智能体质量评估工具集平台 Web API", version=get_version(), lifespan=lifespan, ) settings = get_settings() app.add_middleware( CORSMiddleware, allow_origins=settings.allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) _api_deps = [Depends(require_api_key)] # Login endpoints must stay open — they are how the client obtains credentials. app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(targets.router, prefix="/api/targets", tags=["targets"], dependencies=_api_deps) app.include_router(scenarios.router, prefix="/api/scenarios", tags=["scenarios"], dependencies=_api_deps) app.include_router(runs.router, prefix="/api/runs", tags=["runs"], dependencies=_api_deps) app.include_router(campaigns.router, prefix="/api/campaigns", tags=["campaigns"], dependencies=_api_deps) app.include_router(exploration.router, prefix="/api/exploration", tags=["exploration"], dependencies=_api_deps) app.include_router( intelligent_evals.router, prefix="/api/intelligent-evals", tags=["intelligent-evals"], dependencies=_api_deps ) app.include_router( openclaw_cron_pool.router, prefix="/api/openclaw", tags=["openclaw-cron-pool"], dependencies=_api_deps ) app.include_router(reports.router, prefix="/api/reports", tags=["reports"], dependencies=_api_deps) app.include_router(stats.router, prefix="/api/stats", tags=["stats"], dependencies=_api_deps) app.include_router(files.router, prefix="/api/files", tags=["files"], dependencies=_api_deps) app.include_router( model_configs.router, prefix="/api/model-configs", tags=["model-configs"], dependencies=_api_deps, ) @app.websocket("/openclaw") async def openclaw_ws_root(websocket: WebSocket): url = proxy.get_ws_upstream() if websocket.query_params: url += f"?{websocket.query_params}" await proxy.ws_bridge(websocket, url) @app.websocket("/openclaw/{path:path}") async def openclaw_ws_proxy(websocket: WebSocket, path: str): url = f"{proxy.get_ws_upstream()}/{path}" if websocket.query_params: url += f"?{websocket.query_params}" await proxy.ws_bridge(websocket, url) app.include_router(proxy.router, prefix="/openclaw", tags=["proxy"]) _default_dist = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "web" / "dist" WEB_DIST = Path(settings.frontend_dist_path) if settings.frontend_dist_path else _default_dist if WEB_DIST.exists(): from fastapi.staticfiles import StaticFiles app.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") @app.get("/api/health", tags=["health"]) def health() -> dict: return {"status": "ok", **get_build_info()} @app.websocket("/ws/runs/{run_id}") async def websocket_run_progress(websocket: WebSocket, run_id: str): await ws_manager.connect(run_id, websocket) try: while True: await websocket.receive_text() except WebSocketDisconnect: ws_manager.disconnect(run_id, websocket) @app.get("/{full_path:path}") def serve_spa(full_path: str): """Serve the React SPA for all non-API routes.""" index_file = WEB_DIST / "index.html" if index_file.exists(): return FileResponse(index_file) return JSONResponse({"detail": "frontend not built"}, status_code=404)