AgentEvalTool/tests/integration/test_exploration_patrol_api.py
sinohqb 1317552701 feat(intelligent-eval): add backend for OpenClaw-driven intelligent evaluation (tickets 01-04)
Introduce 智能评估 as an evaluation paradigm parallel to static evaluation,
driven by OpenClaw. The platform supplies storage, lifecycle, and reporting;
OpenClaw plans and executes.

- Data model: IntelligentEval + Session + Message tables (new, not reusing exploration)
- Lifecycle state machine: draft → planning → pending_approval → executing → completed/cancelled/failed
- Session API: create/message (channel-forwarded)/close with turn accounting
- Report API: pydantic-validated structured report, executing → completed, Markdown export (pure renderer)
- Alembic migration for the three tables; domain glossary added to CONTEXT.md
2026-08-05 03:18:52 +08:00

207 lines
8.2 KiB
Python

"""Integration tests for the exploration patrol API (v0.9 票据 03).
One stateless call returns every running production-line campaign that
participates in exploration, the new results since the last patrol watermark
(reusing the campaign report aggregation), and the remaining exploration
budget. The watermark advances after each call so subsequent calls only
report increments.
"""
from datetime import timedelta
import pytest
from agenteval.models import Campaign, EvalRun, RunStatus, RunSummary
from agenteval.storage.db import utc_now
from agenteval.storage.repository import CampaignRepository, RunRepository
from agenteval.web.app import app
from httpx import ASGITransport, AsyncClient
SEEDS = {"personas": ["急性子用户"], "goals": ["查询账单并缴费"]}
def _make_campaign(campaign_id: str, *, time_scale: float = 1.0, status: str = "running", seeds=SEEDS) -> Campaign:
return Campaign(
id=campaign_id,
name=f"campaign-{campaign_id}",
target_id="t-1",
window_seconds=86400,
time_scale=time_scale,
plan=[{"scenario_id": "s-1", "offset_seconds": 0, "count": 1}],
status=status,
started_at=utc_now() - timedelta(hours=2),
exploration_seeds=seeds,
)
def _seed_run(db_session, run_id: str, campaign_id: str, *, pass_rate: float, completed_at) -> None:
RunRepository(db_session).create(EvalRun(
id=run_id, target_id="t-1", scenario_id="s-1", campaign_id=campaign_id,
status=RunStatus.COMPLETED, started_at=completed_at - timedelta(minutes=5),
completed_at=completed_at,
summary=RunSummary(total_cases=2, pass_rate=pass_rate, avg_latency_ms=120.0),
))
@pytest.fixture()
def seeded_db(db_session, monkeypatch):
from agenteval.models import ChannelType, EvalTarget, PlatformType, TargetStatus
from agenteval.storage import db as db_module
from agenteval.storage import repository as repo_module
from agenteval.storage.repository import TargetRepository
from agenteval.web import app as app_module
monkeypatch.setattr(app_module, "init_db", lambda: None)
def _test_get_session():
return db_session
monkeypatch.setattr(db_module, "get_session", _test_get_session)
monkeypatch.setattr(repo_module, "get_session", _test_get_session)
from agenteval.web.deps import get_db
def _test_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = _test_get_db
TargetRepository(db_session).create(EvalTarget(
id="t-1", name="mock-target",
platform=PlatformType.AI_DIGITAL_EMPLOYEE,
channel_type=ChannelType.TUTU_API,
channel_config={"base_url": "http://mock", "token": "x"},
status=TargetStatus.ACTIVE,
))
repo = CampaignRepository(db_session)
repo.create(_make_campaign("c-prod")) # 正式线,参与探索
repo.create(_make_campaign("c-fast", time_scale=24.0)) # 加速线 → 不巡检
repo.create(_make_campaign("c-noseed", seeds=None)) # 无种子集 → 不巡检
repo.create(_make_campaign("c-done", status="completed")) # 终态 → 不巡检
_seed_run(db_session, "r-1", "c-prod", pass_rate=1.0, completed_at=utc_now() - timedelta(hours=1))
_seed_run(db_session, "r-2", "c-prod", pass_rate=0.5, completed_at=utc_now() - timedelta(minutes=30))
yield db_session
app.dependency_overrides.clear()
@pytest.fixture()
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async def _patrol(client) -> dict:
resp = await client.get("/api/exploration/patrol")
assert resp.status_code == 200, resp.text
return resp.json()
async def test_patrol_filters_to_running_production_seeded_campaigns(client, seeded_db):
body = await _patrol(client)
ids = [c["campaign_id"] for c in body["campaigns"]]
assert ids == ["c-prod"]
async def test_patrol_entry_content(client, seeded_db):
body = await _patrol(client)
entry = body["campaigns"][0]
assert entry["campaign_name"] == "campaign-c-prod"
assert entry["target_id"] == "t-1"
assert entry["target_name"] == "mock-target"
assert entry["last_patrolled_at"] is None # 首次巡检无水位
new_results = entry["new_results"]
assert new_results is not None
assert new_results["summary"]["total_runs"] == 2
assert new_results["summary"]["overall_pass_rate"] == 0.75
assert new_results["capability_summary"][0]["scenario_id"] == "s-1"
budget = entry["budget"]
assert budget["max_sessions"] == 8
assert budget["sessions_used"] == 0
assert budget["remaining_sessions"] == 8
assert budget["max_turns"] == 12
assert budget["min_interval_seconds"] == 30 * 60
assert budget["seconds_since_last_session"] is None
async def test_patrol_watermark_advances_and_reports_increments(client, seeded_db):
await _patrol(client)
campaign = CampaignRepository(seeded_db).get("c-prod")
assert campaign.last_patrolled_at is not None
# 第二次巡检:无新完成的子运行 → 增量为空
body = await _patrol(client)
entry = body["campaigns"][0]
assert entry["new_results"] is None
# 水位之后新完成一个子运行 → 第三次巡检只报这一个
_seed_run(seeded_db, "r-3", "c-prod", pass_rate=0.0, completed_at=utc_now())
body = await _patrol(client)
entry = body["campaigns"][0]
assert entry["new_results"]["summary"]["total_runs"] == 1
assert entry["new_results"]["summary"]["overall_pass_rate"] == 0.0
async def test_patrol_budget_reflects_existing_sessions(client, seeded_db):
from agenteval.exploration.models import ExplorationSession
from agenteval.storage.repository import ExplorationSessionRepository
ExplorationSessionRepository(seeded_db).create(ExplorationSession(
campaign_id="c-prod", target_id="t-1", goal="查询账单", persona={"name": "x"},
))
body = await _patrol(client)
budget = body["campaigns"][0]["budget"]
assert budget["sessions_used"] == 1
assert budget["remaining_sessions"] == 7
assert budget["seconds_since_last_session"] is not None
async def test_patrol_budget_honours_campaign_override(client, seeded_db):
campaign = CampaignRepository(seeded_db).get("c-prod")
campaign.exploration_budget = {"max_sessions": 3}
CampaignRepository(seeded_db).update(campaign)
body = await _patrol(client)
budget = body["campaigns"][0]["budget"]
assert budget["max_sessions"] == 3
assert budget["remaining_sessions"] == 3
async def test_patrol_migration_column_on_existing_db(tmp_path, monkeypatch):
"""last_patrolled_at applies on a DB at the previous head."""
from pathlib import Path
from agenteval.storage import db as db_module
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from sqlmodel import SQLModel
database_url = f"sqlite:///{tmp_path / 'patrol.db'}"
monkeypatch.setattr(db_module, "DATABASE_URL", database_url)
config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
SQLModel.metadata.create_all(create_engine(database_url))
with create_engine(database_url).begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_messages"))
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_sessions"))
connection.execute(text("DROP TABLE IF EXISTS intelligent_evals"))
connection.execute(text("DROP TABLE IF EXISTS exploration_sessions"))
connection.execute(text("DROP TABLE IF EXISTS exploration_messages"))
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_seeds"))
connection.execute(text("ALTER TABLE campaigns DROP COLUMN exploration_budget"))
connection.execute(text("ALTER TABLE campaigns DROP COLUMN last_patrolled_at"))
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
command.stamp(config, "b3c7d9e1f5a2")
command.upgrade(config, "head")
cols = {c["name"] for c in inspect(create_engine(database_url)).get_columns("campaigns")}
assert "last_patrolled_at" in cols