Compare commits

..

2 Commits

Author SHA1 Message Date
sinohqb
12c1732617 v0.4-t3t4: OpenClaw 通道 + 前端 bundle 优化
## T3: OpenClaw 直连通道
- channels/openclaw.py: OpenClawChannel
  - send: POST /api/v1/chat/completions
  - poll_reply: GET /api/v1/chat/completions/{msg_id}
  - health_check: GET /api/health
  - 默认从 settings 读取 upstream/auth_token,channel_config 可覆盖
- channels/factory.py: 注册 ChannelType.OPENCLAW → OpenClawChannel
- Targets.tsx: 通道类型下拉新增「HTTP 通用」和「OpenClaw」选项
- 8 个 OpenClawChannel 单元测试(发送/轮询/超时/健康检查/默认配置)

## T4: 前端 Bundle 优化
- App.tsx: 7 个页面改为 React.lazy 懒加载 + Suspense fallback(Spin)
- vite.config.ts: 精细化 manualChunks
  - vendor-antd / vendor-monaco / vendor-charts 独立拆分
  - 主 index 68KB → 7KB,页面按需加载
  - 无循环依赖警告

## 测试
- 178/178 全绿,覆盖率维持 77%

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:37:14 +08:00
sinohqb
e0b69fa2b9 v0.4-t1t2: 测试覆盖率 62%→77% + UTC 时区根本修复
## T1: P0 测试补全(+67 个测试)
- test_utils_llm.py: extract_reply_text / extract_content_from_llm_response / parse_json_from_llm_text 各边界
- test_file_repository.py: 分类 CRUD / 树形结构 / 级联删除 / 文件创建/查询/删除/物理文件清理
- test_report.py: generate_report / generate_compare_report / render_markdown / render_json
- test_llm_score.py: OpenAI 格式 / Anthropic content-block 格式 / JSON 回退解析 / 异常降级

## T2: P1 测试补全(+28 个测试)
- test_scenarios.py: 模板列表/字段完整性/规则类型有效性 + YAML/JSON 加载/校验
- test_webhook.py: 未配置不发送 / 正确 payload / secret header / 异常静默忽略
- test_reports_api.py: GET /reports/{id} / /html / /json / /markdown / /compare 集成测试

## UTC 时区根本修复
- storage/db.py: 新增 iso_utc() 函数,确保所有 datetime 序列化输出带 Z 后缀
- runs.py / files.py / report.py: 6 处 .isoformat() → iso_utc()
- 前端 toDate() 兜底仍保留(向下兼容),但后端不再输出无时区时间戳

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:19:16 +08:00
18 changed files with 1463 additions and 39 deletions

View File

@ -2,6 +2,7 @@
from agenteval.channels.base import EvalChannel from agenteval.channels.base import EvalChannel
from agenteval.channels.http import HttpChannel from agenteval.channels.http import HttpChannel
from agenteval.channels.openclaw import OpenClawChannel
from agenteval.channels.tutu import TutuApiChannel from agenteval.channels.tutu import TutuApiChannel
from agenteval.models import ChannelType, EvalTarget from agenteval.models import ChannelType, EvalTarget
@ -12,6 +13,7 @@ class ChannelFactory:
_mapping: dict[ChannelType, type[EvalChannel]] = { _mapping: dict[ChannelType, type[EvalChannel]] = {
ChannelType.TUTU_API: TutuApiChannel, ChannelType.TUTU_API: TutuApiChannel,
ChannelType.HTTP: HttpChannel, ChannelType.HTTP: HttpChannel,
ChannelType.OPENCLAW: OpenClawChannel,
} }
@classmethod @classmethod

View File

@ -0,0 +1,108 @@
"""OpenClaw message channel.
Connects directly to an OpenClaw instance as an evaluation target.
Uses the OpenClaw chat API to send messages and poll for replies.
Configuration keys (in channel_config, all optional defaults come from settings):
base_url Override OpenClaw upstream URL (defaults to AGENTEVAL_OPENCLAW_UPSTREAM)
auth_token Override auth token (defaults to AGENTEVAL_OPENCLAW_AUTH_TOKEN)
model Model name for chat completions (default: "doubao-seed-2.0")
poll_interval Seconds between polls (default 1.0)
timeout Seconds before poll gives up (default 30.0)
"""
import asyncio
import uuid
from typing import Any, Optional
import httpx
from agenteval.channels.base import ChannelHealth, EvalChannel, Reply, SendResult
from agenteval.config import get_settings
class OpenClawChannel(EvalChannel):
"""Message channel backed by an OpenClaw chat API."""
def __init__(self, config: dict[str, Any]):
settings = get_settings()
self.base_url: str = config.get("base_url", settings.openclaw_upstream).rstrip("/")
self.auth_token: str = config.get("auth_token", settings.openclaw_auth_token)
self.model: str = config.get("model", "doubao-seed-2.0")
self._poll_interval: float = float(config.get("poll_interval", 1.0))
self._client = httpx.AsyncClient(
headers={
"Authorization": f"Bearer {self.auth_token}",
"Content-Type": "application/json",
},
timeout=30,
)
async def close(self) -> None:
await self._client.aclose()
async def health_check(self) -> ChannelHealth:
try:
resp = await self._client.get(f"{self.base_url}/api/health")
resp.raise_for_status()
return ChannelHealth(ok=True, message=f"OpenClaw {resp.status_code}")
except Exception as exc:
return ChannelHealth(ok=False, message=str(exc))
async def send(self, content: str, **kwargs: Any) -> SendResult:
"""Send a chat message to OpenClaw."""
payload = {
"model": self.model,
"messages": [{"role": "user", "content": content}],
"stream": False,
}
try:
resp = await self._client.post(
f"{self.base_url}/api/v1/chat/completions",
json=payload,
)
resp.raise_for_status()
data = resp.json()
# Extract the assistant message ID from the response
msg_id = data.get("id") or str(uuid.uuid4())
return SendResult(ok=True, question_msg_id=msg_id, raw_response=data)
except Exception as exc:
return SendResult(ok=False, error=str(exc))
async def poll_reply(
self,
question_msg_id: str,
timeout: float = 30.0,
poll_interval: float = 1.0,
) -> Optional[Reply]:
"""Poll the chat completions endpoint until a reply is available.
Uses the conversation ID from the send response to track the thread.
"""
interval = poll_interval or self._poll_interval
deadline = asyncio.get_event_loop().time() + timeout
# Re-send with the same conversation to get the latest reply
while asyncio.get_event_loop().time() < deadline:
try:
# Get the thread/messages from the conversation
resp = await self._client.get(
f"{self.base_url}/api/v1/chat/completions/{question_msg_id}",
)
if resp.status_code == 200:
data = resp.json()
choice = (data.get("choices") or [{}])[0]
reply_content = choice.get("message", {}).get("content", "")
if reply_content:
return Reply(
question_msg_id=question_msg_id,
content=reply_content,
raw_message={"text": reply_content, "_raw": data},
)
except Exception:
pass
await asyncio.sleep(interval)
return None

View File

@ -7,7 +7,7 @@ from typing import Any, Optional
from jinja2 import Template from jinja2 import Template
from agenteval.storage.db import DATA_DIR from agenteval.storage.db import DATA_DIR, 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
@ -154,8 +154,8 @@ def generate_report(run_id: str, session=None) -> dict[str, Any]:
"scenario_id": run.scenario_id, "scenario_id": run.scenario_id,
"scenario_name": scenario.name if scenario else "未知", "scenario_name": scenario.name if scenario else "未知",
"status": run.status.value, "status": run.status.value,
"started_at": run.started_at.isoformat() if run.started_at else None, "started_at": iso_utc(run.started_at),
"completed_at": run.completed_at.isoformat() if run.completed_at else None, "completed_at": iso_utc(run.completed_at),
"summary": { "summary": {
"total_cases": summary.get("total_cases", 0), "total_cases": summary.get("total_cases", 0),
"passed_cases": summary.get("passed_cases", 0), "passed_cases": summary.get("passed_cases", 0),
@ -191,14 +191,16 @@ def generate_compare_report(run_id_1: str, run_id_2: str, session=None) -> dict[
return None return None
return all(r["passed"] for r in c.get("results", [])) return all(r["passed"] for r in c.get("results", []))
case_diffs.append({ case_diffs.append(
"case_id": cid, {
"run_a_passed": _case_passed(ca), "case_id": cid,
"run_b_passed": _case_passed(cb), "run_a_passed": _case_passed(ca),
"changed": _case_passed(ca) != _case_passed(cb), "run_b_passed": _case_passed(cb),
"run_a_results": ca["results"] if ca else [], "changed": _case_passed(ca) != _case_passed(cb),
"run_b_results": cb["results"] if cb else [], "run_a_results": ca["results"] if ca else [],
}) "run_b_results": cb["results"] if cb else [],
}
)
return { return {
"run_a": { "run_a": {

View File

@ -27,6 +27,19 @@ def utc_now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def iso_utc(dt: datetime | None) -> str | None:
"""Serialize a datetime to ISO 8601 with UTC timezone suffix.
Guarantees the output always ends with 'Z' or '+00:00' so JavaScript's
Date.parse() interprets it correctly as UTC (no 8-hour local-time offset).
"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat(timespec="seconds").replace("+00:00", "Z")
def new_uuid() -> str: def new_uuid() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())

View File

@ -10,7 +10,7 @@ from pydantic import BaseModel
from sqlmodel import Session from sqlmodel import Session
from agenteval.config import get_settings from agenteval.config import get_settings
from agenteval.storage.db import FILES_DIR from agenteval.storage.db import FILES_DIR, iso_utc
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
from agenteval.web.deps import get_db from agenteval.web.deps import get_db
@ -112,7 +112,7 @@ def list_files(category_id: str | None = None, session: Session = Depends(get_db
"mime_type": r.mime_type, "mime_type": r.mime_type,
"file_ext": r.file_ext, "file_ext": r.file_ext,
"category_id": r.category_id, "category_id": r.category_id,
"created_at": r.created_at.isoformat() if r.created_at else None, "created_at": iso_utc(r.created_at),
} }
for r in records for r in records
] ]
@ -174,7 +174,7 @@ async def upload_file(
"mime_type": record.mime_type, "mime_type": record.mime_type,
"file_ext": record.file_ext, "file_ext": record.file_ext,
"category_id": record.category_id, "category_id": record.category_id,
"created_at": record.created_at.isoformat() if record.created_at else None, "created_at": iso_utc(record.created_at),
} }

View File

@ -9,7 +9,7 @@ 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
from agenteval.storage.db import get_session 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
from agenteval.utils.webhook import send_run_webhook from agenteval.utils.webhook import send_run_webhook
@ -148,8 +148,8 @@ async def get_run_logs(run_id: str, session: Session = Depends(get_db)) -> dict:
"latency_ms": t.latency_ms, "latency_ms": t.latency_ms,
"sent_text": t.get_sent_message().get("msgBody", {}).get("content", ""), "sent_text": t.get_sent_message().get("msgBody", {}).get("content", ""),
"reply_text": extract_reply_text(t.get_reply()), "reply_text": extract_reply_text(t.get_reply()),
"sent_at": t.sent_at.isoformat() if t.sent_at else None, "sent_at": iso_utc(t.sent_at),
"received_at": t.received_at.isoformat() if t.received_at else None, "received_at": iso_utc(t.received_at),
} }
for t in turns for t in turns
] ]

View File

@ -39,10 +39,13 @@ class AgentEvalSkill:
def run(self) -> dict[str, Any]: def run(self) -> dict[str, Any]:
with httpx.Client(base_url=self.api_base, headers=self._headers(), timeout=30) as client: with httpx.Client(base_url=self.api_base, headers=self._headers(), timeout=30) as client:
# 1. Start the run # 1. Start the run
resp = client.post("/api/runs", json={ resp = client.post(
"target_id": self.target_id, "/api/runs",
"scenario_id": self.scenario_id, json={
}) "target_id": self.target_id,
"scenario_id": self.scenario_id,
},
)
if resp.status_code != 200: if resp.status_code != 200:
return {"ok": False, "error": f"启动评测失败: HTTP {resp.status_code} {resp.text}"} return {"ok": False, "error": f"启动评测失败: HTTP {resp.status_code} {resp.text}"}

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react' import { lazy, Suspense, useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Layout, Menu } from 'antd' import { Layout, Menu, Spin } from 'antd'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { import {
DashboardOutlined, DashboardOutlined,
@ -11,18 +11,34 @@ import {
RobotOutlined, RobotOutlined,
FolderOpenOutlined, FolderOpenOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import HomePage from './pages/Home'
import TargetsPage from './pages/Targets'
import ScenariosPage from './pages/Scenarios'
import RunsPage from './pages/Runs'
import ReportsPage from './pages/Reports'
import OpenClawPage from './pages/OpenClaw'
import FilesPage from './pages/Files'
import TabBar from './components/TabBar' import TabBar from './components/TabBar'
import { useTabStore, type TabItem } from './stores/tabStore' import { useTabStore, type TabItem } from './stores/tabStore'
import { colors } from './tokens' import { colors } from './tokens'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
// Lazy-load pages to split heavy dependencies (@ant-design/charts, Monaco Editor)
// into separate chunks. The keep-alive tab pattern still works — each page is
// loaded on first access and then stays mounted.
const HomePage = lazy(() => import('./pages/Home'))
const TargetsPage = lazy(() => import('./pages/Targets'))
const ScenariosPage = lazy(() => import('./pages/Scenarios'))
const RunsPage = lazy(() => import('./pages/Runs'))
const ReportsPage = lazy(() => import('./pages/Reports'))
const OpenClawPage = lazy(() => import('./pages/OpenClaw'))
const FilesPage = lazy(() => import('./pages/Files'))
function PageLoader({ children }: { children: ReactNode }) {
return (
<Suspense fallback={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin size="large" />
</div>
}>
{children}
</Suspense>
)
}
interface RouteConfig { interface RouteConfig {
path: string path: string
name: string name: string
@ -31,13 +47,13 @@ interface RouteConfig {
} }
const routeConfigs: RouteConfig[] = [ const routeConfigs: RouteConfig[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, component: () => <HomePage /> }, { path: '/', name: '仪表盘', icon: <DashboardOutlined />, component: () => <PageLoader><HomePage /></PageLoader> },
{ path: '/targets', name: '评测对象', icon: <AimOutlined />, component: () => <TargetsPage /> }, { path: '/targets', name: '评测对象', icon: <AimOutlined />, component: () => <PageLoader><TargetsPage /></PageLoader> },
{ path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <ScenariosPage /> }, { path: '/scenarios', name: '评测场景', icon: <FileTextOutlined />, component: () => <PageLoader><ScenariosPage /></PageLoader> },
{ path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <RunsPage /> }, { path: '/runs', name: '评测执行', icon: <PlayCircleOutlined />, component: () => <PageLoader><RunsPage /></PageLoader> },
{ path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <ReportsPage /> }, { path: '/reports', name: '评测报告', icon: <BarChartOutlined />, component: () => <PageLoader><ReportsPage /></PageLoader> },
{ path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <OpenClawPage /> }, { path: '/openclaw', name: 'AI 助手', icon: <RobotOutlined />, component: () => <PageLoader><OpenClawPage /></PageLoader> },
{ path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <FilesPage /> }, { path: '/files', name: '原始文件', icon: <FolderOpenOutlined />, component: () => <PageLoader><FilesPage /></PageLoader> },
] ]
const componentMap: Record<string, () => ReactNode> = {} const componentMap: Record<string, () => ReactNode> = {}

View File

@ -198,6 +198,8 @@ export default function TargetsPage() {
<Form.Item name="channel_type" label="通道类型" rules={[{ required: true }]}> <Form.Item name="channel_type" label="通道类型" rules={[{ required: true }]}>
<Select options={[ <Select options={[
{ value: 'tutu-api', label: 'Tutu API' }, { value: 'tutu-api', label: 'Tutu API' },
{ value: 'http', label: 'HTTP 通用' },
{ value: 'openclaw', label: 'OpenClaw' },
]} /> ]} />
</Form.Item> </Form.Item>

View File

@ -25,9 +25,19 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id) { manualChunks(id) {
// Monaco Editor — only used by Scenarios page
if (id.includes('@monaco-editor') || id.includes('monaco-editor')) { if (id.includes('@monaco-editor') || id.includes('monaco-editor')) {
return 'vendor-monaco' return 'vendor-monaco'
} }
// @ant-design/charts — only used by Home page
if (id.includes('@ant-design/charts')) {
return 'vendor-charts'
}
// Ant Design core — used everywhere
if (id.includes('antd') || id.includes('@ant-design/icons')) {
return 'vendor-antd'
}
// React + remaining libs — used everywhere, merged to avoid circular imports
if (id.includes('node_modules')) { if (id.includes('node_modules')) {
return 'vendor' return 'vendor'
} }

View File

@ -0,0 +1,160 @@
"""Integration tests for the reports API: /api/reports/{id}, /markdown, /compare."""
import json
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from agenteval.models import (
Case, CaseType, EvalResult, EvalRun, EvalTarget, RunStatus, Scenario,
PlatformType, ChannelType, TargetStatus, Turn,
)
from agenteval.storage.repository import ResultRepository, 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, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'reports_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_run(session: Session, name_suffix: str = "") -> str:
target = EvalTarget(
name=f"target{name_suffix}",
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
channel_type=ChannelType.TUTU_API,
channel_config={},
status=TargetStatus.ACTIVE,
)
target = TargetRepository(session).create(target)
scenario = Scenario(
name=f"scenario{name_suffix}",
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
)
scenario = ScenarioRepository(session).create(scenario)
run = EvalRun(
target_id=target.id,
scenario_id=scenario.id,
status=RunStatus.COMPLETED,
)
run = RunRepository(session).create(run)
result_repo = ResultRepository(session)
run_repo = RunRepository(session)
turn = Turn(
run_id=run.id, case_id="c1", round_index=1,
sent_message={"msgBody": {"content": "问题"}},
reply={"msgBody": {"content": "回答"}},
latency_ms=300,
)
result_repo.save_turn(turn)
db_turn = run_repo.get_turns(run.id)[0]
result_repo.save_result(EvalResult(
run_id=run.id, case_id="c1",
turn_id=db_turn.id or "",
rule_type="response_time",
passed=True, score=0.9, reason="",
))
run.summary = {
"total_cases": 1, "passed_cases": 1, "failed_cases": 0,
"total_rules": 1, "passed_rules": 1, "pass_rate": 1.0,
}
RunRepository(session).update(run)
return run.id
def test_get_report_200(client_with_db):
client, session = client_with_db
run_id = _seed_run(session)
resp = client.get(f"/api/reports/{run_id}")
assert resp.status_code == 200
data = resp.json()
assert data["run_id"] == run_id
assert data["summary"]["pass_rate"] == 1.0
def test_get_report_404(client_with_db):
client, _ = client_with_db
resp = client.get("/api/reports/no-such-run")
assert resp.status_code == 404
def test_get_html_report(client_with_db):
client, session = client_with_db
run_id = _seed_run(session)
resp = client.get(f"/api/reports/{run_id}/html")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
assert "评测报告" in resp.text
def test_get_json_report(client_with_db):
client, session = client_with_db
run_id = _seed_run(session)
resp = client.get(f"/api/reports/{run_id}/json")
assert resp.status_code == 200
data = json.loads(resp.text)
assert "run_id" in data
def test_get_markdown_report(client_with_db):
client, session = client_with_db
run_id = _seed_run(session)
resp = client.get(f"/api/reports/{run_id}/markdown")
assert resp.status_code == 200
assert "text/markdown" in resp.headers["content-type"]
assert "# 评测报告" in resp.text
assert "## 汇总" in resp.text
def test_get_markdown_report_attachment_header(client_with_db):
client, session = client_with_db
run_id = _seed_run(session)
resp = client.get(f"/api/reports/{run_id}/markdown")
assert "attachment" in resp.headers.get("content-disposition", "")
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")
resp = client.get(f"/api/reports/compare?run1={run_id_a}&run2={run_id_b}")
assert resp.status_code == 200
data = resp.json()
assert data["run_a"]["run_id"] == run_id_a
assert data["run_b"]["run_id"] == run_id_b
assert "delta" in data
assert "cases" in data
def test_compare_report_run_not_found(client_with_db):
client, session = client_with_db
run_id = _seed_run(session)
resp = client.get(f"/api/reports/compare?run1={run_id}&run2=ghost-id")
assert resp.status_code == 404

View File

@ -0,0 +1,220 @@
"""Unit tests for FileCategoryRepository and FileRecordRepository."""
import uuid
from pathlib import Path
import pytest
from sqlmodel import Session, SQLModel, create_engine
from agenteval.storage.file_repository import FileCategoryRepository, FileRecordRepository
@pytest.fixture()
def file_db_session(tmp_path: Path):
"""Isolated SQLite session with file management tables."""
from agenteval.storage.db import ( # noqa: F401
EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'file_test.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
try:
yield session
finally:
session.close()
engine.dispose()
@pytest.fixture()
def fake_files_dir(tmp_path: Path, monkeypatch):
"""Redirect FILES_DIR to a temp directory so tests don't touch data/files."""
import agenteval.storage.db as db_mod
import agenteval.storage.file_repository as repo_mod
fake_dir = tmp_path / "files"
fake_dir.mkdir()
monkeypatch.setattr(db_mod, "FILES_DIR", fake_dir)
monkeypatch.setattr(repo_mod, "FILES_DIR", fake_dir)
return fake_dir
# ── FileCategoryRepository ────────────────────────────────────────────────
def test_create_root_category(file_db_session):
repo = FileCategoryRepository(file_db_session)
cat = repo.create("文件一类")
assert cat.id is not None
assert cat.name == "文件一类"
assert cat.parent_id is None
def test_create_child_category(file_db_session):
repo = FileCategoryRepository(file_db_session)
root = repo.create("根分类")
child = repo.create("子分类", parent_id=root.id)
assert child.parent_id == root.id
def test_list_all_categories(file_db_session):
repo = FileCategoryRepository(file_db_session)
repo.create("A")
repo.create("B")
all_cats = repo.list_all()
assert len(all_cats) == 2
def test_get_tree_structure(file_db_session):
repo = FileCategoryRepository(file_db_session)
root = repo.create("root")
repo.create("child1", parent_id=root.id)
repo.create("child2", parent_id=root.id)
tree = repo.get_tree()
assert len(tree) == 1
assert tree[0]["key"] == root.id
assert len(tree[0]["children"]) == 2
def test_get_tree_flat(file_db_session):
repo = FileCategoryRepository(file_db_session)
repo.create("A")
repo.create("B")
tree = repo.get_tree()
assert len(tree) == 2
assert all(len(node["children"]) == 0 for node in tree)
def test_update_category_name(file_db_session):
repo = FileCategoryRepository(file_db_session)
cat = repo.create("旧名称")
updated = repo.update(cat.id, "新名称")
assert updated is not None
assert updated.name == "新名称"
def test_update_nonexistent_returns_none(file_db_session):
repo = FileCategoryRepository(file_db_session)
assert repo.update("nonexistent-id", "name") is None
def test_get_nonexistent_returns_none(file_db_session):
repo = FileCategoryRepository(file_db_session)
assert repo.get("no-such-id") is None
def test_delete_category(file_db_session):
repo = FileCategoryRepository(file_db_session)
cat = repo.create("删除目标")
assert repo.delete(cat.id) is True
assert repo.get(cat.id) is None
def test_delete_nonexistent_returns_false(file_db_session):
repo = FileCategoryRepository(file_db_session)
assert repo.delete("ghost-id") is False
def test_delete_cascades_to_children(file_db_session, fake_files_dir):
cat_repo = FileCategoryRepository(file_db_session)
file_repo = FileRecordRepository(file_db_session)
root = cat_repo.create("")
child = cat_repo.create("", parent_id=root.id)
# Create a file in child
file_repo.create("f.txt", "f-storage.txt", 10, "text/plain", "txt", category_id=child.id)
assert cat_repo.delete(root.id) is True
assert cat_repo.get(root.id) is None
assert cat_repo.get(child.id) is None
assert len(file_repo.list_all()) == 0
# ── FileRecordRepository ──────────────────────────────────────────────────
def test_create_file_record(file_db_session):
repo = FileRecordRepository(file_db_session)
rec = repo.create("test.txt", "stored-uuid.txt", 128, "text/plain", "txt")
assert rec.id is not None
assert rec.original_name == "test.txt"
assert rec.file_size == 128
def test_create_file_with_category(file_db_session):
cat_repo = FileCategoryRepository(file_db_session)
file_repo = FileRecordRepository(file_db_session)
cat = cat_repo.create("分类")
rec = file_repo.create("data.json", "stored.json", 256, "application/json", "json", category_id=cat.id)
assert rec.category_id == cat.id
def test_list_files_all(file_db_session):
repo = FileRecordRepository(file_db_session)
repo.create("a.txt", "a-s.txt", 1, "text/plain", "txt")
repo.create("b.txt", "b-s.txt", 2, "text/plain", "txt")
assert len(repo.list_all()) == 2
def test_list_files_by_category(file_db_session):
cat_repo = FileCategoryRepository(file_db_session)
file_repo = FileRecordRepository(file_db_session)
cat_a = cat_repo.create("A")
cat_b = cat_repo.create("B")
file_repo.create("in_a.txt", "s1.txt", 1, "text/plain", "txt", category_id=cat_a.id)
file_repo.create("in_b.txt", "s2.txt", 2, "text/plain", "txt", category_id=cat_b.id)
file_repo.create("no_cat.txt", "s3.txt", 3, "text/plain", "txt")
only_a = file_repo.list_all(category_id=cat_a.id)
assert len(only_a) == 1
assert only_a[0].original_name == "in_a.txt"
def test_list_files_includes_subcategory_files(file_db_session):
cat_repo = FileCategoryRepository(file_db_session)
file_repo = FileRecordRepository(file_db_session)
root = cat_repo.create("root")
child = cat_repo.create("child", parent_id=root.id)
file_repo.create("in_root.txt", "sr.txt", 1, "text/plain", "txt", category_id=root.id)
file_repo.create("in_child.txt", "sc.txt", 2, "text/plain", "txt", category_id=child.id)
files = file_repo.list_all(category_id=root.id)
assert len(files) == 2
def test_get_file_record(file_db_session):
repo = FileRecordRepository(file_db_session)
rec = repo.create("get_me.txt", "gm.txt", 10, "text/plain", "txt")
fetched = repo.get(rec.id)
assert fetched is not None
assert fetched.id == rec.id
def test_get_nonexistent_file_returns_none(file_db_session):
repo = FileRecordRepository(file_db_session)
assert repo.get("no-such-id") is None
def test_delete_file_record(file_db_session):
repo = FileRecordRepository(file_db_session)
rec = repo.create("del.txt", "del-s.txt", 5, "text/plain", "txt")
assert repo.delete(rec.id) is True
assert repo.get(rec.id) is None
def test_delete_removes_physical_file(file_db_session, fake_files_dir):
repo = FileRecordRepository(file_db_session)
storage_name = f"{uuid.uuid4()}.txt"
physical = fake_files_dir / storage_name
physical.write_text("content")
assert physical.exists()
rec = repo.create("orig.txt", storage_name, 7, "text/plain", "txt")
repo.delete(rec.id)
assert not physical.exists()
def test_delete_nonexistent_file_returns_false(file_db_session):
repo = FileRecordRepository(file_db_session)
assert repo.delete("ghost-id") is False

View File

@ -1,4 +1,4 @@
"""Unit tests for HttpChannel and async rule evaluation.""" """Unit tests for HttpChannel, OpenClawChannel, and async rule evaluation."""
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from agenteval.channels.http import HttpChannel, _get_path from agenteval.channels.http import HttpChannel, _get_path
from agenteval.channels.openclaw import OpenClawChannel
from agenteval.channels.base import SendResult from agenteval.channels.base import SendResult
from agenteval.evaluation.rules.keyword import KeywordMatchRule from agenteval.evaluation.rules.keyword import KeywordMatchRule
from agenteval.evaluation.rules.response_time import ResponseTimeRule from agenteval.evaluation.rules.response_time import ResponseTimeRule
@ -211,3 +212,80 @@ async def test_rules_empty_dialog_fail():
result = await rule.evaluate(case, []) result = await rule.evaluate(case, [])
assert result.passed is False assert result.passed is False
assert "无回复" in result.reason assert "无回复" in result.reason
# ── OpenClawChannel ──────────────────────────────────────────────────────
def _make_openclaw_channel(**extra) -> OpenClawChannel:
config = {"base_url": "http://mock-openclaw:18789", "auth_token": "test-token", **extra}
with patch("agenteval.channels.openclaw.get_settings") as mock_settings:
mock_settings.return_value.openclaw_upstream = "http://default:18789"
mock_settings.return_value.openclaw_auth_token = "default-token"
return OpenClawChannel(config)
async def test_openclaw_health_check_ok():
ch = _make_openclaw_channel()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
result = await ch.health_check()
assert result.ok is True
assert "OpenClaw" in result.message
async def test_openclaw_health_check_fail():
ch = _make_openclaw_channel()
with patch.object(ch._client, "get", new=AsyncMock(side_effect=Exception("conn refused"))):
result = await ch.health_check()
assert result.ok is False
async def test_openclaw_send_ok():
ch = _make_openclaw_channel()
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={"id": "chat-msg-1", "choices": [{"message": {"content": "reply"}}]})
with patch.object(ch._client, "post", new=AsyncMock(return_value=mock_resp)):
result = await ch.send("hello")
assert result.ok is True
assert result.question_msg_id == "chat-msg-1"
async def test_openclaw_send_failure():
ch = _make_openclaw_channel()
with patch.object(ch._client, "post", new=AsyncMock(side_effect=Exception("timeout"))):
result = await ch.send("hi")
assert result.ok is False
async def test_openclaw_poll_reply_found():
ch = _make_openclaw_channel()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json = MagicMock(return_value={"choices": [{"message": {"content": "assistant reply"}}]})
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
reply = await ch.poll_reply("chat-msg-1", timeout=5.0)
assert reply is not None
assert reply.content == "assistant reply"
async def test_openclaw_poll_reply_timeout():
ch = _make_openclaw_channel(poll_interval=0.05)
mock_resp = MagicMock()
mock_resp.status_code = 404
mock_resp.json = MagicMock(return_value={})
with patch.object(ch._client, "get", new=AsyncMock(return_value=mock_resp)):
reply = await ch.poll_reply("chat-msg-1", timeout=0.15, poll_interval=0.05)
assert reply is None
async def test_openclaw_uses_default_settings():
"""When config has no base_url or auth_token, fall back to settings defaults."""
with patch("agenteval.channels.openclaw.get_settings") as mock_settings:
mock_settings.return_value.openclaw_upstream = "http://default:18789"
mock_settings.return_value.openclaw_auth_token = "default-token"
ch = OpenClawChannel({})
assert ch.base_url == "http://default:18789"
assert ch.auth_token == "default-token"

View File

@ -0,0 +1,191 @@
"""Unit tests for LlmScoreRule — mocking httpx to avoid real API calls."""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agenteval.evaluation.rules.llm_score import LlmScoreRule
from agenteval.models import Case, CaseType, Turn
def _turn(reply_text: str, sent_text: str = "问题", latency_ms: int = 500) -> Turn:
return Turn(
id="t1", run_id="r1", case_id="c1", round_index=1,
sent_message={"msgBody": {"content": sent_text}},
reply={"msgBody": {"content": reply_text}},
latency_ms=latency_ms,
)
def _case() -> Case:
return Case(id="c1", type=CaseType.SINGLE, messages=["hi"])
def _make_llm_response(score: float, reason: str = "ok") -> MagicMock:
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={
"choices": [{"message": {"content": json.dumps({"score": score, "reason": reason})}}]
})
return mock_resp
def _make_content_block_response(score: float, reason: str = "ok") -> MagicMock:
"""Simulate Anthropic content-block-array format."""
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={
"choices": [{
"message": {
"content": [
{"type": "text", "text": json.dumps({"score": score, "reason": reason})}
]
}
}]
})
return mock_resp
# ── basic evaluate ────────────────────────────────────────────────────────
async def test_llm_score_no_api_url_fails():
rule = LlmScoreRule({"criteria": "礼貌"})
result = await rule.evaluate(_case(), [_turn("回答内容")])
assert result.passed is False
assert "api_url" in result.reason
async def test_llm_score_empty_dialog_fails():
rule = LlmScoreRule({"api_url": "http://mock", "min_score": 7})
result = await rule.evaluate(_case(), [])
assert result.passed is False
assert "无回复" in result.reason
# ── OpenAI format ─────────────────────────────────────────────────────────
async def test_llm_score_passes_above_threshold():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
mock_resp = _make_llm_response(score=8.0, reason="很好")
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=mock_resp)
result = await rule.evaluate(_case(), [_turn("优质回答")])
assert result.passed is True
assert result.score == pytest.approx(0.8)
assert "8" in result.reason
async def test_llm_score_fails_below_threshold():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 7})
mock_resp = _make_llm_response(score=4.0, reason="较差")
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=mock_resp)
result = await rule.evaluate(_case(), [_turn("差劲回答")])
assert result.passed is False
assert result.score == pytest.approx(0.4)
async def test_llm_score_clamps_score_to_0_10():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 5})
# API returns out-of-range score
mock_resp = _make_llm_response(score=12.0)
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=mock_resp)
result = await rule.evaluate(_case(), [_turn("answer")])
assert result.score == pytest.approx(1.0) # clamped 10/10 = 1.0
# ── Anthropic content-block format ────────────────────────────────────────
async def test_llm_score_handles_content_block_array():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
mock_resp = _make_content_block_response(score=7.5)
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=mock_resp)
result = await rule.evaluate(_case(), [_turn("answer")])
assert result.passed is True
assert result.score == pytest.approx(0.75)
# ── JSON fallback parsing ─────────────────────────────────────────────────
async def test_llm_score_parses_json_with_preamble():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={
"choices": [{"message": {"content": 'Sure! Here is the result: {"score": 7, "reason": "decent"}'}}]
})
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=mock_resp)
result = await rule.evaluate(_case(), [_turn("answer")])
assert result.passed is True
# ── error handling ────────────────────────────────────────────────────────
async def test_llm_score_api_error_fails_gracefully():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(side_effect=Exception("connection timeout"))
result = await rule.evaluate(_case(), [_turn("answer")])
assert result.passed is False
assert "timeout" in result.reason.lower() or "LLM" in result.reason
async def test_llm_score_empty_content_fails():
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 6})
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json = MagicMock(return_value={"choices": []})
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=mock_resp)
result = await rule.evaluate(_case(), [_turn("answer")])
assert result.passed is False
# ── question extraction ───────────────────────────────────────────────────
async def test_llm_score_extracts_question_from_sent_message():
"""Verifies that sent_message is used as the question when dialog has 1 turn."""
rule = LlmScoreRule({"api_url": "http://mock/v1/chat", "min_score": 5})
captured_payload = {}
mock_resp = _make_llm_response(score=8.0)
async def capture_post(url, **kwargs):
captured_payload.update(kwargs.get("json", {}))
return mock_resp
with patch("agenteval.evaluation.rules.llm_score.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(side_effect=capture_post)
await rule.evaluate(_case(), [_turn("答案内容", sent_text="这是用户的问题")])
# The user_prompt should contain the sent question text
messages = captured_payload.get("messages", [])
user_msg = next((m for m in messages if m["role"] == "user"), None)
assert user_msg is not None
assert "这是用户的问题" in user_msg["content"]

227
tests/unit/test_report.py Normal file
View File

@ -0,0 +1,227 @@
"""Unit tests for report generation: generate_report, generate_compare_report, render_markdown_report."""
import pytest
from sqlmodel import Session, SQLModel, create_engine
from agenteval.evaluation.report import (
generate_compare_report,
generate_report,
render_markdown_report,
render_json_report,
)
from agenteval.models import (
Case, CaseType, EvalResult, EvalRun, EvalTarget, RunStatus, Scenario, Turn,
PlatformType, ChannelType, TargetStatus,
)
from agenteval.storage.repository import ResultRepository, RunRepository, ScenarioRepository, TargetRepository
@pytest.fixture()
def report_session(tmp_path):
from agenteval.storage.db import ( # noqa: F401
EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, FileRecordDB, ScenarioDB, TurnDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'report_test.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
try:
yield session
finally:
session.close()
engine.dispose()
def _seed_run(session: Session, *, pass_rate: float = 1.0, n_cases: int = 1) -> str:
"""Create a minimal completed run with real data in the DB and return run_id."""
target = EvalTarget(
name="测试对象",
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
channel_type=ChannelType.TUTU_API,
channel_config={},
status=TargetStatus.ACTIVE,
)
target = TargetRepository(session).create(target)
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)
run = EvalRun(
target_id=target.id,
scenario_id=scenario.id,
status=RunStatus.COMPLETED,
)
run = RunRepository(session).create(run)
run_repo = RunRepository(session)
result_repo = ResultRepository(session)
total = n_cases
passed = int(total * pass_rate)
for i in range(n_cases):
turn = Turn(
run_id=run.id,
case_id=f"c{i}",
round_index=1,
sent_message={"msgBody": {"content": f"问题{i}"}},
reply={"msgBody": {"content": f"回答{i}"}},
latency_ms=200,
)
result_repo.save_turn(turn)
db_turn = run_repo.get_turns(run.id)[-1]
eval_result = EvalResult(
run_id=run.id,
case_id=f"c{i}",
turn_id=db_turn.id or "",
rule_type="keyword_match",
passed=(i < passed),
score=1.0 if i < passed else 0.0,
reason="通过" if i < passed else "失败",
)
result_repo.save_result(eval_result)
run.summary = {
"total_cases": total,
"passed_cases": passed,
"failed_cases": total - passed,
"total_rules": total,
"passed_rules": passed,
"pass_rate": round(pass_rate, 4),
}
RunRepository(session).update(run)
return run.id
# ── generate_report ───────────────────────────────────────────────────────
def test_generate_report_structure(report_session):
run_id = _seed_run(report_session)
report = generate_report(run_id, report_session)
assert report["run_id"] == run_id
assert report["target_name"] == "测试对象"
assert report["scenario_name"] == "测试场景"
assert report["status"] == "completed"
assert "summary" in report
assert "cases" in report
def test_generate_report_summary_values(report_session):
run_id = _seed_run(report_session, pass_rate=1.0, n_cases=2)
report = generate_report(run_id, report_session)
s = report["summary"]
assert s["total_cases"] == 2
assert s["passed_cases"] == 2
assert s["pass_rate"] == 1.0
def test_generate_report_partial_pass(report_session):
run_id = _seed_run(report_session, pass_rate=0.5, n_cases=2)
report = generate_report(run_id, report_session)
s = report["summary"]
assert s["passed_cases"] == 1
assert s["failed_cases"] == 1
def test_generate_report_cases_contain_turns_and_results(report_session):
run_id = _seed_run(report_session, n_cases=1)
report = generate_report(run_id, report_session)
assert len(report["cases"]) == 1
case = report["cases"][0]
assert len(case["turns"]) == 1
assert len(case["results"]) == 1
assert case["turns"][0]["sent_text"] == "问题0"
assert case["turns"][0]["latency_ms"] == 200
def test_generate_report_not_found_raises(report_session):
with pytest.raises(ValueError, match="run not found"):
generate_report("no-such-id", report_session)
# ── generate_compare_report ───────────────────────────────────────────────
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)
result = generate_compare_report(run_id_a, run_id_b, report_session)
assert "run_a" in result
assert "run_b" in result
assert "delta" in result
assert "cases" in result
assert result["run_a"]["run_id"] == run_id_a
assert result["run_b"]["run_id"] == run_id_b
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)
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)
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
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)
result = generate_compare_report(run_id_a, run_id_b, report_session)
assert len(result["cases"]) >= 1
case = result["cases"][0]
assert "run_a_passed" in case
assert "run_b_passed" in case
assert "changed" in case
# ── render_markdown_report ────────────────────────────────────────────────
def test_render_markdown_contains_header(report_session):
run_id = _seed_run(report_session)
md = render_markdown_report(run_id, report_session)
assert "# 评测报告" in md
def test_render_markdown_contains_summary_table(report_session):
run_id = _seed_run(report_session)
md = render_markdown_report(run_id, report_session)
assert "## 汇总" in md
assert "| 指标 | 数值 |" in md
assert "通过率" in md
def test_render_markdown_contains_case_section(report_session):
run_id = _seed_run(report_session, n_cases=1)
md = render_markdown_report(run_id, report_session)
assert "## 用例明细" in md
assert "### " in md # case header
def test_render_markdown_contains_rule_table(report_session):
run_id = _seed_run(report_session)
md = render_markdown_report(run_id, report_session)
assert "**规则评估结果**" in md
assert "keyword_match" in md
# ── render_json_report ────────────────────────────────────────────────────
def test_render_json_report_is_valid_json(report_session):
import json
run_id = _seed_run(report_session)
json_text = render_json_report(run_id, report_session)
parsed = json.loads(json_text)
assert parsed["run_id"] == run_id

View File

@ -0,0 +1,159 @@
"""Unit tests for scenarios loader and templates."""
import json
import tempfile
from pathlib import Path
import pytest
import yaml
from agenteval.scenarios.loader import load_scenario_file, validate_scenario, ScenarioValidationError
from agenteval.scenarios.templates import get_template, list_templates
# ── templates ─────────────────────────────────────────────────────────────
def test_list_templates_returns_list():
tpls = list_templates()
assert isinstance(tpls, list)
assert len(tpls) >= 6
def test_template_has_required_fields():
for tpl in list_templates():
assert "id" in tpl
assert "name" in tpl
assert "description" in tpl
assert "cases" in tpl
assert isinstance(tpl["cases"], list)
assert len(tpl["cases"]) > 0
def test_get_template_by_id():
tpl = get_template("tpl-single-qa")
assert tpl is not None
assert tpl["id"] == "tpl-single-qa"
def test_get_template_not_found():
assert get_template("no-such-template") is None
def test_all_templates_have_valid_case_ids():
for tpl in list_templates():
for case in tpl["cases"]:
assert "id" in case
assert "type" in case
def test_template_eval_rules_reference_valid_types():
valid_types = {"keyword_match", "response_time", "llm_score", "semantic_similarity", "json_schema", "safety"}
for tpl in list_templates():
for case in tpl["cases"]:
for rule in case.get("eval_rules", []):
assert rule["type"] in valid_types, f"Unknown rule type {rule['type']} in template {tpl['id']}"
# ── scenario loader ───────────────────────────────────────────────────────
def _write_tmp(content: str, suffix: str) -> Path:
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8")
f.write(content)
f.flush()
return Path(f.name)
def test_load_json_scenario():
data = {
"name": "JSON 场景",
"cases": [{"id": "c1", "type": "single", "messages": ["你好"]}],
}
path = _write_tmp(json.dumps(data), ".json")
scenario = load_scenario_file(path)
assert scenario.name == "JSON 场景"
assert len(scenario.cases) == 1
path.unlink()
def test_load_yaml_scenario():
data = {
"name": "YAML 场景",
"cases": [{"id": "c1", "type": "single", "messages": ["Hello"]}],
}
path = _write_tmp(yaml.dump(data, allow_unicode=True), ".yaml")
scenario = load_scenario_file(path)
assert scenario.name == "YAML 场景"
path.unlink()
def test_load_yml_extension():
data = {
"name": "YML 场景",
"cases": [{"id": "c1", "type": "single", "messages": ["test"]}],
}
path = _write_tmp(yaml.dump(data, allow_unicode=True), ".yml")
scenario = load_scenario_file(path)
assert scenario.name == "YML 场景"
path.unlink()
def test_load_scenario_with_tags():
data = {
"name": "带标签",
"tags": ["health", "basic"],
"cases": [{"id": "c1", "type": "single", "messages": ["hi"]}],
}
path = _write_tmp(json.dumps(data), ".json")
scenario = load_scenario_file(path)
assert "health" in scenario.tags
path.unlink()
def test_load_scenario_with_eval_rules():
data = {
"name": "含规则",
"cases": [{
"id": "c1", "type": "single", "messages": ["hi"],
"eval_rules": [{"type": "response_time", "params": {"max_ms": 5000}}],
}],
}
path = _write_tmp(json.dumps(data), ".json")
scenario = load_scenario_file(path)
assert len(scenario.cases[0].eval_rules) == 1
assert scenario.cases[0].eval_rules[0].type == "response_time"
path.unlink()
def test_load_scenario_file_not_found():
with pytest.raises(ScenarioValidationError, match="not found"):
load_scenario_file(Path("/tmp/nonexistent-scenario-xyz.json"))
def test_load_unsupported_extension():
path = Path(_write_tmp("{}", ".txt"))
with pytest.raises(ScenarioValidationError, match="unsupported"):
load_scenario_file(path)
path.unlink()
def test_validate_scenario_valid():
data = {
"name": "有效场景",
"cases": [{"id": "c1", "type": "single", "messages": ["hi"]}],
}
valid, errors = validate_scenario(data)
assert valid is True
assert errors == []
def test_validate_scenario_missing_name():
data = {"cases": [{"id": "c1", "type": "single", "messages": ["hi"]}]}
valid, errors = validate_scenario(data)
assert valid is False
assert len(errors) > 0
def test_validate_scenario_empty_cases():
data = {"name": "空用例", "cases": []} # empty list triggers the cases_not_empty validator
valid, errors = validate_scenario(data)
assert valid is False

View File

@ -0,0 +1,149 @@
"""Unit tests for agenteval.utils.llm — the shared LLM utility functions."""
import json
import pytest
from agenteval.utils.llm import extract_reply_text, extract_content_from_llm_response, parse_json_from_llm_text
# ── extract_reply_text ───────────────────────────────────────────────────
def test_extract_reply_text_none():
assert extract_reply_text(None) == ""
def test_extract_reply_text_str():
assert extract_reply_text("hello") == "hello"
def test_extract_reply_text_dict_msgbody_dict():
msg = {"msgBody": {"content": "inner text"}}
assert extract_reply_text(msg) == "inner text"
def test_extract_reply_text_dict_msgbody_str():
msg = {"msgBody": "plain body"}
assert extract_reply_text(msg) == "plain body"
def test_extract_reply_text_dict_content_key():
msg = {"content": "direct content"}
assert extract_reply_text(msg) == "direct content"
def test_extract_reply_text_dict_msgbody_none_fallback_content():
# msgBody is falsy → falls back to content key
msg = {"msgBody": None, "content": "fallback"}
assert extract_reply_text(msg) == "fallback"
def test_extract_reply_text_int_coerces_to_str():
assert extract_reply_text(42) == "42"
def test_extract_reply_text_empty_dict():
assert extract_reply_text({}) == ""
# ── extract_content_from_llm_response ────────────────────────────────────
def test_extract_content_string_format():
data = {"choices": [{"message": {"content": "answer text"}}]}
assert extract_content_from_llm_response(data) == "answer text"
def test_extract_content_block_array_text():
data = {
"choices": [{
"message": {
"content": [
{"type": "text", "text": "block one"},
{"type": "text", "text": "block two"},
]
}
}]
}
result = extract_content_from_llm_response(data)
assert "block one" in result
assert "block two" in result
def test_extract_content_block_array_skips_non_text():
data = {
"choices": [{
"message": {
"content": [
{"type": "tool_use", "id": "t1", "input": {}},
{"type": "text", "text": "real answer"},
]
}
}]
}
result = extract_content_from_llm_response(data)
assert result == "real answer"
def test_extract_content_block_uses_content_key_fallback():
# Some providers use "content" instead of "text" inside blocks
data = {
"choices": [{
"message": {
"content": [{"type": "text", "content": "via content key"}]
}
}]
}
assert extract_content_from_llm_response(data) == "via content key"
def test_extract_content_missing_choices():
assert extract_content_from_llm_response({}) == ""
def test_extract_content_empty_choices():
assert extract_content_from_llm_response({"choices": []}) == ""
def test_extract_content_integer_coerced():
data = {"choices": [{"message": {"content": 123}}]}
assert extract_content_from_llm_response(data) == "123"
def test_extract_content_empty_block_list():
data = {"choices": [{"message": {"content": []}}]}
assert extract_content_from_llm_response(data) == ""
# ── parse_json_from_llm_text ─────────────────────────────────────────────
def test_parse_json_object():
result = parse_json_from_llm_text('{"score": 8, "reason": "good"}')
assert result["score"] == 8
def test_parse_json_array():
result = parse_json_from_llm_text('["a", "b", "c"]')
assert result == ["a", "b", "c"]
def test_parse_json_with_surrounding_text():
text = 'Here is the result: {"score": 7} and nothing else.'
result = parse_json_from_llm_text(text)
assert result["score"] == 7
def test_parse_json_array_with_preamble():
text = 'Generated questions: ["q1", "q2", "q3"]'
result = parse_json_from_llm_text(text)
assert result == ["q1", "q2", "q3"]
def test_parse_json_raises_on_no_json():
with pytest.raises((ValueError, json.JSONDecodeError)):
parse_json_from_llm_text("no json here at all")
def test_parse_json_markdown_wrapped():
text = '```json\n{"key": "value"}\n```'
# The fallback bracket-search finds the { in the markdown
result = parse_json_from_llm_text(text)
assert result["key"] == "value"

View File

@ -0,0 +1,84 @@
"""Unit tests for webhook notification utility."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
async def test_webhook_not_called_when_url_not_configured():
with patch("agenteval.utils.webhook.get_settings") as mock_settings:
mock_settings.return_value.webhook_url = None
mock_settings.return_value.webhook_secret = None
with patch("agenteval.utils.webhook.httpx.AsyncClient") as MockClient:
from agenteval.utils.webhook import send_run_webhook
await send_run_webhook("run-1", "completed", {"pass_rate": 1.0})
MockClient.assert_not_called()
async def test_webhook_posts_correct_payload():
captured = {}
async def fake_post(url, *, json=None, headers=None, **kwargs):
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
resp = MagicMock()
resp.status_code = 200
return resp
with patch("agenteval.utils.webhook.get_settings") as mock_settings:
mock_settings.return_value.webhook_url = "http://hook.example.com/notify"
mock_settings.return_value.webhook_secret = "secret123"
with patch("agenteval.utils.webhook.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(side_effect=fake_post)
from agenteval.utils.webhook import send_run_webhook
await send_run_webhook("run-42", "completed", {"pass_rate": 0.9})
assert captured["url"] == "http://hook.example.com/notify"
assert captured["json"]["run_id"] == "run-42"
assert captured["json"]["status"] == "completed"
assert captured["json"]["event"] == "run_completed"
assert captured["json"]["summary"]["pass_rate"] == 0.9
assert captured["headers"]["X-Webhook-Secret"] == "secret123"
assert "/api/reports/run-42" in captured["json"]["report_url"]
async def test_webhook_no_secret_header():
captured_headers = {}
async def fake_post(url, *, json=None, headers=None, **kwargs):
captured_headers.update(headers or {})
resp = MagicMock()
resp.status_code = 200
return resp
with patch("agenteval.utils.webhook.get_settings") as mock_settings:
mock_settings.return_value.webhook_url = "http://hook.example.com/notify"
mock_settings.return_value.webhook_secret = None
with patch("agenteval.utils.webhook.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(side_effect=fake_post)
from agenteval.utils.webhook import send_run_webhook
await send_run_webhook("run-1", "completed", {})
assert "X-Webhook-Secret" not in captured_headers
async def test_webhook_silently_ignores_errors():
with patch("agenteval.utils.webhook.get_settings") as mock_settings:
mock_settings.return_value.webhook_url = "http://unreachable.example.com"
mock_settings.return_value.webhook_secret = None
with patch("agenteval.utils.webhook.httpx.AsyncClient") as MockClient:
instance = MockClient.return_value.__aenter__.return_value
instance.post = AsyncMock(side_effect=Exception("connection refused"))
from agenteval.utils.webhook import send_run_webhook
# Must not raise — errors are silently logged
await send_run_webhook("run-1", "failed", {})