AgentEvalTool/backend/agenteval/web/routers/runs.py
sinohqb a77cd83e6a v0.2.0-dev: 文件管理 + 页面布局统一 + 6 个 bug 修复
## 新增功能
- 文件管理模块:分类树 + 文件上传/下载/删除
- 文件上传支持拖拽(Dragger)+ 手动上传(customRequest 模式)

## 页面布局统一(参照评测执行页)
- 仪表盘/评测对象/评测场景/评测报告 全部改为全高 flex 布局
- 统一内联页头样式(h2 + 竖线分隔 + 描述)
- 表格撑满高度、overflow 处理
- 每页添加刷新按钮

## Bug 修复
- 分类树操作按钮 hover 不可见(CSS 规则缺失)
- 文件上传失败(multipart boundary 缺失)
- LLM API 响应 content blocks 数组格式支持(_extract_content_from_api_response)
- response_time_max_ms 被静默忽略(隐式规则传空 params)
- 空 messages 导致 IndexError 崩溃
- poll_reply 异常中止整个 run(缺 try/catch)
- engine finally 未关闭 session
- 3 个页面 UTC 时间戳解析偏差 8 小时

## 后端
- EvalEngine: poll_reply 异常保护、空 dialog 保护、session 关闭
- LLM API 响应解析支持 content-block-array 格式
- 隐式 response_time 规则正确传递 max_ms 参数

## 前端
- api.ts: 移除手动 Content-Type(让浏览器自动添加 boundary)
- Files.tsx: customRequest 替代 beforeUpload、布局优化
- index.css: 分类树 hover 规则
- Targets/Scenarios/Home/Reports: 全高布局改造
- 3 个页面时间戳改用 formatDateTime()(修复 UTC 偏差)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 15:25:22 +08:00

191 lines
6.6 KiB
Python

"""API routes for evaluation runs."""
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session
from agenteval.evaluation.engine import EvalEngine
from agenteval.models import EvalRun, RunStatus
from agenteval.storage.db import get_session
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from agenteval.web.deps import get_db
from agenteval.web.websocket import ws_manager
router = APIRouter()
class StartRunRequest(BaseModel):
target_id: str
scenario_id: str
# ── Task registry for live evaluation runs ─────────────────────────────
# Each running evaluation is an asyncio.Task keyed by run_id. The cancel
# token is a cooperative ``asyncio.Event`` the engine checks between cases.
_tasks: dict[str, asyncio.Task] = {}
_cancel_tokens: dict[str, asyncio.Event] = {}
async def _run_evaluation(run_id: str, target_id: str, scenario_id: str) -> None:
"""Background coroutine that drives one evaluation run to completion."""
session = get_session()
cancel_token = asyncio.Event()
_cancel_tokens[run_id] = cancel_token
try:
target = TargetRepository(session).get(target_id)
scenario = ScenarioRepository(session).get(scenario_id)
existing_run = RunRepository(session).get(run_id)
if not target or not scenario:
return
engine = EvalEngine(
target=target, scenario=scenario, session=session,
cancel_token=cancel_token,
)
await engine.run(
progress_callback=lambda event, data: ws_manager.emit(run_id, event, data),
existing_run=existing_run,
)
finally:
session.close()
_cancel_tokens.pop(run_id, None)
_tasks.pop(run_id, None)
@router.get("")
async def list_runs(session: Session = Depends(get_db)) -> list[dict]:
return [r.model_dump() for r in RunRepository(session).list_all()]
@router.post("")
async def start_run(
request: StartRunRequest,
session: Session = Depends(get_db),
) -> dict:
target = TargetRepository(session).get(request.target_id)
scenario = ScenarioRepository(session).get(request.scenario_id)
if not target or not scenario:
raise HTTPException(status_code=404, detail="target or scenario not found")
run = EvalRun(target_id=request.target_id, scenario_id=request.scenario_id)
run = RunRepository(session).create(run)
task = asyncio.create_task(
_run_evaluation(run.id, request.target_id, request.scenario_id),
name=f"eval-run-{run.id}",
)
_tasks[run.id] = task
return run.model_dump()
@router.get("/{run_id}")
async def get_run(run_id: str, session: Session = Depends(get_db)) -> dict:
run = RunRepository(session).get(run_id)
if not run:
raise HTTPException(status_code=404, detail="run not found")
return run.model_dump()
@router.post("/{run_id}/cancel")
async def cancel_run(run_id: str, session: Session = Depends(get_db)) -> dict:
repo = RunRepository(session)
run = repo.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="run not found")
if run.status not in (RunStatus.PENDING, RunStatus.RUNNING):
raise HTTPException(status_code=400, detail="run is not in a cancellable state")
cancel_token = _cancel_tokens.get(run_id)
task: Optional[asyncio.Task] = _tasks.get(run_id)
if cancel_token is not None:
# Cooperative cancel: the engine will catch CancelledError and mark
# the run as FAILED with code=cancelled_by_user.
cancel_token.set()
elif task is not None:
# Fallback: hard-cancel the task if no token exists (shouldn't happen).
task.cancel()
else:
# No live task (e.g. process restarted): mark the DB row directly.
run.status = RunStatus.FAILED
run.summary = {
"error": {"code": "cancelled_by_user", "message": "评测已手动停止"},
}
repo.update(run)
return run.model_dump()
@router.get("/{run_id}/logs")
async def get_run_logs(run_id: str, session: Session = Depends(get_db)) -> dict:
repo = RunRepository(session)
run = repo.get(run_id)
if not run:
raise HTTPException(status_code=404, detail="run not found")
turns = repo.get_turns(run_id)
results = repo.get_results(run_id)
turns_data = [
{
"id": t.id,
"case_id": t.case_id,
"round_index": t.round_index,
"latency_ms": t.latency_ms,
"sent_text": t.get_sent_message().get("msgBody", {}).get("content", ""),
"reply_text": _extract_reply_text(t.get_reply()),
"sent_at": t.sent_at.isoformat() if t.sent_at else None,
"received_at": t.received_at.isoformat() if t.received_at else None,
}
for t in turns
]
results_data = [
{
"case_id": r.case_id,
"rule_type": r.rule_type,
"passed": r.passed,
"score": r.score,
"reason": r.reason,
}
for r in results
]
scenario_snapshot: dict = {}
scenario = ScenarioRepository(session).get(run.scenario_id)
if scenario:
for case in scenario.cases:
scenario_snapshot[case.id] = {
"id": case.id,
"type": case.type.value if hasattr(case.type, "value") else str(case.type),
"messages": list(case.messages),
"prompt": case.prompt,
"turns": case.turns,
"expectations": {
"intent": case.expectations.intent,
"keywords_include": list(case.expectations.keywords_include),
"keywords_exclude": list(case.expectations.keywords_exclude),
"response_time_max_ms": case.expectations.response_time_max_ms,
"coherence_min_score": case.expectations.coherence_min_score,
},
"eval_rules": [
{"type": r.type, "params": dict(r.params)} for r in case.eval_rules
],
}
return {"turns": turns_data, "results": results_data, "scenario_snapshot": scenario_snapshot}
def _extract_reply_text(reply) -> str:
if reply is None:
return ""
if isinstance(reply, str):
return reply
if isinstance(reply, dict):
body = reply.get("msgBody") or reply.get("content", "")
if isinstance(body, dict):
return body.get("content", "")
return str(body)
return str(reply)