AgentEvalTool/backend/agenteval/web/app.py
sinohqb 12481cd1b8 v0.3-s1: 规则层异步化 + 工具函数去重 + HTTP 通道
## 核心变更

### 规则层全面异步化(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>
2026-07-17 10:52:32 +08:00

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)