Some checks failed
CI / test (push) Failing after 33s
架构保养候选 3:探索生命周期的账本规则与状态机从 HTTP 层落入 exploration/lifecycle.py(open/conduct/close)与 patrol.py(巡检读模型), 违规改用类型化领域异常(NotFound/Guardrail/Channel),router 瘦回纯 HTTP 翻译(404/409/502 映射),领域层不再依赖 fastapi,可脱离 TestClient 直测(新增 12 个单元测试)。
219 lines
8.7 KiB
Python
219 lines
8.7 KiB
Python
"""探索生命周期领域模块直测(架构保养候选 3)。
|
||
|
||
账本规则与状态机从 HTTP 层落入 exploration/lifecycle.py 与 patrol.py,
|
||
用类型化领域异常(NotFound / Guardrail / Channel)表达违规,
|
||
脱离 TestClient 即可单测。
|
||
"""
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from agenteval.exploration import lifecycle, patrol
|
||
from agenteval.exploration.errors import ExplorationGuardrailError, ExplorationNotFoundError
|
||
from agenteval.exploration.models import ExplorationSessionStatus, ExplorationTrigger
|
||
from agenteval.models import (
|
||
Campaign,
|
||
CampaignPlanEntry,
|
||
CampaignStatus,
|
||
EvalRun,
|
||
EvalTarget,
|
||
ExplorationBudgetConfig,
|
||
ExplorationSeeds,
|
||
RunStatus,
|
||
)
|
||
from agenteval.storage.repository import (
|
||
CampaignRepository,
|
||
ExplorationSessionRepository,
|
||
RunRepository,
|
||
TargetRepository,
|
||
)
|
||
from sqlmodel import Session, SQLModel, create_engine
|
||
|
||
T0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||
|
||
|
||
@pytest.fixture()
|
||
def db_session(tmp_path):
|
||
from agenteval.storage.db import ( # noqa: F401
|
||
CampaignDB,
|
||
EvalResultDB,
|
||
EvalRunDB,
|
||
EvalTargetDB,
|
||
ExplorationMessageDB,
|
||
ExplorationSessionDB,
|
||
FileCategoryDB,
|
||
FileRecordDB,
|
||
ScenarioDB,
|
||
TurnDB,
|
||
)
|
||
engine = create_engine(
|
||
f"sqlite:///{tmp_path / 'lifecycle.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_campaign(
|
||
db_session,
|
||
*,
|
||
status=CampaignStatus.RUNNING,
|
||
time_scale=1.0,
|
||
budget=None,
|
||
seeds=None,
|
||
) -> Campaign:
|
||
target = TargetRepository(db_session).create(EvalTarget(id=f"t-{uuid4().hex[:8]}", name="数字员工"))
|
||
campaign = CampaignRepository(db_session).create(Campaign(
|
||
name="cycle", target_id=target.id, window_seconds=3600, time_scale=time_scale,
|
||
plan=[CampaignPlanEntry(scenario_id="s-a", offset_seconds=0, count=1)],
|
||
status=status, started_at=T0,
|
||
exploration_budget=budget, exploration_seeds=seeds,
|
||
))
|
||
return campaign
|
||
|
||
|
||
def _open(db_session, campaign, triggered_by=ExplorationTrigger.AUTO, goal="查询账单"):
|
||
return lifecycle.open_session(
|
||
db_session,
|
||
campaign_id=campaign.id,
|
||
persona={"name": "急性子用户"},
|
||
goal=goal,
|
||
triggered_by=triggered_by,
|
||
)
|
||
|
||
|
||
# ── 账本四规则 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_open_rejects_non_running_campaign(db_session):
|
||
campaign = _seed_campaign(db_session, status=CampaignStatus.PLANNED)
|
||
with pytest.raises(ExplorationGuardrailError, match="活动不在进行中"):
|
||
_open(db_session, campaign)
|
||
|
||
|
||
def test_open_rejects_auto_on_accelerated_line_but_allows_manual(db_session):
|
||
campaign = _seed_campaign(db_session, time_scale=10.0)
|
||
with pytest.raises(ExplorationGuardrailError, match="加速调试线仅允许手动"):
|
||
_open(db_session, campaign)
|
||
session_obj = _open(db_session, campaign, triggered_by=ExplorationTrigger.MANUAL)
|
||
assert session_obj.status == ExplorationSessionStatus.RUNNING
|
||
|
||
|
||
def test_open_enforces_max_sessions_budget(db_session):
|
||
campaign = _seed_campaign(db_session, budget=ExplorationBudgetConfig(max_sessions=1))
|
||
_open(db_session, campaign, triggered_by=ExplorationTrigger.MANUAL)
|
||
with pytest.raises(ExplorationGuardrailError, match="探索会话数超出预算"):
|
||
_open(db_session, campaign, triggered_by=ExplorationTrigger.MANUAL)
|
||
|
||
|
||
def test_open_enforces_min_interval(db_session):
|
||
campaign = _seed_campaign(db_session) # 默认间隔 30 分钟
|
||
_open(db_session, campaign)
|
||
with pytest.raises(ExplorationGuardrailError, match="相邻探索会话间隔不足"):
|
||
_open(db_session, campaign)
|
||
|
||
|
||
def test_open_missing_campaign_or_target_raises_not_found(db_session):
|
||
with pytest.raises(ExplorationNotFoundError):
|
||
lifecycle.open_session(
|
||
db_session, campaign_id="missing", persona={}, goal="x",
|
||
)
|
||
campaign = _seed_campaign(db_session)
|
||
campaign.target_id = "missing-target"
|
||
CampaignRepository(db_session).update(campaign)
|
||
with pytest.raises(ExplorationNotFoundError):
|
||
lifecycle.open_session(
|
||
db_session, campaign_id=campaign.id, persona={}, goal="x",
|
||
)
|
||
|
||
|
||
# ── 关闭状态机 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_close_completes_session_and_normalizes_experience(db_session, monkeypatch):
|
||
judged: list[str] = []
|
||
monkeypatch.setattr(lifecycle, "start_judge_review", lambda sid: judged.append(sid))
|
||
campaign = _seed_campaign(db_session)
|
||
session_obj = _open(db_session, campaign)
|
||
|
||
updated = lifecycle.close_session(
|
||
db_session, session_id=session_obj.id,
|
||
experience={"goal_achieved": True, "blockers": ["入口难找"], "emotion": "weird"},
|
||
)
|
||
assert updated.status == ExplorationSessionStatus.COMPLETED
|
||
assert updated.closed_at is not None
|
||
assert updated.experience["emotion"] == "neutral" # 非法情绪归一
|
||
assert updated.experience["blockers"] == ["入口难找"]
|
||
assert judged == [session_obj.id]
|
||
|
||
|
||
def test_close_rejects_non_running_session(db_session, monkeypatch):
|
||
monkeypatch.setattr(lifecycle, "start_judge_review", lambda sid: None)
|
||
campaign = _seed_campaign(db_session)
|
||
session_obj = _open(db_session, campaign)
|
||
lifecycle.close_session(db_session, session_id=session_obj.id, experience={"goal_achieved": False})
|
||
with pytest.raises(ExplorationGuardrailError, match="不在进行中"):
|
||
lifecycle.close_session(db_session, session_id=session_obj.id, experience={"goal_achieved": False})
|
||
|
||
|
||
def test_close_missing_session_raises_not_found(db_session):
|
||
with pytest.raises(ExplorationNotFoundError):
|
||
lifecycle.close_session(db_session, session_id="missing", experience={})
|
||
|
||
|
||
# ── 对话轮账本(拒收路径;通道往返由集成测试覆盖)────────────────────────
|
||
|
||
|
||
async def test_turn_rejects_non_running_session(db_session):
|
||
campaign = _seed_campaign(db_session)
|
||
session_obj = _open(db_session, campaign)
|
||
lifecycle.close_session(db_session, session_id=session_obj.id, experience={"goal_achieved": True})
|
||
with pytest.raises(ExplorationGuardrailError, match="拒收消息"):
|
||
await lifecycle.conduct_turn(db_session, session_id=session_obj.id, content="你好")
|
||
|
||
|
||
async def test_turn_enforces_turn_budget(db_session):
|
||
campaign = _seed_campaign(db_session, budget=ExplorationBudgetConfig(max_turns=1, min_interval_seconds=1))
|
||
session_obj = _open(db_session, campaign)
|
||
repo = ExplorationSessionRepository(db_session)
|
||
session_obj.turn_count = 1
|
||
repo.update(session_obj)
|
||
with pytest.raises(ExplorationGuardrailError, match="会话轮数超出预算"):
|
||
await lifecycle.conduct_turn(db_session, session_id=session_obj.id, content="你好")
|
||
|
||
|
||
async def test_turn_missing_session_raises_not_found(db_session):
|
||
with pytest.raises(ExplorationNotFoundError):
|
||
await lifecycle.conduct_turn(db_session, session_id="missing", content="你好")
|
||
|
||
|
||
# ── 巡检:筛选 + 增量 + 水位 ──────────────────────────────────────────────
|
||
|
||
|
||
def test_patrol_filters_and_advances_watermark(db_session):
|
||
eligible = _seed_campaign(db_session, seeds=ExplorationSeeds(personas=["急性子用户"], goals=["查账单"]))
|
||
# 不符合巡检条件:加速线 / 无种子集
|
||
_seed_campaign(db_session, time_scale=10.0, seeds=ExplorationSeeds(personas=["慢用户"], goals=["y"]))
|
||
RunRepository(db_session).create(EvalRun(
|
||
target_id="t-1", scenario_id="s-a", campaign_id=eligible.id,
|
||
status=RunStatus.COMPLETED, started_at=T0, completed_at=T0 + timedelta(seconds=10),
|
||
summary={"total_cases": 1, "passed_cases": 1, "pass_rate": 1.0, "avg_latency_ms": 100},
|
||
))
|
||
|
||
first = patrol.patrol_report(db_session)
|
||
assert [e["campaign_id"] for e in first["campaigns"]] == [eligible.id]
|
||
assert first["campaigns"][0]["new_results"] is not None
|
||
|
||
# 水位推进后无增量;且只动水位列(状态不被覆写)
|
||
second = patrol.patrol_report(db_session)
|
||
assert second["campaigns"][0]["new_results"] is None
|
||
fresh = CampaignRepository(db_session).get(eligible.id)
|
||
assert fresh.last_patrolled_at is not None
|
||
assert fresh.status == CampaignStatus.RUNNING
|