feat(exploration): judge sampling review after session close

After an exploration session closes, the platform samples up to 3
conversation rounds and runs an independent judge-role review through
the v0.7 ChatClient seam, persisting quality-dimension conclusions
(attitude, professionalism, hallucination) into the session's
judge_review. The review runs as a background task: failures are
recorded without touching session state or the first-hand experience
record, and a missing model config skips silently.
This commit is contained in:
sinohqb 2026-08-03 18:59:40 +08:00
parent 3e485bfbe6
commit 2285a25009
4 changed files with 449 additions and 1 deletions

View File

@ -0,0 +1,187 @@
"""Judge sampling review for exploration sessions (judge 岗位抽样复核).
会话关闭后平台对该会话对话抽样默认 3 token judge 岗位模型
独立复核产出质量维度结论态度专业性幻觉落入会话的 ``judge_review``
复核是异步后台执行失败落错误不阻塞会话状态也不影响体验记录这条第一手
证据线未配置模型时静默跳过LLM 调用沿 v0.7 分析的 ``ChatClient`` 接缝
注入测试用假客户端覆盖
"""
import asyncio
import json
import logging
from typing import Any, Optional
from sqlmodel import Session
from agenteval.evaluation.analysis import (
ChatClient,
gateway_chat_client,
resolve_analysis_model,
)
from agenteval.exploration.models import ExplorationMessage, ExplorationSession
from agenteval.models import Campaign
from agenteval.services.model_configs import ModelRuntimeConfig
from agenteval.storage.db import get_session, iso_utc, utc_now
from agenteval.storage.repository import (
CampaignRepository,
ExplorationMessageRepository,
ExplorationSessionRepository,
)
from agenteval.utils.llm import parse_json_from_llm_text
_logger = logging.getLogger("agenteval")
MAX_JUDGE_SAMPLES = 3
SAMPLE_TEXT_LIMIT = 500
VALID_DIMENSIONS = ("attitude", "professionalism", "hallucination")
VALID_RATINGS = ("good", "acceptable", "poor")
JUDGE_SYSTEM_PROMPT = """你是评测平台的 judge 岗位模型,负责独立复核一段「虚拟用户」与被评对象的探索对话。
只依据给定对话抽样判断不要臆测抽样之外的内容输出必须是合法 JSON且仅包含以下结构
{
"dimensions": [
{"dimension": "attitude | professionalism | hallucination", "rating": "good | acceptable | poor", "comment": "一句话依据"}
],
"summary": "一句话总体结论"
}
维度说明attitude=服务态度professionalism=专业性流程与答复正确性
hallucination=幻觉编造事实政策或能力无幻觉时 rating good
三个维度必须各出现一次"""
class JudgeReviewError(RuntimeError):
"""judge 复核失败(模型输出无法解析或调用失败),不影响会话状态。"""
def resolve_judge_model(campaign: Campaign, session: Session) -> Optional[ModelRuntimeConfig]:
"""解析 judge 岗位模型:沿用活动分析模型口径(活动覆盖 ?? 全局分析默认)。"""
return resolve_analysis_model(campaign, session)
def sample_round_indexes(rounds: list[int], limit: int = MAX_JUDGE_SAMPLES) -> list[int]:
"""超限时均匀取样(含首尾),保证抽样可复现。"""
if len(rounds) <= limit:
return list(rounds)
span = len(rounds) - 1
return [rounds[round(i * span / (limit - 1))] for i in range(limit)]
def build_judge_messages(
session_obj: ExplorationSession, samples: list[ExplorationMessage]
) -> list[dict[str, str]]:
transcript = []
for message in samples:
speaker = "虚拟用户" if message.role == "user" else "被评对象"
content = str(message.content)[:SAMPLE_TEXT_LIMIT]
transcript.append(f"[第 {message.round_index} 轮] {speaker}: {content}")
payload = {
"persona": session_obj.persona,
"goal": session_obj.goal,
"transcript": transcript,
}
return [
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
]
def normalize_judge_review(raw: Any) -> dict[str, Any]:
"""白名单归一:未知维度丢弃,非法档位归 acceptable沿 v0.7 白名单经验)。"""
dims = raw.get("dimensions") if isinstance(raw, dict) else None
dims = dims if isinstance(dims, list) else []
normalized = []
for item in dims:
if not isinstance(item, dict):
continue
name = item.get("dimension")
if name not in VALID_DIMENSIONS:
continue
rating = item.get("rating")
normalized.append(
{
"dimension": name,
"rating": rating if rating in VALID_RATINGS else "acceptable",
"comment": str(item.get("comment") or "")[:SAMPLE_TEXT_LIMIT],
}
)
summary = str(raw.get("summary") or "")[:SAMPLE_TEXT_LIMIT] if isinstance(raw, dict) else ""
return {"dimensions": normalized, "summary": summary}
async def judge_conversation(
session_obj: ExplorationSession,
samples: list[ExplorationMessage],
*,
chat_client: ChatClient,
) -> dict[str, Any]:
text = await chat_client(build_judge_messages(session_obj, samples))
try:
raw = parse_json_from_llm_text(text)
except Exception as exc:
raise JudgeReviewError(f"judge 输出解析失败: {exc}") from exc
if not isinstance(raw, dict):
raise JudgeReviewError("judge 输出解析失败: 输出不是 JSON 对象")
review = normalize_judge_review(raw)
review["sampled_rounds"] = sorted({m.round_index for m in samples})
return review
async def execute_judge_review(
exploration_session_id: str,
*,
chat_client: Optional[ChatClient] = None,
) -> None:
"""后台执行体:抽样 → judge 复核 → judge_review 落库。
Runs 同款后台任务约定自持 Sessiontry/finally 关闭失败落 error
不阻塞会话状态未配置模型时静默跳过
"""
session = get_session()
try:
repo = ExplorationSessionRepository(session)
session_obj = repo.get(exploration_session_id)
if not session_obj:
return
campaign = CampaignRepository(session).get(session_obj.campaign_id)
runtime = resolve_judge_model(campaign, session) if campaign else None
if runtime is None:
_logger.info("探索会话 %s 跳过 judge 复核:未配置模型", exploration_session_id)
return
messages = ExplorationMessageRepository(session).list_by_session(session_obj.id)
round_indexes = sorted({m.round_index for m in messages})
sampled = set(sample_round_indexes(round_indexes))
samples = [m for m in messages if m.round_index in sampled]
if not samples:
return
client = chat_client or gateway_chat_client(runtime)
reviewed_at = iso_utc(utc_now())
try:
review = await judge_conversation(session_obj, samples, chat_client=client)
review.update(status="completed", model_config_id=runtime.id, reviewed_at=reviewed_at)
except Exception as exc:
_logger.warning("探索会话 %s judge 复核失败: %s", exploration_session_id, exc)
review = {
"status": "failed",
"model_config_id": runtime.id,
"reviewed_at": reviewed_at,
"error": str(exc)[:SAMPLE_TEXT_LIMIT],
}
session_obj.judge_review = review
repo.update(session_obj)
finally:
session.close()
_BACKGROUND_TASKS: set[asyncio.Task] = set()
def start_judge_review(exploration_session_id: str) -> asyncio.Task:
"""以后台任务启动抽样复核fire-and-forget结果经会话 judge_review 观测)。"""
task = asyncio.create_task(execute_judge_review(exploration_session_id))
# 强引用防止未持有引用的任务被 GCasyncio 已知坑)
_BACKGROUND_TASKS.add(task)
task.add_done_callback(_BACKGROUND_TASKS.discard)
return task

View File

@ -20,6 +20,7 @@ from sqlmodel import Session
from agenteval.channels.factory import ChannelFactory
from agenteval.config import get_settings
from agenteval.evaluation.report import generate_campaign_report
from agenteval.exploration.judge import start_judge_review
from agenteval.exploration.models import (
ExplorationBudget,
ExplorationMessage,
@ -290,4 +291,6 @@ async def close_session(
session_obj.experience = normalize_experience(request.experience)
session_obj.status = ExplorationSessionStatus.COMPLETED
session_obj.closed_at = utc_now()
return repo.update(session_obj).model_dump(mode="json")
updated = repo.update(session_obj)
start_judge_review(session_obj.id)
return updated.model_dump(mode="json")

View File

@ -54,6 +54,11 @@ def seeded_db(db_session, monkeypatch):
app.dependency_overrides[get_db] = _test_get_db
# 隔离后台 judge 复核任务API 测试不真正派发后台任务
from agenteval.web.routers import exploration as exploration_router
monkeypatch.setattr(exploration_router, "start_judge_review", lambda session_id: None)
target = EvalTarget(
id="t-1",
name="mock-target",
@ -330,6 +335,20 @@ async def test_close_twice_rejected(seeded_db, mock_channel, client):
assert resp.status_code == 409
async def test_close_triggers_judge_review(seeded_db, mock_channel, client, monkeypatch):
from agenteval.web.routers import exploration as exploration_router
started: list[str] = []
monkeypatch.setattr(exploration_router, "start_judge_review", started.append)
session_id = (await _create_session(client)).json()["id"]
resp = await client.post(
f"/api/exploration/sessions/{session_id}/close", json={"experience": {"goal_achieved": True}}
)
assert resp.status_code == 200, resp.text
assert started == [session_id]
# ---------------------------------------------------------------- migration

View File

@ -0,0 +1,239 @@
"""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.repository import (
CampaignRepository,
ExplorationMessageRepository,
ExplorationSessionRepository,
)
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