## 新增功能 - 文件管理模块:分类树 + 文件上传/下载/删除 - 文件上传支持拖拽(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>
191 lines
6.6 KiB
Python
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)
|