AgentEvalTool/tests/unit/test_campaign_analysis.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

303 lines
12 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.

"""Two-phase campaign analysis orchestration (v0.7 ticket 03)."""
import json
import pytest
from agenteval.evaluation.analysis import (
AnalysisError,
analyze_campaign,
collect_failure_samples,
resolve_analysis_model,
)
from agenteval.evaluation.intelligence_jobs import execute_campaign_analysis_job
from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, EvalRun, RunStatus
from agenteval.storage.db import CampaignAnalysisDB, EvalResultDB, ModelConfigDB, TurnDB
from agenteval.storage.model_config_repository import ModelConfigRepository
from agenteval.storage.repository import CampaignRepository, RunRepository
from sqlmodel import select
class FakeChatClient:
"""Queued-response fake for the analysis LLM seam."""
def __init__(self, *responses):
self._responses = list(responses)
self.calls: list[list[dict]] = []
async def __call__(self, messages: list[dict]) -> str:
self.calls.append(messages)
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(**overrides) -> Campaign:
data = {
"id": "camp-1",
"name": "24h 正式线",
"target_id": "t-1",
"window_seconds": 86400,
"time_scale": 1.0,
"status": CampaignStatus.COMPLETED,
"plan": [CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=2)],
}
data.update(overrides)
return Campaign(**data)
def _report() -> dict:
return {
"campaign_id": "camp-1",
"name": "24h 正式线",
"summary": {
"total_runs": 3,
"completed_runs": 3,
"overall_pass_rate": 0.5,
"overall_availability": 1.0,
"avg_latency_ms": 800,
},
"time_trend": [],
"capability_summary": [
{"scenario_id": "s-1", "scenario_name": "售前咨询", "run_count": 2,
"pass_rate": 0.5, "availability": 1.0, "avg_latency_ms": 700},
{"scenario_id": "s-2", "scenario_name": "售后工单", "run_count": 1,
"pass_rate": 0.0, "availability": 1.0, "avg_latency_ms": 900},
],
}
STAGE1_A = json.dumps({
"narrative": "售前场景半数对话答非所问",
"problems": [{"severity": "high", "title": "答非所问", "description": "...", "evidence_run_ids": ["run-1"]}],
})
STAGE1_B = json.dumps({"narrative": "售后场景全部超时", "problems": []})
STAGE2 = json.dumps({
"overall": "活动整体不达标,售前售后均有阻断性问题",
"problems": [
{"severity": "high", "title": "售前答非所问", "description": "意图识别漂移",
"scenario_ids": ["s-1", "ghost-scenario"], "evidence_run_ids": ["run-1", "fake-run"]},
{"severity": "nonsense", "title": "售后超时", "description": "上游缓慢",
"scenario_ids": ["s-2"], "evidence_run_ids": ["run-2"]},
],
"suggestions": [{"priority": 1, "text": "补充售前意图语料"}, {"priority": 2, "text": "排查工单接口"}],
})
async def test_two_phase_orchestration_assembles_result():
client = FakeChatClient(STAGE1_A, STAGE1_B, STAGE2)
result = await analyze_campaign(
campaign=_campaign(),
report=_report(),
failure_samples={"s-2": [{"run_id": "run-2", "user": "u", "reply": "r", "reason": "超时"}]},
valid_run_ids={"run-1", "run-2"},
chat_client=client,
)
# 阶段一每场景一次调用,阶段二一次汇总调用
assert len(client.calls) == 3
assert "售前咨询" in str(client.calls[0]) and "售后工单" in str(client.calls[1])
stage2_prompt = str(client.calls[2])
assert "售前场景半数对话答非所问" in stage2_prompt and "售后场景全部超时" in stage2_prompt
assert result["overall"] == "活动整体不达标,售前售后均有阻断性问题"
assert result["scenario_narratives"] == [
{"scenario_id": "s-1", "narrative": "售前场景半数对话答非所问"},
{"scenario_id": "s-2", "narrative": "售后场景全部超时"},
]
assert result["suggestions"] == [
{"priority": 1, "text": "补充售前意图语料"},
{"priority": 2, "text": "排查工单接口"},
]
async def test_fabricated_evidence_is_dropped_before_persisting():
client = FakeChatClient(STAGE1_A, STAGE1_B, STAGE2)
result = await analyze_campaign(
campaign=_campaign(),
report=_report(),
failure_samples={},
valid_run_ids={"run-1", "run-2"},
chat_client=client,
)
problem = result["problems"][0]
assert problem["evidence_run_ids"] == ["run-1"] # fake-run 剔除
assert problem["scenario_ids"] == ["s-1"] # ghost-scenario 剔除
assert result["problems"][1]["severity"] == "medium" # 非法 severity 归一
async def test_unparseable_llm_output_fails_analysis():
client = FakeChatClient("这不是 JSON")
with pytest.raises(AnalysisError):
await analyze_campaign(
campaign=_campaign(),
report=_report(),
failure_samples={},
valid_run_ids=set(),
chat_client=client,
)
async def test_scenario_without_failure_samples_still_gets_narrative():
client = FakeChatClient(STAGE1_A, STAGE1_B, STAGE2)
result = await analyze_campaign(
campaign=_campaign(),
report=_report(),
failure_samples={},
valid_run_ids=set(),
chat_client=client,
)
assert len(result["scenario_narratives"]) == 2
async def test_analysis_input_includes_exploration_summary():
"""v0.9 票据 05阶段二输入追加探索摘要问题清单 + 达成统计,非全量对话)。"""
exploration_summary = {
"session_count": 2,
"sessions_with_experience": 2,
"goal_achieved_count": 1,
"goal_achievement_rate": 0.5,
"issues": [{"issue": "缴费入口难找", "count": 2}],
"misled": [],
"judge_review": None,
}
client = FakeChatClient(STAGE1_A, STAGE1_B, STAGE2)
await analyze_campaign(
campaign=_campaign(),
report=_report(),
failure_samples={},
valid_run_ids=set(),
chat_client=client,
exploration_summary=exploration_summary,
)
stage2_prompt = json.dumps(client.calls[2], ensure_ascii=False)
assert "探索发现" in stage2_prompt
assert "缴费入口难找" in stage2_prompt
assert "goal_achievement_rate" in stage2_prompt or "0.5" in stage2_prompt
async def test_analysis_without_exploration_omits_section():
client = FakeChatClient(STAGE1_A, STAGE1_B, STAGE2)
await analyze_campaign(
campaign=_campaign(),
report=_report(),
failure_samples={},
valid_run_ids=set(),
chat_client=client,
)
stage2_prompt = json.dumps(client.calls[2], ensure_ascii=False)
assert "探索发现" not in stage2_prompt
# ── 分析模型解析 ─────────────────────────────────────────────────────────
def _seed_config(session, config_id: str, *, analysis_default: bool = False, enabled: bool = True) -> None:
ModelConfigRepository(session).create(ModelConfigDB(
id=config_id, name=f"cfg-{config_id}", provider="openai_compatible", capability="chat",
endpoint_url="https://models.example.com/v1/chat/completions", model_name="m",
enabled=enabled, is_analysis_default=analysis_default,
))
def test_resolve_returns_none_when_nothing_configured(db_session):
assert resolve_analysis_model(_campaign(), db_session) is None
def test_resolve_prefers_campaign_override(db_session):
_seed_config(db_session, "mc-default", analysis_default=True)
_seed_config(db_session, "mc-override")
runtime = resolve_analysis_model(_campaign(analysis_model_config_id="mc-override"), db_session)
assert runtime is not None and runtime.id == "mc-override"
def test_resolve_falls_back_to_global_default(db_session):
_seed_config(db_session, "mc-default", analysis_default=True)
runtime = resolve_analysis_model(_campaign(), db_session)
assert runtime is not None and runtime.id == "mc-default"
def test_resolve_returns_none_when_override_missing(db_session):
assert resolve_analysis_model(_campaign(analysis_model_config_id="ghost"), db_session) is None
# ── 失败样例选取 ─────────────────────────────────────────────────────────
def _seed_failed_run(session, run_id: str, scenario_id: str, failed_turns: int) -> None:
RunRepository(session).create(EvalRun(
id=run_id, target_id="t-1", scenario_id=scenario_id,
campaign_id="camp-1", status=RunStatus.COMPLETED,
))
for i in range(failed_turns):
turn = TurnDB(id=f"{run_id}-turn-{i}", run_id=run_id, case_id=f"c{i}", round_index=0)
turn.set_sent_message({"msgBody": {"content": f"用户消息-{run_id}-{i}"}})
turn.set_reply({"msgBody": {"content": "" * 300}})
session.add(turn)
session.add(EvalResultDB(
run_id=run_id, case_id=f"c{i}", turn_id=turn.id,
rule_type="llm_score", passed=False, reason=f"不合格-{i}",
))
session.commit()
def test_collect_failure_samples_caps_and_truncates(db_session):
_seed_failed_run(db_session, "run-1", "s-1", failed_turns=4)
samples = collect_failure_samples("camp-1", db_session)
assert set(samples) == {"s-1"}
assert len(samples["s-1"]) == 3 # 每场景最多 3 条
item = samples["s-1"][0]
assert item["run_id"] == "run-1"
assert item["user"].startswith("用户消息-run-1")
assert len(item["reply"]) <= 200 # 截断
assert item["reason"]
def test_collect_failure_samples_skips_passing_runs(db_session):
RunRepository(db_session).create(EvalRun(
id="run-ok", target_id="t-1", scenario_id="s-1",
campaign_id="camp-1", status=RunStatus.COMPLETED,
))
assert collect_failure_samples("camp-1", db_session) == {}
# ── 后台执行状态机 ───────────────────────────────────────────────────────
async def test_execute_writes_completed_row_with_model_snapshot(db_session):
_seed_config(db_session, "mc-default", analysis_default=True)
CampaignRepository(db_session).create(_campaign())
_seed_failed_run(db_session, "run-1", "s-1", failed_turns=1)
_seed_failed_run(db_session, "run-2", "s-2", failed_turns=1)
client = FakeChatClient(STAGE1_A, STAGE1_B, STAGE2)
await execute_campaign_analysis_job(
"camp-1", triggered_by="manual", chat_client=client, session_factory=lambda: db_session,
)
row = db_session.exec(
select(CampaignAnalysisDB).where(CampaignAnalysisDB.campaign_id == "camp-1")
).one()
assert row.status == "completed"
assert row.model_config_id == "mc-default"
assert row.triggered_by == "manual"
assert row.get_result()["overall"].startswith("活动整体不达标")
async def test_execute_records_failure(db_session):
_seed_config(db_session, "mc-default", analysis_default=True)
CampaignRepository(db_session).create(_campaign())
client = FakeChatClient("garbage")
await execute_campaign_analysis_job(
"camp-1", triggered_by="auto", chat_client=client, session_factory=lambda: db_session,
)
row = db_session.exec(
select(CampaignAnalysisDB).where(CampaignAnalysisDB.campaign_id == "camp-1")
).one()
assert row.status == "failed"
assert row.error
assert row.triggered_by == "auto"