AgentEvalTool/tests/unit/test_intelligence_jobs_settlement_characterization.py
sinohqb 7eae6de52d refactor(evaluation/storage): 结算统一与 repository 拆分(Phase 2 + 3)
合并两个不可分割的深化:

Phase 2 — 智能作业结算统一(ADR-0012)
- intelligence_jobs.execute(job_kind, campaign_id, ...) 作为结算的
  唯一实现:建行 → 认领 → 校验 → generating → 落账,一处编排、
  一处截断(500 字符)。两个 executor 退化为 ensure_queued /
  validate / work_fn 三个小 adapter。
- analysis.validate_analysis_request() 共享校验入口(活动终态 →
  模型),路由捕获映射 400、executor 捕获落 failed 行,与
  validate_comparison_request 先例同构。
- campaign_runner._auto_start_analysis 的跳过守卫收敛至
  auto_intelligence_eligible 单一判断点。
- comparison.py 删除零调用的 build_comparison_payload;
  load_comparison_view 投影归位至 campaign_read_model。
- 新增 characterization 测试(认领竞争、重复触发、截断、恢复上限)。

Phase 3 — storage/repository.py 拆分
- AsyncJobRepository 及两个子类迁至
  storage/async_job_repository.py(Phase 2 的 intelligence_jobs
  与 comparison 必须 import 自该路径,故与 Phase 2 同 commit)。
- ExplorationSession / ExplorationMessage 迁至
  storage/exploration_repository.py;repository.py 由 1180 行降至
  约 814 行,grep 确认无残留符号。
- exploration 子模块与路由 import 全部更新;测试 import 跟随。

刻意不做:CAS 共享原语、app.py 五 registry 关停顺序归一
(ADR-0006 精神,等真实需求出现再议)。
2026-08-24 05:50:27 +08:00

331 lines
12 KiB
Python
Raw Permalink 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.

"""Characterization tests: intelligence-job settlement contract (Phase 2.8).
锁定两个 executor智能分析 / 周期对比)的结算契约边界,供
``execute(job_kind, work_fn)`` 接缝收敛Phase 2.92.11)对照:
1. 认领竞争:两个执行流观察同一 queued 行,只有一个真正跑领域工作。
2. 重复触发completed 行被再次触发时重置回 queued 并重跑generating
中的行不被回退(后者已有 test_reenqueue_does_not_move_generating_*)。
3. 失败截断work 异常落账时 error 截断到 500 字符(一处语义)。
4. 恢复上限:``recover_campaign_intelligence_jobs`` 把超过恢复次数上限
的 queued 行落 failed不再重启。
不改动任何生产代码。
"""
import asyncio
import json
from datetime import timedelta
import pytest
from agenteval.evaluation import intelligence_jobs
from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, EvalRun, RunStatus, RunSummary
from agenteval.storage.db import (
CampaignAnalysisDB,
ModelConfigDB,
utc_now,
)
from agenteval.storage.model_config_repository import ModelConfigRepository
from agenteval.storage.async_job_repository import CampaignAnalysisRepository, CampaignPeriodComparisonRepository
from agenteval.storage.repository import (
CampaignRepository,
RunRepository,
)
from sqlmodel import Session, SQLModel, create_engine
T0 = utc_now().replace(tzinfo=None) - timedelta(hours=1)
STAGE1 = json.dumps({"narrative": "n", "problems": []})
STAGE2 = json.dumps({"overall": "o", "problems": [], "suggestions": []})
NARRATION = json.dumps({"trend": "stable", "summary": "s"})
@pytest.fixture()
def db_session(tmp_path):
from agenteval.storage.db import ( # noqa: F401
CampaignDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'settlement.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
try:
yield session
finally:
session.close()
engine.dispose()
class FakeChatClient:
def __init__(self, *responses):
self._responses = list(responses)
self.calls = 0
async def __call__(self, messages):
self.calls += 1
if not self._responses:
raise AssertionError("unexpected extra LLM call")
item = self._responses.pop(0)
if isinstance(item, Exception):
raise item
return item
def _campaign(
campaign_id: str = "camp-1",
*,
time_scale: float = 1.0,
status: CampaignStatus = CampaignStatus.COMPLETED,
completed_at=T0 + timedelta(hours=1),
) -> Campaign:
return Campaign(
id=campaign_id,
name=f"campaign-{campaign_id}",
target_id="t-1",
window_seconds=86400,
time_scale=time_scale,
plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=2)],
status=status,
completed_at=completed_at,
)
def _seed_config(session) -> None:
ModelConfigRepository(session).create(
ModelConfigDB(
id="mc-default",
name="cfg",
provider="openai_compatible",
capability="chat",
endpoint_url="https://models.example.com/v1/chat/completions",
model_name="m",
is_analysis_default=True,
)
)
def _seed_failed_run(session, campaign_id: str = "camp-1") -> None:
from agenteval.storage.db import EvalResultDB, TurnDB
RunRepository(session).create(
EvalRun(
id=f"run-{campaign_id}",
target_id="t-1",
scenario_id="s-1",
campaign_id=campaign_id,
status=RunStatus.COMPLETED,
)
)
turn = TurnDB(id=f"run-{campaign_id}-turn-0", run_id=f"run-{campaign_id}", case_id="c0", round_index=0)
turn.set_sent_message({"msgBody": {"content": "用户消息"}})
turn.set_reply({"msgBody": {"content": ""}})
session.add(turn)
session.add(
EvalResultDB(
run_id=f"run-{campaign_id}",
case_id="c0",
turn_id=turn.id,
rule_type="llm_score",
passed=False,
reason="不合格",
)
)
session.commit()
class TestClaimRace:
"""两个执行流竞争同一 queued 行:只有一个跑领域工作。"""
async def test_concurrent_analysis_jobs_run_work_once(self, db_session):
_seed_config(db_session)
CampaignRepository(db_session).create(_campaign())
_seed_failed_run(db_session)
CampaignAnalysisRepository(db_session).enqueue("camp-1", triggered_by="auto")
client = FakeChatClient(STAGE1, STAGE2)
await asyncio.gather(
intelligence_jobs.execute_campaign_analysis_job(
"camp-1",
triggered_by="auto",
chat_client=client,
session_factory=lambda: db_session,
),
intelligence_jobs.execute_campaign_analysis_job(
"camp-1",
triggered_by="auto",
chat_client=client,
session_factory=lambda: db_session,
),
)
assert client.calls == 2 # 阶段一 1 场景 + 阶段二 1 次 = 恰好一次完整工作
row = CampaignAnalysisRepository(db_session).get_by_campaign("camp-1")
assert row.status == "completed"
async def test_generating_row_blocks_second_executor(self, db_session):
"""已在 generating 的行再次进入 executor不认领、不跑工作。"""
_seed_config(db_session)
CampaignRepository(db_session).create(_campaign())
CampaignAnalysisRepository(db_session).upsert("camp-1", status="generating", triggered_by="auto")
client = FakeChatClient(STAGE1, STAGE2)
await intelligence_jobs.execute_campaign_analysis_job(
"camp-1",
triggered_by="auto",
chat_client=client,
session_factory=lambda: db_session,
)
assert client.calls == 0
assert CampaignAnalysisRepository(db_session).get_by_campaign("camp-1").status == "generating"
class TestRetriggerSemantics:
"""completed 行再触发 → 重置 queued 并重跑queued 重复入队保持原样。"""
def test_retrigger_of_completed_campaign_resets_and_relaunches(self, db_session, monkeypatch):
"""重复触发幂等观察completed 行经 enqueue 重置回 queued 并重启 worker。"""
repo = CampaignAnalysisRepository(db_session)
repo.upsert("camp-1", status="completed", result={"overall": "旧结论"}, triggered_by="auto")
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_analysis",
lambda cid, *, triggered_by: launched.append((cid, triggered_by)),
)
intelligence_jobs.enqueue_campaign_analysis("camp-1", triggered_by="manual", session=db_session)
row = repo.get_by_campaign("camp-1")
assert row.status == "queued"
assert row.result is None
assert launched == [("camp-1", "manual")]
def test_reenqueue_completed_row_resets_to_queued(self, db_session):
repo = CampaignAnalysisRepository(db_session)
repo.upsert("camp-1", status="completed", result={"overall": ""}, triggered_by="auto")
row = repo.enqueue("camp-1", triggered_by="manual")
assert row.status == "queued"
assert row.result is None
assert row.triggered_by == "manual"
def test_reenqueue_completed_comparison_resets_and_replaces_baseline(self, db_session):
repo = CampaignPeriodComparisonRepository(db_session)
repo.upsert(
"camp-cur",
status="completed",
baseline_campaign_id="camp-old",
result={"trend": "stable"},
triggered_by="auto",
)
row = repo.enqueue("camp-cur", baseline_campaign_id="camp-new", triggered_by="manual")
assert row.status == "queued"
assert row.baseline_campaign_id == "camp-new"
assert row.result is None
class TestFailureTruncation:
"""work 异常落账error 截断到 500 字符。"""
async def test_analysis_error_truncated_to_500(self, db_session):
_seed_config(db_session)
CampaignRepository(db_session).create(_campaign())
_seed_failed_run(db_session)
client = FakeChatClient(RuntimeError("" * 800))
await intelligence_jobs.execute_campaign_analysis_job(
"camp-1",
triggered_by="auto",
chat_client=client,
session_factory=lambda: db_session,
)
row = CampaignAnalysisRepository(db_session).get_by_campaign("camp-1")
assert row.status == "failed"
assert len(row.error) == 500
async def test_comparison_error_truncated_to_500(self, db_session):
_seed_config(db_session)
baseline, current = _campaign("camp-base"), _campaign("camp-cur")
CampaignRepository(db_session).create(baseline)
CampaignRepository(db_session).create(current)
for cid in ("camp-base", "camp-cur"):
analysis = CampaignAnalysisDB(campaign_id=cid, status="completed")
analysis.set_result({"overall": cid})
db_session.add(analysis)
RunRepository(db_session).create(
EvalRun(
id=f"run-{cid}",
target_id="t-1",
scenario_id="s-1",
campaign_id=cid,
status=RunStatus.COMPLETED,
started_at=utc_now(),
summary=RunSummary(total_cases=2, pass_rate=0.5, avg_latency_ms=700),
)
)
db_session.commit()
client = FakeChatClient(RuntimeError("" * 800))
await intelligence_jobs.execute_campaign_comparison_job(
"camp-cur",
triggered_by="manual",
baseline_campaign_id="camp-base",
chat_client=client,
session_factory=lambda: db_session,
)
row = CampaignPeriodComparisonRepository(db_session).get_by_campaign("camp-cur")
assert row.status == "failed"
assert len(row.error) == 500
class TestRecoveryCap:
"""recover_campaign_intelligence_jobs超限落 failed中断行计入返回。"""
def test_exhausted_queued_job_fails_instead_of_relaunch(self, db_session, monkeypatch):
repo = CampaignAnalysisRepository(db_session)
repo.enqueue("camp-1", triggered_by="auto")
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_analysis",
lambda cid, *, triggered_by: launched.append(cid),
)
for _ in range(intelligence_jobs.MAX_QUEUED_RECOVERY_ATTEMPTS):
intelligence_jobs.recover_campaign_intelligence_jobs(db_session)
assert launched == ["camp-1"] * intelligence_jobs.MAX_QUEUED_RECOVERY_ATTEMPTS
interrupted, relaunched = intelligence_jobs.recover_campaign_intelligence_jobs(db_session)
assert relaunched == 0
assert launched == ["camp-1"] * intelligence_jobs.MAX_QUEUED_RECOVERY_ATTEMPTS
row = repo.get_by_campaign("camp-1")
assert row.status == "failed"
assert "上限" in row.error
def test_recovery_reports_interrupted_generating_rows(self, db_session, monkeypatch):
CampaignAnalysisRepository(db_session).upsert("camp-a", status="generating", triggered_by="auto")
CampaignPeriodComparisonRepository(db_session).upsert(
"camp-c",
status="generating",
baseline_campaign_id="camp-base",
triggered_by="auto",
)
monkeypatch.setattr(intelligence_jobs, "_launch_analysis", lambda *a, **kw: None)
monkeypatch.setattr(intelligence_jobs, "_launch_comparison", lambda *a, **kw: None)
interrupted, relaunched = intelligence_jobs.recover_campaign_intelligence_jobs(db_session)
assert interrupted == 2
assert relaunched == 0
assert CampaignAnalysisRepository(db_session).get_by_campaign("camp-a").status == "failed"
assert CampaignPeriodComparisonRepository(db_session).get_by_campaign("camp-c").status == "failed"