AgentEvalTool/tests/integration/test_exploration_api.py
sinohqb 53afb9b5d1 feat(campaigns): exploration seed set and budget config per campaign
v0.9 ticket 02. Campaigns now carry an exploration seed set (seed
personas × seed goals — the comparability unit for exploratory
evaluation) and an optional budget override, stored as JSON columns
isomorphic to plan. Empty seeds normalize to null, marking the campaign
as opted out of exploration. resolve_budget merges per-field overrides
into platform defaults; enforcement stays server-side. The create form
gains seed lists and budget inputs (minutes → seconds), submitting null
when left empty.
2026-08-03 17:52:25 +08:00

391 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Integration tests for exploration session lifecycle + platform guardrails (v0.9 票据 01).
Uses ``httpx.AsyncClient`` with ``app=`` to drive the FastAPI app in-process.
Channel I/O is stubbed with MockChannel; tests cover the full lifecycle
(create → message → close), the three budget guardrails (409), trigger-source
gating per line tier, and experience-record normalization.
"""
from datetime import timedelta
import pytest
from agenteval.models import Campaign, ChannelType, EvalTarget, PlatformType, TargetStatus
from agenteval.storage.db import ExplorationSessionDB, utc_now
from agenteval.storage.repository import CampaignRepository, ExplorationSessionRepository, TargetRepository
from agenteval.web.app import app
from httpx import ASGITransport, AsyncClient
def _make_campaign(campaign_id: str, *, time_scale: float = 1.0, status: str = "running") -> 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(),
)
@pytest.fixture()
def seeded_db(db_session, monkeypatch):
"""Patch get_session/get_db to the test session and seed target + campaign."""
from agenteval.storage import db as db_module
from agenteval.storage import repository as repo_module
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
target = 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,
)
TargetRepository(db_session).create(target)
CampaignRepository(db_session).create(_make_campaign("c-1"))
yield db_session
app.dependency_overrides.clear()
def _stub_channel_factory(monkeypatch, channel) -> None:
"""Point the exploration router's ChannelFactory at a test channel."""
from agenteval.web.routers import exploration as exploration_module
class _StubFactory:
@staticmethod
def create(target):
return channel
monkeypatch.setattr(exploration_module, "ChannelFactory", _StubFactory)
@pytest.fixture()
def mock_channel(monkeypatch):
"""Stub ChannelFactory in the exploration router with a MockChannel."""
from tests.unit.mock_channel import MockChannel
channel = MockChannel(reply_text="您好,请问有什么可以帮您?")
_stub_channel_factory(monkeypatch, channel)
return channel
@pytest.fixture()
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
def _session_payload(**overrides) -> dict:
payload = {
"campaign_id": "c-1",
"persona": {"name": "急性子用户", "traits": ["急躁", "目标导向"]},
"goal": "查询本月账单并完成缴费",
"triggered_by": "auto",
}
payload.update(overrides)
return payload
def _rewind_latest_session(db_session, minutes: int = 31) -> None:
"""Move the newest session's created_at back so the interval guardrail passes."""
repo = ExplorationSessionRepository(db_session)
sessions = repo.list_by_campaign("c-1")
latest = max(sessions, key=lambda s: s.created_at)
row = db_session.get(ExplorationSessionDB, latest.id)
row.created_at = utc_now() - timedelta(minutes=minutes)
db_session.add(row)
db_session.commit()
async def _create_session(client, **overrides):
return await client.post("/api/exploration/sessions", json=_session_payload(**overrides))
# ---------------------------------------------------------------- lifecycle
async def test_full_lifecycle_create_message_close(seeded_db, mock_channel, client):
resp = await _create_session(client)
assert resp.status_code == 200, resp.text
session = resp.json()
assert session["status"] == "running"
assert session["campaign_id"] == "c-1"
assert session["target_id"] == "t-1"
assert session["persona"]["name"] == "急性子用户"
session_id = session["id"]
resp = await client.post(
f"/api/exploration/sessions/{session_id}/messages",
json={"content": "我要查这个月的账单"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["reply"] == "您好,请问有什么可以帮您?"
assert isinstance(body["latency_ms"], int)
assert body["turn_count"] == 1
repo = ExplorationSessionRepository(seeded_db)
assert repo.get(session_id).turn_count == 1
resp = await client.post(
f"/api/exploration/sessions/{session_id}/close",
json={
"experience": {
"goal_achieved": True,
"blockers": [],
"misled": [],
"emotion": "positive",
"notes": "顺利完成",
}
},
)
assert resp.status_code == 200, resp.text
closed = resp.json()
assert closed["status"] == "completed"
assert closed["experience"]["goal_achieved"] is True
assert closed["experience"]["emotion"] == "positive"
assert closed["closed_at"] is not None
async def test_create_requires_existing_running_campaign(seeded_db, client):
resp = await _create_session(client, campaign_id="nope")
assert resp.status_code == 404
CampaignRepository(seeded_db).create(_make_campaign("c-done", status="completed"))
resp = await _create_session(client, campaign_id="c-done")
assert resp.status_code == 409
assert "进行中" in resp.json()["detail"]
async def test_accelerated_line_accepts_manual_only(seeded_db, mock_channel, client):
CampaignRepository(seeded_db).create(_make_campaign("c-fast", time_scale=24.0))
resp = await _create_session(client, campaign_id="c-fast", triggered_by="auto")
assert resp.status_code == 409
assert "手动" in resp.json()["detail"]
resp = await _create_session(client, campaign_id="c-fast", triggered_by="manual")
assert resp.status_code == 200
async def test_session_budget_guardrail(seeded_db, mock_channel, client):
for _ in range(8):
resp = await _create_session(client)
assert resp.status_code == 200, resp.text
_rewind_latest_session(seeded_db)
resp = await _create_session(client)
assert resp.status_code == 409
assert "预算" in resp.json()["detail"]
async def test_session_interval_guardrail(seeded_db, mock_channel, client):
resp = await _create_session(client)
assert resp.status_code == 200
resp = await _create_session(client)
assert resp.status_code == 409
assert "间隔" in resp.json()["detail"]
_rewind_latest_session(seeded_db)
resp = await _create_session(client)
assert resp.status_code == 200
async def test_campaign_budget_override_limits_sessions(seeded_db, mock_channel, client):
"""活动级预算覆盖生效(票据 02max_sessions=1 时第二个会话被拒。"""
campaign = CampaignRepository(seeded_db).get("c-1")
campaign.exploration_budget = {"max_sessions": 1}
CampaignRepository(seeded_db).update(campaign)
assert (await _create_session(client)).status_code == 200
_rewind_latest_session(seeded_db)
resp = await _create_session(client)
assert resp.status_code == 409
assert "预算" in resp.json()["detail"]
async def test_turn_budget_guardrail(seeded_db, mock_channel, client):
session_id = (await _create_session(client)).json()["id"]
for i in range(12):
resp = await client.post(
f"/api/exploration/sessions/{session_id}/messages",
json={"content": f"{i + 1} 轮问题"},
)
assert resp.status_code == 200, resp.text
resp = await client.post(
f"/api/exploration/sessions/{session_id}/messages",
json={"content": "第 13 轮问题"},
)
assert resp.status_code == 409
assert "轮数" in resp.json()["detail"]
async def test_message_rejected_when_session_not_running(seeded_db, mock_channel, client):
session_id = (await _create_session(client)).json()["id"]
resp = await client.post(
f"/api/exploration/sessions/{session_id}/close",
json={"experience": {"goal_achieved": False}},
)
assert resp.status_code == 200
resp = await client.post(
f"/api/exploration/sessions/{session_id}/messages",
json={"content": "还在吗?"},
)
assert resp.status_code == 409
assert "进行中" in resp.json()["detail"]
async def test_message_unknown_session_returns_404(seeded_db, mock_channel, client):
resp = await client.post("/api/exploration/sessions/nope/messages", json={"content": "hi"})
assert resp.status_code == 404
async def test_channel_failure_returns_502_without_consuming_turn(seeded_db, monkeypatch, client):
from tests.unit.mock_channel import MockChannel
channel = MockChannel(send_ok=False)
_stub_channel_factory(monkeypatch, channel)
session_id = (await _create_session(client)).json()["id"]
resp = await client.post(
f"/api/exploration/sessions/{session_id}/messages",
json={"content": "你好"},
)
assert resp.status_code == 502
assert ExplorationSessionRepository(seeded_db).get(session_id).turn_count == 0
async def test_poll_timeout_consumes_turn_budget(seeded_db, monkeypatch, client):
"""消息已送达但等不到回复:账本仍计一轮(超时不可绕过轮数预算)。"""
from types import SimpleNamespace
from agenteval.web.routers import exploration as exploration_module
from tests.unit.mock_channel import MockChannel
channel = MockChannel(missing_reply=True)
_stub_channel_factory(monkeypatch, channel)
monkeypatch.setattr(exploration_module, "get_settings", lambda: SimpleNamespace(poll_reply_timeout=0.05))
session_id = (await _create_session(client)).json()["id"]
resp = await client.post(
f"/api/exploration/sessions/{session_id}/messages",
json={"content": "有人在吗"},
)
assert resp.status_code == 502
assert ExplorationSessionRepository(seeded_db).get(session_id).turn_count == 1
async def test_close_normalizes_experience(seeded_db, mock_channel, client):
session_id = (await _create_session(client)).json()["id"]
resp = await client.post(
f"/api/exploration/sessions/{session_id}/close",
json={
"experience": {
"blockers": [42, {"not": "a string"}],
"misled": "不是列表",
"emotion": "暴怒!!!",
}
},
)
assert resp.status_code == 200, resp.text
experience = resp.json()["experience"]
assert experience["goal_achieved"] is False
assert experience["blockers"] == ["42"]
assert experience["misled"] == []
assert experience["emotion"] == "neutral"
async def test_close_twice_rejected(seeded_db, mock_channel, client):
session_id = (await _create_session(client)).json()["id"]
payload = {"experience": {"goal_achieved": True}}
assert (await client.post(f"/api/exploration/sessions/{session_id}/close", json=payload)).status_code == 200
resp = await client.post(f"/api/exploration/sessions/{session_id}/close", json=payload)
assert resp.status_code == 409
# ---------------------------------------------------------------- migration
def test_exploration_migration_on_existing_db(tmp_path, monkeypatch):
"""Alembic migration applies on an existing DB at the previous head.
Brand-new DBs take the create_all path (exercised by every test above via
the db_session fixture, which creates the new tables from metadata).
"""
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
database_url = f"sqlite:///{tmp_path / 'exploration.db'}"
monkeypatch.setattr(db_module, "DATABASE_URL", database_url)
config = Config(str(Path(__file__).resolve().parents[2] / "alembic.ini"))
# 既有库先例:基线迁移假设 create_all 建好的表已存在,先 stamp 基线前状态
from sqlmodel import SQLModel
engine = create_engine(database_url)
SQLModel.metadata.create_all(engine)
engine.dispose()
with create_engine(database_url).begin() as connection:
from sqlalchemy import text
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("DROP TABLE IF EXISTS alembic_version"))
command.stamp(config, "f2a9b7c34d18")
command.upgrade(config, "head")
inspector = inspect(create_engine(database_url))
tables = set(inspector.get_table_names())
assert "exploration_sessions" in tables
assert "exploration_messages" in tables
session_cols = {c["name"] for c in inspector.get_columns("exploration_sessions")}
assert {
"campaign_id",
"target_id",
"persona",
"goal",
"seed_ref",
"status",
"triggered_by",
"experience",
"judge_review",
"turn_count",
"closed_at",
} <= session_cols
message_cols = {c["name"] for c in inspector.get_columns("exploration_messages")}
assert {"session_id", "round_index", "role", "content", "latency_ms"} <= message_cols