合并两个不可分割的深化: 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 精神,等真实需求出现再议)。
239 lines
8.8 KiB
Python
239 lines
8.8 KiB
Python
"""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
|