- Add metrics.py with pool utilization, task backlog, stuck rate, avg processing time, eval completion rate - Add alerts.py with alert rules (pool utilization > 90%, task backlog > 50, stuck rate > 10%) - Implement alert history and webhook notifications - Add metrics and alerts APIs - Add database migration for alert history table - Add 11 unit tests for metrics, 10 unit tests for alerts, 8 integration tests - Update migration tests to include new alert history table All 853 tests passing.
212 lines
8.6 KiB
Python
212 lines
8.6 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 intelligent_eval_config_snapshots"))
|
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_decision_logs"))
|
|
connection.execute(text("DROP TABLE IF EXISTS intelligent_eval_task_queue"))
|
|
connection.execute(text("DROP TABLE IF EXISTS openclaw_cron_pool"))
|
|
connection.execute(text("DROP TABLE IF EXISTS cron_pool_alert_history"))
|
|
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
|