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:
parent
92f98c3af7
commit
739d586aec
@ -6,6 +6,10 @@
|
||||
# In production, set a strong random token; clients must send it as X-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.
|
||||
# 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.
|
||||
|
||||
@ -33,6 +33,13 @@ class Settings(BaseSettings):
|
||||
default=None,
|
||||
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(
|
||||
default=None,
|
||||
description="Fernet key used to encrypt model provider API keys at rest.",
|
||||
|
||||
@ -24,6 +24,7 @@ from agenteval.models import (
|
||||
ModelPurpose,
|
||||
RuleLogic,
|
||||
RunStatus,
|
||||
RunTrigger,
|
||||
Scenario,
|
||||
Turn,
|
||||
)
|
||||
@ -75,6 +76,7 @@ class EvalEngine:
|
||||
cancel_token: Optional[asyncio.Event] = None,
|
||||
timeout_config: Optional[TimeoutConfig] = None,
|
||||
max_concurrent_cases: int = 1,
|
||||
triggered_by: RunTrigger = RunTrigger.MANUAL,
|
||||
):
|
||||
self.target = target
|
||||
self.scenario = scenario
|
||||
@ -84,6 +86,7 @@ class EvalEngine:
|
||||
self.result_repo = result_repo or ResultRepository(self.session)
|
||||
self.cancel_token = cancel_token or asyncio.Event()
|
||||
self.timeout_config = timeout_config or TimeoutConfig()
|
||||
self.triggered_by = triggered_by
|
||||
self._case_semaphore = asyncio.Semaphore(max(1, max_concurrent_cases))
|
||||
# Collects fatal case-level errors (e.g. dynamic message generation
|
||||
# failures) so their cause is persisted into run.summary — not just
|
||||
@ -116,6 +119,7 @@ class EvalEngine:
|
||||
target_id=self.target.id or "",
|
||||
scenario_id=self.scenario.id or "",
|
||||
status=RunStatus.RUNNING,
|
||||
triggered_by=self.triggered_by,
|
||||
started_at=utc_now(),
|
||||
)
|
||||
run = self.run_repo.create(run)
|
||||
|
||||
@ -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]:
|
||||
"""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_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:
|
||||
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):
|
||||
if not c:
|
||||
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(
|
||||
{
|
||||
|
||||
@ -148,6 +148,12 @@ class RunStatus(str, Enum):
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RunTrigger(str, Enum):
|
||||
MANUAL = "manual"
|
||||
AI_ASSISTANT = "ai_assistant"
|
||||
CLI = "cli"
|
||||
|
||||
|
||||
class EvalRun(BaseModel):
|
||||
"""A single evaluation run."""
|
||||
|
||||
@ -155,6 +161,7 @@ class EvalRun(BaseModel):
|
||||
target_id: str
|
||||
scenario_id: str
|
||||
status: RunStatus = RunStatus.PENDING
|
||||
triggered_by: RunTrigger = RunTrigger.MANUAL
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
summary: Optional[dict[str, Any]] = None
|
||||
|
||||
@ -168,6 +168,7 @@ class EvalRunDB(SQLModel, table=True):
|
||||
target_id: Optional[str] = Field(default=None, foreign_key="eval_targets.id")
|
||||
scenario_id: Optional[str] = Field(default=None, foreign_key="scenarios.id")
|
||||
status: str = "pending"
|
||||
triggered_by: str = Field(default="manual")
|
||||
started_at: Optional[datetime] = Field(default_factory=utc_now)
|
||||
completed_at: Optional[datetime] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
@ -82,6 +82,7 @@ def _run_to_db(run: EvalRun) -> EvalRunDB:
|
||||
target_id=run.target_id,
|
||||
scenario_id=run.scenario_id,
|
||||
status=run.status.value,
|
||||
triggered_by=run.triggered_by.value,
|
||||
started_at=run.started_at,
|
||||
completed_at=run.completed_at,
|
||||
)
|
||||
@ -96,6 +97,7 @@ def _run_from_db(db: EvalRunDB) -> EvalRun:
|
||||
target_id=db.target_id,
|
||||
scenario_id=db.scenario_id,
|
||||
status=db.status,
|
||||
triggered_by=db.triggered_by or "manual",
|
||||
started_at=db.started_at,
|
||||
completed_at=db.completed_at,
|
||||
summary=db.get_summary(),
|
||||
|
||||
@ -11,7 +11,7 @@ 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, 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
|
||||
|
||||
|
||||
@ -40,6 +40,8 @@ app.add_middleware(
|
||||
|
||||
_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(scenarios.router, prefix="/api/scenarios", tags=["scenarios"], dependencies=_api_deps)
|
||||
app.include_router(runs.router, prefix="/api/runs", tags=["runs"], dependencies=_api_deps)
|
||||
|
||||
@ -21,21 +21,37 @@ def get_db():
|
||||
session.close()
|
||||
|
||||
|
||||
def require_api_key(x_api_key: Optional[str] = Header(default=None)) -> None:
|
||||
"""Enforce X-API-Key header when AGENTEVAL_API_KEY is configured.
|
||||
def require_auth(
|
||||
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
|
||||
working without extra configuration.
|
||||
Accepted credentials (either one passes):
|
||||
- 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
|
||||
if not configured:
|
||||
settings = get_settings()
|
||||
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
|
||||
if not x_api_key or x_api_key != configured:
|
||||
raise HTTPException(
|
||||
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)]``.
|
||||
auth_required = Depends(require_api_key)
|
||||
auth_required = Depends(require_auth)
|
||||
|
||||
52
backend/agenteval/web/routers/auth.py
Normal file
52
backend/agenteval/web/routers/auth.py
Normal 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),
|
||||
}
|
||||
@ -23,10 +23,14 @@ def get_compare_report(
|
||||
session: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
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}")
|
||||
if not repo.get(run2):
|
||||
if not run_b:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
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.repository import RunRepository, ScenarioRepository, TargetRepository
|
||||
from agenteval.utils.llm import extract_reply_text
|
||||
@ -22,6 +22,7 @@ router = APIRouter()
|
||||
class StartRunRequest(BaseModel):
|
||||
target_id: str
|
||||
scenario_id: str
|
||||
triggered_by: RunTrigger = RunTrigger.MANUAL
|
||||
|
||||
|
||||
# ── 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("")
|
||||
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("")
|
||||
@ -82,7 +92,11 @@ async def start_run(
|
||||
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 = EvalRun(
|
||||
target_id=request.target_id,
|
||||
scenario_id=request.scenario_id,
|
||||
triggered_by=request.triggered_by,
|
||||
)
|
||||
run = RunRepository(session).create(run)
|
||||
|
||||
task = asyncio.create_task(
|
||||
|
||||
@ -1,34 +1,93 @@
|
||||
"""API routes for statistics and dashboard data."""
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from agenteval.storage.model_config_repository import ModelConfigRepository
|
||||
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
||||
from agenteval.web.deps import get_db
|
||||
|
||||
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")
|
||||
def dashboard(session: Session = Depends(get_db)) -> dict:
|
||||
targets = TargetRepository(session).list_all()
|
||||
scenarios = ScenarioRepository(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]
|
||||
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
|
||||
|
||||
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 {
|
||||
"targets_count": len(targets),
|
||||
"scenarios_count": len(scenarios),
|
||||
"runs_count": len(runs),
|
||||
"model_configs_count": len(model_configs),
|
||||
"today_runs": today_runs,
|
||||
"running_count": running_count,
|
||||
"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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
import typer
|
||||
from agenteval.evaluation.engine import EvalEngine
|
||||
from agenteval.models import RunTrigger
|
||||
from agenteval.storage.repository import RunRepository, ScenarioRepository, TargetRepository
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
@ -44,7 +45,7 @@ def start_run(
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"开始评测: 对象={target.name}, 场景={scenario.name}")
|
||||
engine = EvalEngine(target=target, scenario=scenario)
|
||||
engine = EvalEngine(target=target, scenario=scenario, triggered_by=RunTrigger.CLI)
|
||||
try:
|
||||
run = asyncio.run(engine.run(progress_callback=_progress))
|
||||
except Exception as exc:
|
||||
|
||||
@ -1,57 +1,45 @@
|
||||
# OpenClaw 插件 for AgentEvalTool
|
||||
|
||||
本目录提供 OpenClaw 调用 AgentEvalTool 的示例技能,使 OpenClaw 能够自闭环完成评测策略设计。
|
||||
本目录提供 OpenClaw 调用 AgentEvalTool 的标准接入方式,使 OpenClaw(AI 助手)能够自闭环完成评测。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- AgentEvalTool 作为独立的 CLI 工具集存在,负责:
|
||||
- AgentEvalTool 负责:
|
||||
- 评测对象管理
|
||||
- 评测场景管理
|
||||
- 评测执行与原始数据收集
|
||||
- 评估分析与报告生成
|
||||
- OpenClaw 通过本插件调用 `agenteval` CLI,负责:
|
||||
- OpenClaw 负责:
|
||||
- 评测策略编排(选择对象、场景、触发时机)
|
||||
- 定时调度(OpenClaw 自身的 cron + skill 机制)
|
||||
- 结果通知与后续动作
|
||||
|
||||
## 前置条件
|
||||
## 唯一标准接入方式:HTTP API
|
||||
|
||||
1. 已安装 AgentEvalTool CLI:
|
||||
```bash
|
||||
pip install -e /path/to/AgentEvalTool
|
||||
```
|
||||
2. `agenteval` 命令已在 PATH 中可用。
|
||||
3. 已通过 `agenteval target add` 注册评测对象。
|
||||
4. 已通过 `agenteval scenario import` 导入评测场景。
|
||||
**所有 OpenClaw 触发的评测必须走 AgentEvalTool 的 HTTP API**(`POST /api/runs` 等),
|
||||
并在启动评测时携带 `"triggered_by": "ai_assistant"`,以便平台区分手动触发与 AI 助手触发。
|
||||
|
||||
## 接入方式
|
||||
> ⚠️ 禁止在 OpenClaw 中通过 subprocess 调用 `agenteval` CLI。
|
||||
> CLI 直接写入其运行环境下的本地 SQLite(路径硬编码),与 Web 后台的数据库不共享,
|
||||
> 会导致评测记录"消失"——Web 页面永远查不到。CLI 仅用于开发者在 agenteval
|
||||
> 服务同一环境下的手工调试。
|
||||
|
||||
### 方式一:直接调用 CLI(推荐)
|
||||
### 方式一:agenteval-run 技能(推荐)
|
||||
|
||||
在 OpenClaw Skill 中直接调用系统命令:
|
||||
`skills/agenteval-run/SKILL.md` 是标准技能文件,指导 AI 助手用 `curl` 走 API 全流程
|
||||
(列对象/场景 → 启动评测 → 轮询状态 → 取报告)。
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
部署脚本(`scripts/deploy-t480.sh`)会自动把它同步到 OpenClaw 工作区:
|
||||
`data/openclaw/workspace/skills/agenteval-run/SKILL.md`。
|
||||
|
||||
# 触发评测
|
||||
subprocess.run([
|
||||
"agenteval", "run", "start",
|
||||
"--target-id", "<target-id>",
|
||||
"--scenario-id", "<scenario-id>",
|
||||
], check=True)
|
||||
### 方式二:Python Skill 封装
|
||||
|
||||
# 获取报告(需要在运行输出中解析 run_id)
|
||||
report = subprocess.check_output([
|
||||
"agenteval", "report", "show", "<run-id>", "--format", "json",
|
||||
])
|
||||
```
|
||||
|
||||
### 方式二:使用本目录封装的 Skill
|
||||
|
||||
参考 `agenteval_skill.py`,在 OpenClaw 中注册技能时传入配置:
|
||||
参考 `agenteval_skill.py`(httpx 调 API),在 OpenClaw 中注册技能时传入配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_base_url": "http://agenteval:8000",
|
||||
"api_key": "<可选,对应 AGENTEVAL_API_KEY>",
|
||||
"target_id": "<target-id>",
|
||||
"scenario_id": "<scenario-id>",
|
||||
"report_format": "json"
|
||||
@ -66,20 +54,21 @@ OpenClaw 的 cron 配置(具体格式以 OpenClaw 平台为准):
|
||||
skill: agenteval_skill
|
||||
schedule: "0 9 * * *" # 每天早上 9 点执行
|
||||
config:
|
||||
api_base_url: "http://agenteval:8000"
|
||||
target_id: "<target-id>"
|
||||
scenario_id: "<scenario-id>"
|
||||
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
|
||||
评测完成: run_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, status=completed
|
||||
```
|
||||
|
||||
OpenClaw Skill 需要从 stdout 中提取 `run_id`,然后调用 `agenteval report show <run_id>` 获取报告。
|
||||
如果平台设置了 `AGENTEVAL_API_KEY`,所有请求需带 `X-API-Key` 头。
|
||||
|
||||
## 扩展建议
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ class AgentEvalSkill:
|
||||
json={
|
||||
"target_id": self.target_id,
|
||||
"scenario_id": self.scenario_id,
|
||||
"triggered_by": "ai_assistant",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
||||
54
backend/plugins/openclaw/skills/agenteval-run/SKILL.md
Normal file
54
backend/plugins/openclaw/skills/agenteval-run/SKILL.md
Normal 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)以及失败用例摘要。
|
||||
@ -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")
|
||||
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "agenteval"
|
||||
version = "0.3.0-dev"
|
||||
version = "0.4.0-dev"
|
||||
description = "智能体质量评估工具集平台"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@ -92,6 +92,23 @@ run rsync -az --delete \
|
||||
--exclude='*.db*' \
|
||||
./ "$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 ──────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_BUILD" == "1" ]]; then
|
||||
warn "skipping image rebuild (--skip-build)"
|
||||
|
||||
89
tests/integration/test_auth_api.py
Normal file
89
tests/integration/test_auth_api.py
Normal 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
|
||||
@ -39,7 +39,7 @@ def client_with_db(tmp_path):
|
||||
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(
|
||||
name=f"target{name_suffix}",
|
||||
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
|
||||
@ -49,15 +49,17 @@ def _seed_run(session: Session, name_suffix: str = "") -> str:
|
||||
)
|
||||
target = TargetRepository(session).create(target)
|
||||
|
||||
if scenario_id is None:
|
||||
scenario = Scenario(
|
||||
name=f"scenario{name_suffix}",
|
||||
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
||||
)
|
||||
scenario = ScenarioRepository(session).create(scenario)
|
||||
scenario_id = scenario.id
|
||||
|
||||
run = EvalRun(
|
||||
target_id=target.id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_id=scenario_id,
|
||||
status=RunStatus.COMPLETED,
|
||||
)
|
||||
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):
|
||||
client, session = client_with_db
|
||||
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}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
@ -153,6 +156,15 @@ def test_compare_report(client_with_db):
|
||||
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):
|
||||
client, session = client_with_db
|
||||
run_id = _seed_run(session)
|
||||
|
||||
@ -196,3 +196,45 @@ async def test_start_run_missing_target(client, seeded_db):
|
||||
"target_id": "does-not-exist", "scenario_id": "s-1",
|
||||
})
|
||||
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"
|
||||
|
||||
106
tests/integration/test_stats_api.py
Normal file
106
tests/integration/test_stats_api.py
Normal 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)
|
||||
@ -34,7 +34,13 @@ def report_session(tmp_path):
|
||||
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."""
|
||||
target = EvalTarget(
|
||||
name="测试对象",
|
||||
@ -45,15 +51,17 @@ def _seed_run(session: Session, *, pass_rate: float = 1.0, n_cases: int = 1) ->
|
||||
)
|
||||
target = TargetRepository(session).create(target)
|
||||
|
||||
if scenario_id is None:
|
||||
scenario = Scenario(
|
||||
name="测试场景",
|
||||
cases=[Case(id=f"c{i}", type=CaseType.SINGLE, messages=["hi"]) for i in range(n_cases)],
|
||||
)
|
||||
scenario = ScenarioRepository(session).create(scenario)
|
||||
scenario_id = scenario.id
|
||||
|
||||
run = EvalRun(
|
||||
target_id=target.id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_id=scenario_id,
|
||||
status=RunStatus.COMPLETED,
|
||||
)
|
||||
run = RunRepository(session).create(run)
|
||||
@ -148,9 +156,14 @@ def test_generate_report_not_found_raises(report_session):
|
||||
|
||||
# ── 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):
|
||||
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)
|
||||
|
||||
assert "run_a" in result
|
||||
@ -163,14 +176,16 @@ def test_compare_report_structure(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_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)
|
||||
assert result["delta"]["pass_rate"] > 0 # B improved over A
|
||||
|
||||
|
||||
def test_compare_report_changed_cases(report_session):
|
||||
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)
|
||||
# At least one case changed (A all-pass vs B half-pass)
|
||||
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):
|
||||
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)
|
||||
assert len(result["cases"]) >= 1
|
||||
case = result["cases"][0]
|
||||
@ -187,6 +203,13 @@ def test_compare_report_case_level(report_session):
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
def test_render_markdown_contains_header(report_session):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user