## 核心变更
### 规则层全面异步化(DEBT-1)
- EvalRule.evaluate() 签名改为 async def,全量同步改造(无兼容层)
- LlmScoreRule._call_llm: requests.post → httpx.AsyncClient,彻底消除事件循环阻塞
- engine._save_rule_results: rule.evaluate() → await rule.evaluate()
### 工具函数去重(DEBT-2)
- 新建 agenteval/utils/llm.py,统一三个函数:
- extract_reply_text (原 5 处重复)
- extract_content_from_llm_response (原 2 处重复)
- parse_json_from_llm_text (统一 LLM 输出 JSON 解析)
- engine.py / llm_score.py / runs.py / report.py 全部切换到 utils.llm
### HTTP 通用通道(S1-3)
- 新建 channels/http.py (HttpChannel)
- 配置化 send_url / reply_url 模板 ({message}, {msg_id} 占位)
- dot-path 提取 msg_id 和 reply_text
- 可选 reply_ready_path 就绪标志
- 长连接 AsyncClient 复用
- ChannelFactory 注册 ChannelType.HTTP → HttpChannel
### 测试
- 新增 tests/unit/test_http_channel_and_rules.py (19 个测试)
- _get_path / health_check / send / poll_reply / 超时 / 就绪标志 / async 规则评估
- 测试总数:24 → 43,全部通过
Co-Authored-By: Claude <noreply@anthropic.com>
99 lines
3.2 KiB
Python
99 lines
3.2 KiB
Python
"""FastAPI web backend for AgentEvalTool."""
|
|
|
|
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 init_db
|
|
from agenteval.version import get_build_info, get_version
|
|
from agenteval.web.deps import require_api_key
|
|
from agenteval.web.routers import files, proxy, reports, runs, scenarios, stats, targets
|
|
from agenteval.web.websocket import ws_manager
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
init_db()
|
|
yield
|
|
|
|
|
|
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)]
|
|
|
|
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(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.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)
|