"""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