架构重构(候选 1-6): - storage/repository.py 按域拆分为包(target/scenario/run/campaign/result) - storage/db.py 按域拆分为包(eval/campaign/file/model_config/intelligent_eval) - intelligent_eval/lifecycle.py 按状态机阶段拆分为包 - services/runs.py 编排逻辑下沉 - Campaigns.tsx 拆分为 campaigns/ 子组件 测试补全(候选 7): 前端(+125 用例,107→232): - utils/ 纯函数:date/campaignTime/ruleLabels/fileTree/fileFormat/colors - stores/tabStore 状态管理 - 核心组件:FormDrawer/PageWrapper/ChatBubble/GeneratedMessages/SectionHeader/StatCard/TurnList - 业务组件:CaseBlock/CaseDetail/RuleOverview/WindowTimeline/RunList/TabBar/CampaignRunTimeline - 文件管理:FileCategoryTree/FileTable - hooks:sessionReducer/useFiles/useRunSession 后端(+38 用例,916→954): - targets API CRUD + 404 路径 - WebSocket 连接管理器 - proxy 头部重写(CSP/X-Frame-Options) - target 仓储 update 方法 - app 健康检查 + SPA 404 - scenarios 模板端点 + 404 - files API 边缘分支(404 场景 + 500 兜底) - files service update_category - 智能评估状态机迁移测试 门禁状态: - 前端:tsc 干净 + 232 passed - 后端:954 passed + ruff 全绿
175 lines
6.5 KiB
Python
175 lines
6.5 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.intelligent_eval.scheduler import scheduler_runtime
|
|
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,
|
|
proxy,
|
|
reports,
|
|
runs,
|
|
scenarios,
|
|
stats,
|
|
targets,
|
|
)
|
|
from agenteval.web.websocket import ws_manager
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
init_db()
|
|
scheduler_runtime.start()
|
|
# 恢复耐久 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
|
|
# 优雅停止智能评估扫描调度,再停其他进程内任务。
|
|
await scheduler_runtime.stop()
|
|
# 优雅停止所有进程内任务:先停活动调度循环,再停在跑的评测运行,
|
|
# 最后停活动智能作业(分析 / 周期对比)和 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.services.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(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)
|