feat(backend): v0.4 triggered_by tracking, login gate, compare guard, dashboard stats

- EvalRun.triggered_by 全链路(manual/ai_assistant/cli)+ 迁移 b7d4e6f81c22
- 标准 agenteval-run SKILL.md 纳入版本管理,deploy 脚本同步 + API Key 注入
- 简单登录:AGENTEVAL_ADMIN_PASSWORD + HMAC 会话 token,require_auth 双凭据
- 对比报告限同场景(400)+ 空 results 误判修复
- /api/stats/dashboard 扩展聚合;/api/runs 返回场景/对象名
- 测试 218 → 232
This commit is contained in:
sinohqb 2026-07-28 17:40:54 +08:00
parent 92f98c3af7
commit 739d586aec
25 changed files with 625 additions and 81 deletions

View File

@ -6,6 +6,10 @@
# In production, set a strong random token; clients must send it as X-API-Key. # In production, set a strong random token; clients must send it as X-API-Key.
AGENTEVAL_API_KEY= AGENTEVAL_API_KEY=
# If set, the web UI requires login with this password (session-level, stored
# in sessionStorage). Leave empty to disable the login gate (dev default).
AGENTEVAL_ADMIN_PASSWORD=
# Fernet key used to encrypt model API keys stored in SQLite. # Fernet key used to encrypt model API keys stored in SQLite.
# Generate once with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Generate once with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Keep this value backed up with the database. Do not commit a real key. # Keep this value backed up with the database. Do not commit a real key.

View File

@ -33,6 +33,13 @@ class Settings(BaseSettings):
default=None, default=None,
description="If set, all /api/* endpoints require the X-API-Key header to match.", description="If set, all /api/* endpoints require the X-API-Key header to match.",
) )
admin_password: Optional[str] = Field(
default=None,
description=(
"If set, the web UI requires login with this password. "
"Leave empty to disable the login gate (dev default)."
),
)
secret_key: Optional[str] = Field( secret_key: Optional[str] = Field(
default=None, default=None,
description="Fernet key used to encrypt model provider API keys at rest.", description="Fernet key used to encrypt model provider API keys at rest.",

View File

@ -24,6 +24,7 @@ from agenteval.models import (
ModelPurpose, ModelPurpose,
RuleLogic, RuleLogic,
RunStatus, RunStatus,
RunTrigger,
Scenario, Scenario,
Turn, Turn,
) )
@ -75,6 +76,7 @@ class EvalEngine:
cancel_token: Optional[asyncio.Event] = None, cancel_token: Optional[asyncio.Event] = None,
timeout_config: Optional[TimeoutConfig] = None, timeout_config: Optional[TimeoutConfig] = None,
max_concurrent_cases: int = 1, max_concurrent_cases: int = 1,
triggered_by: RunTrigger = RunTrigger.MANUAL,
): ):
self.target = target self.target = target
self.scenario = scenario self.scenario = scenario
@ -84,6 +86,7 @@ class EvalEngine:
self.result_repo = result_repo or ResultRepository(self.session) self.result_repo = result_repo or ResultRepository(self.session)
self.cancel_token = cancel_token or asyncio.Event() self.cancel_token = cancel_token or asyncio.Event()
self.timeout_config = timeout_config or TimeoutConfig() self.timeout_config = timeout_config or TimeoutConfig()
self.triggered_by = triggered_by
self._case_semaphore = asyncio.Semaphore(max(1, max_concurrent_cases)) self._case_semaphore = asyncio.Semaphore(max(1, max_concurrent_cases))
# Collects fatal case-level errors (e.g. dynamic message generation # Collects fatal case-level errors (e.g. dynamic message generation
# failures) so their cause is persisted into run.summary — not just # failures) so their cause is persisted into run.summary — not just
@ -116,6 +119,7 @@ class EvalEngine:
target_id=self.target.id or "", target_id=self.target.id or "",
scenario_id=self.scenario.id or "", scenario_id=self.scenario.id or "",
status=RunStatus.RUNNING, status=RunStatus.RUNNING,
triggered_by=self.triggered_by,
started_at=utc_now(), started_at=utc_now(),
) )
run = self.run_repo.create(run) run = self.run_repo.create(run)

View File

@ -169,10 +169,15 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[str, Any]: def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[str, Any]:
"""Build a side-by-side comparison dict for two runs.""" """Build a side-by-side comparison dict for two runs of the same scenario."""
report_a = generate_report(run_id_1, session) report_a = generate_report(run_id_1, session)
report_b = generate_report(run_id_2, session) report_b = generate_report(run_id_2, session)
# Cross-scenario case_ids never overlap, so every case would be flagged
# "changed" and the diff would be meaningless — reject early.
if report_a.get("scenario_id") != report_b.get("scenario_id"):
raise ValueError("compare report requires both runs to use the same scenario")
def _summary_delta(key: str) -> float: def _summary_delta(key: str) -> float:
return report_b["summary"][key] - report_a["summary"][key] return report_b["summary"][key] - report_a["summary"][key]
@ -189,7 +194,11 @@ def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[
def _case_passed(c): def _case_passed(c):
if not c: if not c:
return None return None
return all(r["passed"] for r in c.get("results", [])) results = c.get("results", [])
if not results:
# No rule results (e.g. errored case) must not count as passed.
return None
return all(r["passed"] for r in results)
case_diffs.append( case_diffs.append(
{ {

View File

@ -148,6 +148,12 @@ class RunStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
class RunTrigger(str, Enum):
MANUAL = "manual"
AI_ASSISTANT = "ai_assistant"
CLI = "cli"
class EvalRun(BaseModel): class EvalRun(BaseModel):
"""A single evaluation run.""" """A single evaluation run."""
@ -155,6 +161,7 @@ class EvalRun(BaseModel):
target_id: str target_id: str
scenario_id: str scenario_id: str
status: RunStatus = RunStatus.PENDING status: RunStatus = RunStatus.PENDING
triggered_by: RunTrigger = RunTrigger.MANUAL
started_at: Optional[datetime] = None started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
summary: Optional[dict[str, Any]] = None summary: Optional[dict[str, Any]] = None

View File

@ -168,6 +168,7 @@ class EvalRunDB(SQLModel, table=True):
target_id: Optional[str] = Field(default=None, foreign_key="eval_targets.id") target_id: Optional[str] = Field(default=None, foreign_key="eval_targets.id")
scenario_id: Optional[str] = Field(default=None, foreign_key="scenarios.id") scenario_id: Optional[str] = Field(default=None, foreign_key="scenarios.id")
status: str = "pending" status: str = "pending"
triggered_by: str = Field(default="manual")
started_at: Optional[datetime] = Field(default_factory=utc_now) started_at: Optional[datetime] = Field(default_factory=utc_now)
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
summary: Optional[str] = None summary: Optional[str] = None

View File

@ -82,6 +82,7 @@ def _run_to_db(run: EvalRun) -> EvalRunDB:
target_id=run.target_id, target_id=run.target_id,
scenario_id=run.scenario_id, scenario_id=run.scenario_id,
status=run.status.value, status=run.status.value,
triggered_by=run.triggered_by.value,
started_at=run.started_at, started_at=run.started_at,
completed_at=run.completed_at, completed_at=run.completed_at,
) )
@ -96,6 +97,7 @@ def _run_from_db(db: EvalRunDB) -> EvalRun:
target_id=db.target_id, target_id=db.target_id,
scenario_id=db.scenario_id, scenario_id=db.scenario_id,
status=db.status, status=db.status,
triggered_by=db.triggered_by or "manual",
started_at=db.started_at, started_at=db.started_at,
completed_at=db.completed_at, completed_at=db.completed_at,
summary=db.get_summary(), summary=db.get_summary(),

View File

@ -11,7 +11,7 @@ from agenteval.config import get_settings
from agenteval.storage.db import init_db from agenteval.storage.db import init_db
from agenteval.version import get_build_info, get_version from agenteval.version import get_build_info, get_version
from agenteval.web.deps import require_api_key from agenteval.web.deps import require_api_key
from agenteval.web.routers import files, model_configs, proxy, reports, runs, scenarios, stats, targets from agenteval.web.routers import auth, files, model_configs, proxy, reports, runs, scenarios, stats, targets
from agenteval.web.websocket import ws_manager from agenteval.web.websocket import ws_manager
@ -40,6 +40,8 @@ app.add_middleware(
_api_deps = [Depends(require_api_key)] _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(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(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(runs.router, prefix="/api/runs", tags=["runs"], dependencies=_api_deps)

View File

@ -21,21 +21,37 @@ def get_db():
session.close() session.close()
def require_api_key(x_api_key: Optional[str] = Header(default=None)) -> None: def require_auth(
"""Enforce X-API-Key header when AGENTEVAL_API_KEY is configured. x_api_key: Optional[str] = Header(default=None),
x_auth_token: Optional[str] = Header(default=None),
) -> None:
"""Enforce authentication when any credential is configured.
When the setting is empty (default), the check is a no-op so local dev keeps Accepted credentials (either one passes):
working without extra configuration. - X-API-Key matching AGENTEVAL_API_KEY (machine callers, OpenClaw skill)
- X-Auth-Token matching the web session token (browser after login)
When neither AGENTEVAL_API_KEY nor AGENTEVAL_ADMIN_PASSWORD is set, the
check is a no-op so local dev keeps working without extra configuration.
""" """
configured = get_settings().api_key settings = get_settings()
if not configured: if not settings.api_key and not settings.admin_password:
return
if settings.api_key and x_api_key == settings.api_key:
return
if settings.admin_password:
from agenteval.web.routers.auth import verify_session_token
if x_auth_token and verify_session_token(x_auth_token):
return return
if not x_api_key or x_api_key != configured:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing X-API-Key", detail="Invalid or missing credentials",
) )
# Backwards-compatible alias: existing routers depend on require_api_key.
require_api_key = require_auth
# Convenience alias used by routers via ``dependencies=[Depends(auth_required)]``. # Convenience alias used by routers via ``dependencies=[Depends(auth_required)]``.
auth_required = Depends(require_api_key) auth_required = Depends(require_auth)

View File

@ -0,0 +1,52 @@
"""Session login for the web UI.
A single shared password (AGENTEVAL_ADMIN_PASSWORD) gates the SPA. On success
the client receives a stateless HMAC token it sends as X-Auth-Token. Machine
callers (e.g. the OpenClaw skill) keep using X-API-Key and are unaffected.
"""
import hmac
import secrets
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel
from agenteval.config import get_settings
router = APIRouter()
_TOKEN_CONTEXT = b"agenteval-web-session"
def session_token() -> str:
"""Derive the stateless session token from configured secrets."""
settings = get_settings()
key = (settings.secret_key or settings.admin_password or "").encode()
return hmac.new(key, _TOKEN_CONTEXT, "sha256").hexdigest()
def verify_session_token(token: str) -> bool:
return bool(token) and hmac.compare_digest(token, session_token())
class LoginRequest(BaseModel):
password: str
@router.post("/login")
def login(request: LoginRequest) -> dict:
configured = get_settings().admin_password
if not configured:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="登录未启用")
if not secrets.compare_digest(request.password, configured):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
return {"token": session_token()}
@router.get("/status")
def auth_status(x_auth_token: str = Header(default="")) -> dict:
auth_required = bool(get_settings().admin_password)
return {
"auth_required": auth_required,
"authenticated": (not auth_required) or verify_session_token(x_auth_token),
}

View File

@ -23,10 +23,14 @@ def get_compare_report(
session: Session = Depends(get_db), session: Session = Depends(get_db),
) -> dict: ) -> dict:
repo = RunRepository(session) repo = RunRepository(session)
if not repo.get(run1): run_a = repo.get(run1)
run_b = repo.get(run2)
if not run_a:
raise HTTPException(status_code=404, detail=f"run not found: {run1}") raise HTTPException(status_code=404, detail=f"run not found: {run1}")
if not repo.get(run2): if not run_b:
raise HTTPException(status_code=404, detail=f"run not found: {run2}") raise HTTPException(status_code=404, detail=f"run not found: {run2}")
if run_a.scenario_id != run_b.scenario_id:
raise HTTPException(status_code=400, detail="对比报告要求两个运行使用相同场景")
return generate_compare_report(run1, run2, session) return generate_compare_report(run1, run2, session)

View File

@ -8,7 +8,7 @@ from pydantic import BaseModel
from sqlmodel import Session from sqlmodel import Session
from agenteval.evaluation.engine import EvalEngine from agenteval.evaluation.engine import EvalEngine
from agenteval.models import EvalRun, RunStatus from agenteval.models import EvalRun, RunStatus, RunTrigger
from agenteval.storage.db import get_session, iso_utc from agenteval.storage.db import get_session, iso_utc
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from agenteval.utils.llm import extract_reply_text from agenteval.utils.llm import extract_reply_text
@ -22,6 +22,7 @@ router = APIRouter()
class StartRunRequest(BaseModel): class StartRunRequest(BaseModel):
target_id: str target_id: str
scenario_id: str scenario_id: str
triggered_by: RunTrigger = RunTrigger.MANUAL
# ── Task registry for live evaluation runs ───────────────────────────── # ── Task registry for live evaluation runs ─────────────────────────────
@ -69,7 +70,16 @@ async def _run_evaluation(run_id: str, target_id: str, scenario_id: str) -> None
@router.get("") @router.get("")
async def list_runs(session: Session = Depends(get_db)) -> list[dict]: async def list_runs(session: Session = Depends(get_db)) -> list[dict]:
return [r.model_dump() for r in RunRepository(session).list_all()] scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()}
target_names = {t.id: t.name for t in TargetRepository(session).list_all()}
return [
{
**r.model_dump(),
"scenario_name": scenario_names.get(r.scenario_id),
"target_name": target_names.get(r.target_id),
}
for r in RunRepository(session).list_all()
]
@router.post("") @router.post("")
@ -82,7 +92,11 @@ async def start_run(
if not target or not scenario: if not target or not scenario:
raise HTTPException(status_code=404, detail="target or scenario not found") raise HTTPException(status_code=404, detail="target or scenario not found")
run = EvalRun(target_id=request.target_id, scenario_id=request.scenario_id) run = EvalRun(
target_id=request.target_id,
scenario_id=request.scenario_id,
triggered_by=request.triggered_by,
)
run = RunRepository(session).create(run) run = RunRepository(session).create(run)
task = asyncio.create_task( task = asyncio.create_task(

View File

@ -1,34 +1,93 @@
"""API routes for statistics and dashboard data.""" """API routes for statistics and dashboard data."""
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlmodel import Session from sqlmodel import Session
from agenteval.storage.model_config_repository import ModelConfigRepository
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from agenteval.web.deps import get_db from agenteval.web.deps import get_db
router = APIRouter() router = APIRouter()
def _ts(dt: datetime | None) -> float:
"""Sortable timestamp tolerant of naive/aware mixes in legacy rows."""
if dt is None:
return 0.0
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
@router.get("/dashboard") @router.get("/dashboard")
def dashboard(session: Session = Depends(get_db)) -> dict: def dashboard(session: Session = Depends(get_db)) -> dict:
targets = TargetRepository(session).list_all() targets = TargetRepository(session).list_all()
scenarios = ScenarioRepository(session).list_all() scenarios = ScenarioRepository(session).list_all()
runs = RunRepository(session).list_all() runs = RunRepository(session).list_all()
model_configs = ModelConfigRepository(session).list_all()
scenario_names = {s.id: s.name for s in scenarios}
target_names = {t.id: t.name for t in targets}
completed_runs = [r for r in runs if r.status == "completed" and r.summary] completed_runs = [r for r in runs if r.status == "completed" and r.summary]
pass_rates = [r.summary.get("pass_rate", 0) for r in completed_runs if isinstance(r.summary, dict)] pass_rates = [r.summary.get("pass_rate", 0) for r in completed_runs if isinstance(r.summary, dict)]
overall_pass_rate = sum(pass_rates) / len(pass_rates) if pass_rates else None overall_pass_rate = sum(pass_rates) / len(pass_rates) if pass_rates else None
recent_runs = sorted(runs, key=lambda r: r.started_at or "", reverse=True)[:10] today = datetime.now(timezone.utc).date()
today_runs = 0
running_count = 0
trigger_breakdown: dict[str, int] = defaultdict(int)
for r in runs:
if r.started_at:
started = r.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
if started.date() == today:
today_runs += 1
if r.status in ("running", "pending"):
running_count += 1
trigger_breakdown[r.triggered_by.value] += 1
# Per-scenario aggregation over completed runs.
by_scenario: dict[str, list] = defaultdict(list)
for r in completed_runs:
by_scenario[r.scenario_id].append(r)
scenario_stats = []
for sid, sruns in by_scenario.items():
rates = [r.summary.get("pass_rate", 0) for r in sruns if isinstance(r.summary, dict)]
last_run = max(sruns, key=lambda r: _ts(r.started_at))
scenario_stats.append({
"scenario_id": sid,
"scenario_name": scenario_names.get(sid, sid[:8]),
"run_count": len(sruns),
"avg_pass_rate": round(sum(rates) / len(rates), 4) if rates else None,
"last_run_at": last_run.started_at.isoformat() if last_run.started_at else None,
})
scenario_stats.sort(key=lambda s: s["run_count"], reverse=True)
recent_runs = sorted(runs, key=lambda r: _ts(r.started_at), reverse=True)[:10]
return { return {
"targets_count": len(targets), "targets_count": len(targets),
"scenarios_count": len(scenarios), "scenarios_count": len(scenarios),
"runs_count": len(runs), "runs_count": len(runs),
"model_configs_count": len(model_configs),
"today_runs": today_runs,
"running_count": running_count,
"overall_pass_rate": overall_pass_rate, "overall_pass_rate": overall_pass_rate,
"recent_runs": [r.model_dump() for r in recent_runs], "trigger_breakdown": dict(trigger_breakdown),
"scenario_stats": scenario_stats,
"recent_runs": [
{
**r.model_dump(),
"scenario_name": scenario_names.get(r.scenario_id),
"target_name": target_names.get(r.target_id),
}
for r in recent_runs
],
} }

View File

@ -5,6 +5,7 @@ from typing import Any
import typer import typer
from agenteval.evaluation.engine import EvalEngine from agenteval.evaluation.engine import EvalEngine
from agenteval.models import RunTrigger
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
@ -44,7 +45,7 @@ def start_run(
raise typer.Exit(1) raise typer.Exit(1)
console.print(f"开始评测: 对象={target.name}, 场景={scenario.name}") console.print(f"开始评测: 对象={target.name}, 场景={scenario.name}")
engine = EvalEngine(target=target, scenario=scenario) engine = EvalEngine(target=target, scenario=scenario, triggered_by=RunTrigger.CLI)
try: try:
run = asyncio.run(engine.run(progress_callback=_progress)) run = asyncio.run(engine.run(progress_callback=_progress))
except Exception as exc: except Exception as exc:

View File

@ -1,57 +1,45 @@
# OpenClaw 插件 for AgentEvalTool # OpenClaw 插件 for AgentEvalTool
本目录提供 OpenClaw 调用 AgentEvalTool 的示例技能,使 OpenClaw 能够自闭环完成评测策略设计 本目录提供 OpenClaw 调用 AgentEvalTool 的标准接入方式,使 OpenClawAI 助手)能够自闭环完成评测
## 设计原则 ## 设计原则
- AgentEvalTool 作为独立的 CLI 工具集存在,负责: - AgentEvalTool 负责:
- 评测对象管理 - 评测对象管理
- 评测场景管理 - 评测场景管理
- 评测执行与原始数据收集 - 评测执行与原始数据收集
- 评估分析与报告生成 - 评估分析与报告生成
- OpenClaw 通过本插件调用 `agenteval` CLI负责: - OpenClaw 负责:
- 评测策略编排(选择对象、场景、触发时机) - 评测策略编排(选择对象、场景、触发时机)
- 定时调度OpenClaw 自身的 cron + skill 机制) - 定时调度OpenClaw 自身的 cron + skill 机制)
- 结果通知与后续动作 - 结果通知与后续动作
## 前置条件 ## 唯一标准接入方式HTTP API
1. 已安装 AgentEvalTool CLI **所有 OpenClaw 触发的评测必须走 AgentEvalTool 的 HTTP API**`POST /api/runs` 等),
```bash 并在启动评测时携带 `"triggered_by": "ai_assistant"`,以便平台区分手动触发与 AI 助手触发。
pip install -e /path/to/AgentEvalTool
```
2. `agenteval` 命令已在 PATH 中可用。
3. 已通过 `agenteval target add` 注册评测对象。
4. 已通过 `agenteval scenario import` 导入评测场景。
## 接入方式 > ⚠️ 禁止在 OpenClaw 中通过 subprocess 调用 `agenteval` CLI。
> CLI 直接写入其运行环境下的本地 SQLite路径硬编码与 Web 后台的数据库不共享,
> 会导致评测记录"消失"——Web 页面永远查不到。CLI 仅用于开发者在 agenteval
> 服务同一环境下的手工调试。
### 方式一:直接调用 CLI推荐 ### 方式一:agenteval-run 技能(推荐)
在 OpenClaw Skill 中直接调用系统命令: `skills/agenteval-run/SKILL.md` 是标准技能文件,指导 AI 助手用 `curl` 走 API 全流程
(列对象/场景 → 启动评测 → 轮询状态 → 取报告)。
```python 部署脚本(`scripts/deploy-t480.sh`)会自动把它同步到 OpenClaw 工作区:
import subprocess `data/openclaw/workspace/skills/agenteval-run/SKILL.md`
# 触发评测 ### 方式二Python Skill 封装
subprocess.run([
"agenteval", "run", "start",
"--target-id", "<target-id>",
"--scenario-id", "<scenario-id>",
], check=True)
# 获取报告(需要在运行输出中解析 run_id 参考 `agenteval_skill.py`httpx 调 API在 OpenClaw 中注册技能时传入配置:
report = subprocess.check_output([
"agenteval", "report", "show", "<run-id>", "--format", "json",
])
```
### 方式二:使用本目录封装的 Skill
参考 `agenteval_skill.py`,在 OpenClaw 中注册技能时传入配置:
```json ```json
{ {
"api_base_url": "http://agenteval:8000",
"api_key": "<可选对应 AGENTEVAL_API_KEY>",
"target_id": "<target-id>", "target_id": "<target-id>",
"scenario_id": "<scenario-id>", "scenario_id": "<scenario-id>",
"report_format": "json" "report_format": "json"
@ -66,20 +54,21 @@ OpenClaw 的 cron 配置(具体格式以 OpenClaw 平台为准):
skill: agenteval_skill skill: agenteval_skill
schedule: "0 9 * * *" # 每天早上 9 点执行 schedule: "0 9 * * *" # 每天早上 9 点执行
config: config:
api_base_url: "http://agenteval:8000"
target_id: "<target-id>" target_id: "<target-id>"
scenario_id: "<scenario-id>" scenario_id: "<scenario-id>"
report_format: "json" report_format: "json"
``` ```
## CLI 返回的 run_id 解析 ## API 速查
`agenteval run start` 成功后会输出: | 操作 | 请求 |
|---|---|
| 启动评测 | `POST /api/runs` body: `{"target_id", "scenario_id", "triggered_by": "ai_assistant"}` |
| 查询状态 | `GET /api/runs/{run_id}`(轮询至 completed/failed |
| 获取报告 | `GET /api/reports/{run_id}` |
```text 如果平台设置了 `AGENTEVAL_API_KEY`,所有请求需带 `X-API-Key` 头。
评测完成: run_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, status=completed
```
OpenClaw Skill 需要从 stdout 中提取 `run_id`,然后调用 `agenteval report show <run_id>` 获取报告。
## 扩展建议 ## 扩展建议

View File

@ -44,6 +44,7 @@ class AgentEvalSkill:
json={ json={
"target_id": self.target_id, "target_id": self.target_id,
"scenario_id": self.scenario_id, "scenario_id": self.scenario_id,
"triggered_by": "ai_assistant",
}, },
) )
if resp.status_code != 200: if resp.status_code != 200:

View File

@ -0,0 +1,54 @@
---
name: agenteval-run
description: 触发 AgentEvalTool 评测任务并获取报告
---
当用户要求执行评测时,使用 exec 工具运行以下命令。
所有评测必须走 AgentEvalTool 标准 HTTP API禁止直接调 CLI 或操作数据库)。
平台可能启用了 API Key 鉴权。每次执行命令前先读取密钥(文件不存在则为空,不影响未启用鉴权的环境):
```bash
KEY=$(cat ~/.openclaw/agenteval-api-key 2>/dev/null)
```
以下所有 curl 命令都必须带 `-H "X-API-Key: $KEY"`
## 查看评测对象列表
```bash
curl -s -H "X-API-Key: $KEY" http://agenteval:8000/api/targets | python3 -m json.tool
```
## 查看评测场景列表
```bash
curl -s -H "X-API-Key: $KEY" http://agenteval:8000/api/scenarios | python3 -m json.tool
```
## 启动评测
必须携带 `"triggered_by": "ai_assistant"`,平台以此区分手动触发与 AI 助手触发:
```bash
curl -s -X POST http://agenteval:8000/api/runs \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"target_id": "<target_id>", "scenario_id": "<scenario_id>", "triggered_by": "ai_assistant"}'
```
响应中的 `id` 字段即 run_id请务必回报给用户。
## 查看运行状态
轮询直到 `status` 变为 `completed``failed`(建议每 5 秒一次):
```bash
curl -s -H "X-API-Key: $KEY" http://agenteval:8000/api/runs/<run_id> | python3 -m json.tool
```
## 获取报告
```bash
curl -s -H "X-API-Key: $KEY" http://agenteval:8000/api/reports/<run_id> | python3 -m json.tool
```
请将 <target_id><scenario_id><run_id> 替换为实际值。
完成后向用户汇报run_id、状态、通过率summary.pass_rate以及失败用例摘要。

View File

@ -0,0 +1,29 @@
"""add triggered_by to eval_runs
Revision ID: b7d4e6f81c22
Revises: a64b2f8c9d10
Create Date: 2026-07-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
import sqlmodel # noqa: F401
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b7d4e6f81c22"
down_revision: Union[str, Sequence[str], None] = "a64b2f8c9d10"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("eval_runs") as batch_op:
batch_op.add_column(
sa.Column("triggered_by", sa.String(), nullable=False, server_default="manual")
)
def downgrade() -> None:
with op.batch_alter_table("eval_runs") as batch_op:
batch_op.drop_column("triggered_by")

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "agenteval" name = "agenteval"
version = "0.3.0-dev" version = "0.4.0-dev"
description = "智能体质量评估工具集平台" description = "智能体质量评估工具集平台"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@ -92,6 +92,23 @@ run rsync -az --delete \
--exclude='*.db*' \ --exclude='*.db*' \
./ "$HOST:$REMOTE_DIR/" ./ "$HOST:$REMOTE_DIR/"
# ── OpenClaw skill sync ────────────────────────────────────────────────
# The agenteval-run skill lives in the openclaw workspace volume (data/),
# which rsync excludes. Sync the versioned copy explicitly so the AI
# assistant always follows the standard HTTP API flow.
SKILL_SRC="backend/plugins/openclaw/skills/agenteval-run/SKILL.md"
SKILL_DST="$REMOTE_DIR/data/openclaw/workspace/skills/agenteval-run/SKILL.md"
log "sync OpenClaw skill → $SKILL_DST"
run ssh "$HOST" "mkdir -p $(dirname "$SKILL_DST")"
run rsync -az "$SKILL_SRC" "$HOST:$SKILL_DST"
# Provision the API key file the skill reads (~/.openclaw/agenteval-api-key in
# the openclaw container = data/openclaw/agenteval-api-key on the host). Sourced
# from AGENTEVAL_API_KEY in the remote .env; removed when the key is unset.
run ssh "$HOST" "KEY=\$(grep -E '^AGENTEVAL_API_KEY=.+' $REMOTE_DIR/.env 2>/dev/null | cut -d= -f2-); \
if [ -n \"\$KEY\" ]; then printf '%s' \"\$KEY\" > $REMOTE_DIR/data/openclaw/agenteval-api-key; \
else rm -f $REMOTE_DIR/data/openclaw/agenteval-api-key; fi"
# ── image rebuild ────────────────────────────────────────────────────── # ── image rebuild ──────────────────────────────────────────────────────
if [[ "$SKIP_BUILD" == "1" ]]; then if [[ "$SKIP_BUILD" == "1" ]]; then
warn "skipping image rebuild (--skip-build)" warn "skipping image rebuild (--skip-build)"

View File

@ -0,0 +1,89 @@
"""Integration tests for the login gate: /api/auth/* and require_auth."""
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from agenteval.config.settings import Settings
from agenteval.web.app import app
from agenteval.web.deps import get_db
@pytest.fixture()
def client_with_db(tmp_path):
from agenteval.storage.db import ( # noqa: F401
EvalResultDB, EvalRunDB, EvalTargetDB, ScenarioDB, TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'auth_api.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
def override_get_db():
try:
yield session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
yield client
app.dependency_overrides.clear()
session.close()
def _patch_settings(monkeypatch, **kwargs):
settings = Settings(_env_file=None, **kwargs)
from agenteval.web import deps as deps_module
from agenteval.web.routers import auth as auth_module
monkeypatch.setattr(deps_module, "get_settings", lambda: settings)
monkeypatch.setattr(auth_module, "get_settings", lambda: settings)
return settings
def test_no_credentials_configured_is_open(client_with_db, monkeypatch):
_patch_settings(monkeypatch)
assert client_with_db.get("/api/targets").status_code == 200
status = client_with_db.get("/api/auth/status").json()
assert status == {"auth_required": False, "authenticated": True}
def test_login_disabled_returns_400(client_with_db, monkeypatch):
_patch_settings(monkeypatch)
resp = client_with_db.post("/api/auth/login", json={"password": "whatever"})
assert resp.status_code == 400
def test_admin_password_gates_api(client_with_db, monkeypatch):
_patch_settings(monkeypatch, admin_password="s3cret")
# No credentials → 401.
assert client_with_db.get("/api/targets").status_code == 401
# Wrong password → 401.
assert client_with_db.post("/api/auth/login", json={"password": "nope"}).status_code == 401
# Correct password → token that unlocks the API.
token = client_with_db.post("/api/auth/login", json={"password": "s3cret"}).json()["token"]
resp = client_with_db.get("/api/targets", headers={"X-Auth-Token": token})
assert resp.status_code == 200
status = client_with_db.get("/api/auth/status", headers={"X-Auth-Token": token}).json()
assert status == {"auth_required": True, "authenticated": True}
assert client_with_db.get("/api/auth/status").json()["authenticated"] is False
def test_api_key_still_works_alongside_login(client_with_db, monkeypatch):
_patch_settings(monkeypatch, admin_password="s3cret", api_key="machine-key")
assert client_with_db.get("/api/targets").status_code == 401
assert client_with_db.get("/api/targets", headers={"X-API-Key": "machine-key"}).status_code == 200
assert client_with_db.get("/api/targets", headers={"X-API-Key": "wrong"}).status_code == 401
def test_health_stays_open(client_with_db, monkeypatch):
_patch_settings(monkeypatch, admin_password="s3cret")
assert client_with_db.get("/api/health").status_code == 200

View File

@ -39,7 +39,7 @@ def client_with_db(tmp_path):
session.close() session.close()
def _seed_run(session: Session, name_suffix: str = "") -> str: def _seed_run(session: Session, name_suffix: str = "", scenario_id: str | None = None) -> str:
target = EvalTarget( target = EvalTarget(
name=f"target{name_suffix}", name=f"target{name_suffix}",
platform=PlatformType.AI_DIGITAL_EMPLOYEE, platform=PlatformType.AI_DIGITAL_EMPLOYEE,
@ -49,15 +49,17 @@ def _seed_run(session: Session, name_suffix: str = "") -> str:
) )
target = TargetRepository(session).create(target) target = TargetRepository(session).create(target)
if scenario_id is None:
scenario = Scenario( scenario = Scenario(
name=f"scenario{name_suffix}", name=f"scenario{name_suffix}",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])], cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
) )
scenario = ScenarioRepository(session).create(scenario) scenario = ScenarioRepository(session).create(scenario)
scenario_id = scenario.id
run = EvalRun( run = EvalRun(
target_id=target.id, target_id=target.id,
scenario_id=scenario.id, scenario_id=scenario_id,
status=RunStatus.COMPLETED, status=RunStatus.COMPLETED,
) )
run = RunRepository(session).create(run) run = RunRepository(session).create(run)
@ -143,7 +145,8 @@ def test_get_markdown_report_attachment_header(client_with_db):
def test_compare_report(client_with_db): def test_compare_report(client_with_db):
client, session = client_with_db client, session = client_with_db
run_id_a = _seed_run(session, "A") run_id_a = _seed_run(session, "A")
run_id_b = _seed_run(session, "B") sid = RunRepository(session).get(run_id_a).scenario_id
run_id_b = _seed_run(session, "B", scenario_id=sid)
resp = client.get(f"/api/reports/compare?run1={run_id_a}&run2={run_id_b}") resp = client.get(f"/api/reports/compare?run1={run_id_a}&run2={run_id_b}")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
@ -153,6 +156,15 @@ def test_compare_report(client_with_db):
assert "cases" in data assert "cases" in data
def test_compare_report_different_scenarios_400(client_with_db):
client, session = client_with_db
run_id_a = _seed_run(session, "A")
run_id_b = _seed_run(session, "B") # separate scenario
resp = client.get(f"/api/reports/compare?run1={run_id_a}&run2={run_id_b}")
assert resp.status_code == 400
assert "相同场景" in resp.json()["detail"]
def test_compare_report_run_not_found(client_with_db): def test_compare_report_run_not_found(client_with_db):
client, session = client_with_db client, session = client_with_db
run_id = _seed_run(session) run_id = _seed_run(session)

View File

@ -196,3 +196,45 @@ async def test_start_run_missing_target(client, seeded_db):
"target_id": "does-not-exist", "scenario_id": "s-1", "target_id": "does-not-exist", "scenario_id": "s-1",
}) })
assert resp.status_code == 404 assert resp.status_code == 404
# ── triggered_by ─────────────────────────────────────────────────────────
async def test_start_run_default_triggered_by_manual(client, seeded_db, mock_channel):
resp = await client.post("/api/runs", json={
"target_id": "t-1", "scenario_id": "s-1",
})
assert resp.status_code == 200
assert resp.json()["triggered_by"] == "manual"
async def test_start_run_ai_assistant_triggered_by(client, seeded_db, mock_channel):
resp = await client.post("/api/runs", json={
"target_id": "t-1", "scenario_id": "s-1", "triggered_by": "ai_assistant",
})
assert resp.status_code == 200
run_id = resp.json()["id"]
assert resp.json()["triggered_by"] == "ai_assistant"
# Persisted, not just echoed.
got = (await client.get(f"/api/runs/{run_id}")).json()
assert got["triggered_by"] == "ai_assistant"
async def test_start_run_invalid_triggered_by_422(client, seeded_db):
resp = await client.post("/api/runs", json={
"target_id": "t-1", "scenario_id": "s-1", "triggered_by": "robot",
})
assert resp.status_code == 422
async def test_list_runs_includes_names_and_trigger(client, seeded_db, mock_channel):
await client.post("/api/runs", json={
"target_id": "t-1", "scenario_id": "s-1", "triggered_by": "ai_assistant",
})
listing = (await client.get("/api/runs")).json()
assert len(listing) == 1
row = listing[0]
assert row["scenario_name"] == "mock-scenario"
assert row["target_name"] == "mock-target"
assert row["triggered_by"] == "ai_assistant"

View File

@ -0,0 +1,106 @@
"""Integration tests for /api/stats/dashboard aggregation."""
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from agenteval.models import (
Case, CaseType, ChannelType, EvalRun, EvalTarget, PlatformType,
RunStatus, RunTrigger, Scenario, TargetStatus,
)
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
from agenteval.web.app import app
from agenteval.web.deps import get_db
@pytest.fixture()
def client_with_db(tmp_path):
from agenteval.storage.db import ( # noqa: F401
EvalResultDB, EvalRunDB, EvalTargetDB, ModelConfigDB, ScenarioDB, TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'stats_api.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
def override_get_db():
try:
yield session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
yield client, session
app.dependency_overrides.clear()
session.close()
def _seed(session: Session) -> None:
target = TargetRepository(session).create(EvalTarget(
name="对象A", platform=PlatformType.AI_DIGITAL_EMPLOYEE,
channel_type=ChannelType.TUTU_API, channel_config={}, status=TargetStatus.ACTIVE,
))
scenario = ScenarioRepository(session).create(Scenario(
name="场景A", cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
))
repo = RunRepository(session)
for pass_rate, trigger in [(1.0, RunTrigger.MANUAL), (0.5, RunTrigger.AI_ASSISTANT)]:
run = repo.create(EvalRun(
target_id=target.id, scenario_id=scenario.id,
status=RunStatus.COMPLETED, triggered_by=trigger,
))
run.summary = {"total_cases": 2, "passed_cases": 1, "failed_cases": 1,
"total_rules": 2, "passed_rules": 1, "pass_rate": pass_rate}
repo.update(run)
repo.create(EvalRun(
target_id=target.id, scenario_id=scenario.id,
status=RunStatus.RUNNING, triggered_by=RunTrigger.MANUAL,
))
def test_dashboard_aggregates(client_with_db):
client, session = client_with_db
_seed(session)
data = client.get("/api/stats/dashboard").json()
assert data["targets_count"] == 1
assert data["scenarios_count"] == 1
assert data["runs_count"] == 3
assert data["model_configs_count"] == 0
assert data["running_count"] == 1
assert data["today_runs"] == 3
assert data["overall_pass_rate"] == pytest.approx(0.75)
assert data["trigger_breakdown"] == {"manual": 2, "ai_assistant": 1}
assert len(data["scenario_stats"]) == 1
stat = data["scenario_stats"][0]
assert stat["scenario_name"] == "场景A"
assert stat["run_count"] == 2
assert stat["avg_pass_rate"] == pytest.approx(0.75)
assert stat["last_run_at"] is not None
assert len(data["recent_runs"]) == 3
assert data["recent_runs"][0]["scenario_name"] == "场景A"
assert data["recent_runs"][0]["target_name"] == "对象A"
assert "triggered_by" in data["recent_runs"][0]
def test_dashboard_empty_db(client_with_db):
client, _ = client_with_db
data = client.get("/api/stats/dashboard").json()
assert data["runs_count"] == 0
assert data["overall_pass_rate"] is None
assert data["scenario_stats"] == []
assert data["recent_runs"] == []
def test_trend_returns_daily_points(client_with_db):
client, session = client_with_db
_seed(session)
points = client.get("/api/stats/trend").json()
assert len(points) == 1
assert points[0]["run_count"] == 2
assert points[0]["pass_rate"] == pytest.approx(75.0)

View File

@ -34,7 +34,13 @@ def report_session(tmp_path):
engine.dispose() engine.dispose()
def _seed_run(session: Session, *, pass_rate: float = 1.0, n_cases: int = 1) -> str: def _seed_run(
session: Session,
*,
pass_rate: float = 1.0,
n_cases: int = 1,
scenario_id: str | None = None,
) -> str:
"""Create a minimal completed run with real data in the DB and return run_id.""" """Create a minimal completed run with real data in the DB and return run_id."""
target = EvalTarget( target = EvalTarget(
name="测试对象", name="测试对象",
@ -45,15 +51,17 @@ def _seed_run(session: Session, *, pass_rate: float = 1.0, n_cases: int = 1) ->
) )
target = TargetRepository(session).create(target) target = TargetRepository(session).create(target)
if scenario_id is None:
scenario = Scenario( scenario = Scenario(
name="测试场景", name="测试场景",
cases=[Case(id=f"c{i}", type=CaseType.SINGLE, messages=["hi"]) for i in range(n_cases)], cases=[Case(id=f"c{i}", type=CaseType.SINGLE, messages=["hi"]) for i in range(n_cases)],
) )
scenario = ScenarioRepository(session).create(scenario) scenario = ScenarioRepository(session).create(scenario)
scenario_id = scenario.id
run = EvalRun( run = EvalRun(
target_id=target.id, target_id=target.id,
scenario_id=scenario.id, scenario_id=scenario_id,
status=RunStatus.COMPLETED, status=RunStatus.COMPLETED,
) )
run = RunRepository(session).create(run) run = RunRepository(session).create(run)
@ -148,9 +156,14 @@ def test_generate_report_not_found_raises(report_session):
# ── generate_compare_report ─────────────────────────────────────────────── # ── generate_compare_report ───────────────────────────────────────────────
def _scenario_of(session: Session, run_id: str) -> str:
return RunRepository(session).get(run_id).scenario_id
def test_compare_report_structure(report_session): def test_compare_report_structure(report_session):
run_id_a = _seed_run(report_session, pass_rate=1.0, n_cases=2) run_id_a = _seed_run(report_session, pass_rate=1.0, n_cases=2)
run_id_b = _seed_run(report_session, pass_rate=0.5, n_cases=2) sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, pass_rate=0.5, n_cases=2, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session) result = generate_compare_report(run_id_a, run_id_b, report_session)
assert "run_a" in result assert "run_a" in result
@ -163,14 +176,16 @@ def test_compare_report_structure(report_session):
def test_compare_report_delta(report_session): def test_compare_report_delta(report_session):
run_id_a = _seed_run(report_session, pass_rate=0.5, n_cases=2) run_id_a = _seed_run(report_session, pass_rate=0.5, n_cases=2)
run_id_b = _seed_run(report_session, pass_rate=1.0, n_cases=2) sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, pass_rate=1.0, n_cases=2, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session) result = generate_compare_report(run_id_a, run_id_b, report_session)
assert result["delta"]["pass_rate"] > 0 # B improved over A assert result["delta"]["pass_rate"] > 0 # B improved over A
def test_compare_report_changed_cases(report_session): def test_compare_report_changed_cases(report_session):
run_id_a = _seed_run(report_session, pass_rate=1.0, n_cases=2) run_id_a = _seed_run(report_session, pass_rate=1.0, n_cases=2)
run_id_b = _seed_run(report_session, pass_rate=0.5, n_cases=2) sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, pass_rate=0.5, n_cases=2, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session) result = generate_compare_report(run_id_a, run_id_b, report_session)
# At least one case changed (A all-pass vs B half-pass) # At least one case changed (A all-pass vs B half-pass)
assert result["changed_cases"] >= 1 assert result["changed_cases"] >= 1
@ -178,7 +193,8 @@ def test_compare_report_changed_cases(report_session):
def test_compare_report_case_level(report_session): def test_compare_report_case_level(report_session):
run_id_a = _seed_run(report_session, n_cases=1) run_id_a = _seed_run(report_session, n_cases=1)
run_id_b = _seed_run(report_session, n_cases=1) sid = _scenario_of(report_session, run_id_a)
run_id_b = _seed_run(report_session, n_cases=1, scenario_id=sid)
result = generate_compare_report(run_id_a, run_id_b, report_session) result = generate_compare_report(run_id_a, run_id_b, report_session)
assert len(result["cases"]) >= 1 assert len(result["cases"]) >= 1
case = result["cases"][0] case = result["cases"][0]
@ -187,6 +203,13 @@ def test_compare_report_case_level(report_session):
assert "changed" in case assert "changed" in case
def test_compare_report_different_scenarios_rejected(report_session):
run_id_a = _seed_run(report_session, n_cases=1)
run_id_b = _seed_run(report_session, n_cases=1) # separate scenario
with pytest.raises(ValueError, match="same scenario"):
generate_compare_report(run_id_a, run_id_b, report_session)
# ── render_markdown_report ──────────────────────────────────────────────── # ── render_markdown_report ────────────────────────────────────────────────
def test_render_markdown_contains_header(report_session): def test_render_markdown_contains_header(report_session):