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

239 lines
8.8 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.

"""Judge sampling review for exploration sessions (v0.9 票据 04).
会话关闭后平台抽样对话≤3 段)经 judge 岗位模型独立复核,质量维度结论
结构化落入会话的 judge_review。复核是异步后台执行失败落错误不阻塞会话
状态无模型配置时静默跳过。LLM 调用经可注入 ChatClient 接缝(沿 v0.7
分析 seam测试用假客户端覆盖先例test_campaign_analysis.py
"""
import json
import pytest
from agenteval.exploration.judge import (
MAX_JUDGE_SAMPLES,
execute_judge_review,
normalize_judge_review,
sample_round_indexes,
)
from agenteval.exploration.models import (
ExplorationMessage,
ExplorationSession,
ExplorationSessionStatus,
)
from agenteval.models import Campaign, CampaignPlanEntry
from agenteval.storage.db import ModelConfigDB
from agenteval.storage.model_config_repository import ModelConfigRepository
from agenteval.storage.exploration_repository import ExplorationMessageRepository, ExplorationSessionRepository
from agenteval.storage.repository import (
CampaignRepository,
)
class FakeChatClient:
"""Queued-response fake for the judge 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
REVIEW_JSON = json.dumps(
{
"dimensions": [
{"dimension": "attitude", "rating": "good", "comment": "态度友好"},
{"dimension": "professionalism", "rating": "acceptable", "comment": "流程基本正确"},
{"dimension": "hallucination", "rating": "poor", "comment": "编造了不存在的政策"},
],
"summary": "服务态度好但存在幻觉",
},
ensure_ascii=False,
)
def _campaign() -> Campaign:
return Campaign(
id="camp-1",
name="24h 正式线",
target_id="t-1",
window_seconds=86400,
time_scale=1.0,
plan=[CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1)],
)
def _seed_config(db_session, config_id: str = "mc-default") -> None:
ModelConfigRepository(db_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=True,
is_analysis_default=True,
)
)
def _seed_session_with_messages(db_session, rounds: int = 5) -> str:
CampaignRepository(db_session).create(_campaign())
repo = ExplorationSessionRepository(db_session)
session_obj = repo.create(
ExplorationSession(
campaign_id="camp-1",
target_id="t-1",
persona={"name": "急性子用户"},
goal="查询账单并缴费",
)
)
session_obj.status = ExplorationSessionStatus.COMPLETED
repo.update(session_obj)
message_repo = ExplorationMessageRepository(db_session)
for i in range(1, rounds + 1):
message_repo.save_message(
ExplorationMessage(session_id=session_obj.id, round_index=i, role="user", content=f"用户消息-{i}")
)
message_repo.save_message(
ExplorationMessage(session_id=session_obj.id, round_index=i, role="assistant", content=f"回复内容-{i}")
)
return session_obj.id
@pytest.fixture()
def judge_env(db_session, monkeypatch):
from agenteval.exploration import judge as judge_module
monkeypatch.setattr(judge_module, "get_session", lambda: db_session)
return db_session
# ── 抽样 ──────────────────────────────────────────────────────────────
def test_sample_round_indexes_returns_all_within_cap():
assert sample_round_indexes([1, 2]) == [1, 2]
assert sample_round_indexes([1, 2, 3]) == [1, 2, 3]
def test_sample_round_indexes_spreads_evenly_over_cap():
assert sample_round_indexes([1, 2, 3, 4, 5]) == [1, 3, 5]
assert len(sample_round_indexes(list(range(1, 11)))) == MAX_JUDGE_SAMPLES
# ── 归一化 ────────────────────────────────────────────────────────────
def test_normalize_judge_review_applies_whitelists():
raw = {
"dimensions": [
{"dimension": "attitude", "rating": "good", "comment": "ok"},
{"dimension": "神秘维度", "rating": "good", "comment": "drop me"},
{"dimension": "hallucination", "rating": "离谱", "comment": "bad rating"},
],
"summary": "结论",
}
review = normalize_judge_review(raw)
dims = {d["dimension"]: d for d in review["dimensions"]}
assert set(dims) == {"attitude", "hallucination"}
assert dims["attitude"]["rating"] == "good"
assert dims["hallucination"]["rating"] == "acceptable" # 非法档位归一
assert review["summary"] == "结论"
def test_normalize_judge_review_rejects_non_list_dimensions():
review = normalize_judge_review({"dimensions": "不是列表", "summary": 123})
assert review["dimensions"] == []
assert review["summary"] == "123"
# ── 后台执行编排 ──────────────────────────────────────────────────────
async def test_execute_persists_structured_review(judge_env):
session_id = _seed_session_with_messages(judge_env, rounds=5)
_seed_config(judge_env)
client = FakeChatClient(REVIEW_JSON)
await execute_judge_review(session_id, chat_client=client)
session_obj = ExplorationSessionRepository(judge_env).get(session_id)
assert session_obj.status == ExplorationSessionStatus.COMPLETED
review = session_obj.judge_review
assert review["status"] == "completed"
assert review["model_config_id"] == "mc-default"
assert review["sampled_rounds"] == [1, 3, 5]
assert len(review["dimensions"]) == 3
assert review["summary"] == "服务态度好但存在幻觉"
async def test_execute_prompt_carries_only_sampled_rounds(judge_env):
session_id = _seed_session_with_messages(judge_env, rounds=5)
_seed_config(judge_env)
client = FakeChatClient(REVIEW_JSON)
await execute_judge_review(session_id, chat_client=client)
payload = json.dumps(client.calls[0], ensure_ascii=False)
assert "用户消息-1" in payload and "用户消息-3" in payload and "用户消息-5" in payload
assert "用户消息-2" not in payload and "用户消息-4" not in payload
async def test_execute_records_error_on_unparseable_output(judge_env):
session_id = _seed_session_with_messages(judge_env)
_seed_config(judge_env)
await execute_judge_review(session_id, chat_client=FakeChatClient("这不是 JSON"))
session_obj = ExplorationSessionRepository(judge_env).get(session_id)
assert session_obj.status == ExplorationSessionStatus.COMPLETED # 复核失败不阻塞会话
review = session_obj.judge_review
assert review["status"] == "failed"
assert review["error"]
assert review["model_config_id"] == "mc-default"
async def test_execute_records_error_on_client_exception(judge_env):
session_id = _seed_session_with_messages(judge_env)
_seed_config(judge_env)
await execute_judge_review(session_id, chat_client=FakeChatClient(RuntimeError("模型网关超时")))
review = ExplorationSessionRepository(judge_env).get(session_id).judge_review
assert review["status"] == "failed"
assert "模型网关超时" in review["error"]
async def test_execute_skips_silently_without_model_config(judge_env):
session_id = _seed_session_with_messages(judge_env)
client = FakeChatClient(REVIEW_JSON)
await execute_judge_review(session_id, chat_client=client)
assert client.calls == []
assert ExplorationSessionRepository(judge_env).get(session_id).judge_review is None
async def test_execute_skips_session_without_messages(judge_env):
CampaignRepository(judge_env).create(_campaign())
session_obj = ExplorationSessionRepository(judge_env).create(
ExplorationSession(campaign_id="camp-1", target_id="t-1", persona={}, goal="g")
)
_seed_config(judge_env)
client = FakeChatClient(REVIEW_JSON)
await execute_judge_review(session_obj.id, chat_client=client)
assert client.calls == []
assert ExplorationSessionRepository(judge_env).get(session_obj.id).judge_review is None