"""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 同款后台任务约定:自持 Session、try/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)) # 强引用防止未持有引用的任务被 GC(asyncio 已知坑) _BACKGROUND_TASKS.add(task) task.add_done_callback(_BACKGROUND_TASKS.discard) return task