AgentEvalTool/backend/agenteval/web/app.py
sinohqb a351f65550 feat(exploration): session lifecycle endpoints with platform hard guardrails
v0.9 ticket 01. Independent exploration_sessions/exploration_messages
entities (never merged into EvalRun, keeping ADR-0001/0002 semantics
intact): create/message/close APIs forward virtual-user messages through
the target's real channel, persist both parties' rows with latency, and
close with a whitelist-normalized experience record. Budget enforcement
is a platform ledger — sessions per window, turns per session, and
session interval overruns return 409 with readable reasons; accelerated
lines accept manual sessions only. Messages delivered but unanswered
still consume a turn so timeouts cannot bypass the budget.
2026-08-03 17:35:17 +08:00

149 lines
5.3 KiB
Python

"""FastAPI web backend for AgentEvalTool."""
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.storage.repository import RunRepository
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,
model_configs,
proxy,
reports,
runs,
scenarios,
stats,
targets,
)
from agenteval.web.websocket import ws_manager
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
# 评测任务是进程内 asyncio 任务,重启后不会恢复——清理僵尸运行(尽力而为,不阻断启动)
try:
session = get_session()
try:
count = RunRepository(session).mark_orphans_failed()
if count:
logging.getLogger("agenteval").warning("启动清理:%d 个中断的运行已标记为 failed", count)
# 据库恢复所有未完成的评估活动,重建其调度循环(不重复派生已到点条目)
from agenteval.evaluation.campaign_runner import resume_running_campaigns
resumed = resume_running_campaigns(session)
if resumed:
logging.getLogger("agenteval").warning("启动恢复:%d 个进行中的评估活动已续跑", resumed)
finally:
session.close()
except Exception as exc:
logging.getLogger("agenteval").warning("启动清理失败(忽略): %s", exc)
yield
# 优雅停止所有进程内任务:先停活动调度循环,再停在跑的评测运行
try:
from agenteval.evaluation.campaign_runner import shutdown_all
from agenteval.web.routers.runs import run_registry
await shutdown_all()
await run_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(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)