AgentEvalTool/backend/agenteval/evaluation/comparison.py

376 lines
15 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.

"""Period comparison — baseline pairing, metric diff and LLM narrative (周期对比).
机械半边:计划指纹(同活动串的判据)、自动基线解析、两期报告的指标
diff——全部确定性计算ADR-0004 口径,经 ``generate_campaign_report`` 的
既有聚合结果现算)。叙述半边:单次 LLM 调用,在两期已消化的分析 JSON 与
机械 diff 之上产出结构化演进叙述CONTEXT.md「周期对比」
"""
import json
from datetime import datetime, timezone
from typing import Any, Optional
from sqlmodel import Session
from agenteval.evaluation.analysis import ChatClient, resolve_analysis_model
from agenteval.evaluation.report import load_campaign_report
from agenteval.models import Campaign, CampaignStatus
from agenteval.storage.db import iso_utc, utc_now
from agenteval.storage.model_config_repository import ModelConfigRepository
from agenteval.storage.repository import (
CampaignAnalysisRepository,
CampaignPeriodComparisonRepository,
CampaignRepository,
)
from agenteval.utils.llm import parse_json_from_llm_text
_SAME_MOMENT_EPS = 1e-3
_VALID_TRENDS = {"improving", "stable", "regressing"}
_VALID_EVOLUTION_STATUSES = {"new", "persisting", "resolved"}
_VALID_SUGGESTION_STATUSES = {"addressed", "partial", "unaddressed", "new"}
class ComparisonError(RuntimeError):
"""对比生成失败(数据缺失或模型输出无法解析),可重试。"""
def campaign_plan_fingerprint(campaign: Campaign) -> str:
"""活动串判据:评测对象 + 计划条目集合 + 窗口秒数(条目顺序无关)。
指纹相等的活动构成同一活动串CONTEXT.md「周期对比」。档位
time_scale不参与指纹——正式/加速由基线解析另行过滤。
"""
entries = sorted(
((entry.scenario_id, entry.offset_seconds, entry.count) for entry in campaign.plan),
key=lambda e: (e[0], e[1], e[2]),
)
return json.dumps(
{"target": campaign.target_id, "window": campaign.window_seconds, "plan": entries},
separators=(",", ":"),
)
def _to_utc(dt: Optional[datetime]) -> Optional[datetime]:
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
def resolve_auto_baseline(campaign: Campaign, session: Session) -> Optional[Campaign]:
"""自动基线:同活动串中最近一期已完成的正式线活动(须有 completed 分析)。
条件(全部满足才可作基线):指纹相等、双方均为正式线
time_scale == 1、候选完成时间早于本期、候选已有 completed 分析。
本期为加速线或无匹配候选时返回 None。
"""
if campaign.time_scale != 1:
return None
reference = _to_utc(campaign.completed_at) or utc_now()
fingerprint = campaign_plan_fingerprint(campaign)
best: Optional[Campaign] = None
best_completed: Optional[datetime] = None
for candidate in CampaignRepository(session).list_all():
if candidate.id == campaign.id or candidate.time_scale != 1:
continue
if campaign_plan_fingerprint(candidate) != fingerprint:
continue
completed = _to_utc(candidate.completed_at)
if completed is None:
continue
if (reference - completed).total_seconds() < _SAME_MOMENT_EPS:
continue
analysis_row = CampaignAnalysisRepository(session).get_by_campaign(candidate.id)
if analysis_row is None or analysis_row.status != "completed":
continue
if best_completed is None or completed > best_completed:
best = candidate
best_completed = completed
return best
def _delta(base: Optional[float], current: Optional[float], ndigits: int) -> Optional[float]:
if base is None or current is None:
return None
return round(current - base, ndigits)
def compute_metric_diff(
baseline_report: dict[str, Any],
current_report: dict[str, Any],
) -> dict[str, Any]:
"""两期 ``generate_campaign_report`` 结果 → 整窗 + 分场景指标 delta。
通过率/可用性按 4 位小数、时延按 1 位小数取 delta任一侧指标为 None
或该侧缺失该场景时 delta 为 None只保留存在的 baseline/current 值)。
纯函数,无 I/O。
"""
def _pair(base: Optional[float], current: Optional[float], ndigits: int) -> dict[str, Optional[float]]:
return {"baseline": base, "current": current, "delta": _delta(base, current, ndigits)}
base_summary = baseline_report.get("summary") or {}
cur_summary = current_report.get("summary") or {}
overall = {
"pass_rate": _pair(base_summary.get("overall_pass_rate"), cur_summary.get("overall_pass_rate"), 4),
"availability": _pair(base_summary.get("overall_availability"), cur_summary.get("overall_availability"), 4),
"avg_latency_ms": _pair(base_summary.get("avg_latency_ms"), cur_summary.get("avg_latency_ms"), 1),
}
base_scenarios = {s.get("scenario_id"): s for s in baseline_report.get("capability_summary") or []}
cur_scenarios = {s.get("scenario_id"): s for s in current_report.get("capability_summary") or []}
scenarios = []
for sid in sorted(set(base_scenarios) | set(cur_scenarios), key=lambda x: x or ""):
base = base_scenarios.get(sid)
cur = cur_scenarios.get(sid)
entry: dict[str, Any] = {
"scenario_id": sid,
"scenario_name": (cur or base or {}).get("scenario_name") or "",
"pass_rate": _pair(
base.get("pass_rate") if base else None,
cur.get("pass_rate") if cur else None,
4,
),
"availability": _pair(
base.get("availability") if base else None,
cur.get("availability") if cur else None,
4,
),
"avg_latency_ms": _pair(
base.get("avg_latency_ms") if base else None,
cur.get("avg_latency_ms") if cur else None,
1,
),
}
scenarios.append(entry)
return {"overall": overall, "scenarios": scenarios}
def validate_comparison_request(
session: Session,
campaign: Campaign,
*,
explicit_baseline_id: Optional[str] = None,
) -> Campaign:
"""共享校验入口:活动终态 → 模型 → 基线 → 分析。违规抛 ComparisonError。
router 触发端点捕获映射 400执行器捕获落 failed 行。校验顺序权威,
两处不再漂移。
"""
if campaign.status in (CampaignStatus.PLANNED, CampaignStatus.RUNNING):
raise ComparisonError("活动完成后才能生成周期对比")
if resolve_analysis_model(campaign, session) is None:
raise ComparisonError(
"未配置分析模型:请在模型配置中心将某个 chat 配置设为「分析默认」,或为该活动指定分析模型"
)
current_analysis = CampaignAnalysisRepository(session).get_by_campaign(campaign.id)
if current_analysis is None or current_analysis.status != "completed":
raise ComparisonError("请先生成本期活动的智能分析")
if explicit_baseline_id:
baseline = CampaignRepository(session).get(explicit_baseline_id)
if baseline is None:
raise ComparisonError(f"基线活动不存在: {explicit_baseline_id}")
else:
baseline = resolve_auto_baseline(campaign, session)
if baseline is None:
raise ComparisonError(
"未找到自动基线:历史活动中没有同计划指纹且已完成分析的活动,可手动选择基线活动"
)
baseline_analysis = CampaignAnalysisRepository(session).get_by_campaign(baseline.id)
if baseline_analysis is None or baseline_analysis.status != "completed":
raise ComparisonError("基线活动没有已完成的智能分析,请先生成基线活动的分析")
return baseline
def load_comparison_view(session: Session, campaign: Campaign) -> dict[str, Any]:
"""周期对比读模型单一出口:返回 GET /comparison 完整响应形状。
无行时 status=none + auto_baseline有行时含 comparison dict含 model_name
标签)+ 对生效基线的 metric_diff。markdown 导出从同一 view 投影。
"""
payload = _build_auto_baseline_payload(campaign, session)
row = CampaignPeriodComparisonRepository(session).get_by_campaign(campaign.id)
if row is None:
return {"status": "none", "comparison": None, **payload}
effective_baseline = CampaignRepository(session).get(row.baseline_campaign_id)
metric_diff = (
compute_metric_diff(
load_campaign_report(session, effective_baseline),
load_campaign_report(session, campaign),
)
if effective_baseline is not None
else None
)
model_cfg = (
ModelConfigRepository(session).get(row.model_config_id) if row.model_config_id else None
)
model_label = (
f"{model_cfg.name}{model_cfg.model_name}"
if model_cfg and model_cfg.model_name
else (model_cfg.name if model_cfg else None)
)
comparison = {
"baseline_campaign_id": row.baseline_campaign_id,
"baseline": (
{
"id": effective_baseline.id,
"name": effective_baseline.name,
"completed_at": iso_utc(effective_baseline.completed_at),
}
if effective_baseline
else None
),
"result": row.get_result(),
"error": row.error,
"model_config_id": row.model_config_id,
"model_name": model_label,
"triggered_by": row.triggered_by,
"updated_at": iso_utc(row.updated_at),
}
return {
"status": row.status,
"comparison": comparison,
"auto_baseline": payload["auto_baseline"],
"metric_diff": metric_diff,
}
def _build_auto_baseline_payload(campaign: Campaign, session: Session) -> dict[str, Any]:
"""自动基线信息 + 机械 diff无基线时两者均为 null"""
baseline = resolve_auto_baseline(campaign, session)
if baseline is None:
return {"auto_baseline": None, "metric_diff": None}
diff = compute_metric_diff(
load_campaign_report(session, baseline),
load_campaign_report(session, campaign),
)
return {
"auto_baseline": {
"id": baseline.id,
"name": baseline.name,
"completed_at": iso_utc(baseline.completed_at),
},
"metric_diff": diff,
}
def build_comparison_payload(campaign: Campaign, session: Session) -> dict[str, Any]:
"""GET 返回体:自动基线信息 + 机械 diff无基线时两者均为 null"""
baseline = resolve_auto_baseline(campaign, session)
if baseline is None:
return {"auto_baseline": None, "metric_diff": None}
diff = compute_metric_diff(
load_campaign_report(session, baseline),
load_campaign_report(session, campaign),
)
return {
"auto_baseline": {
"id": baseline.id,
"name": baseline.name,
"completed_at": iso_utc(baseline.completed_at),
},
"metric_diff": diff,
}
# ── 叙述半边:单次 LLM 调用编排 ─────────────────────────────────────────
async def narrate_period_comparison(
*,
baseline_analysis: dict[str, Any],
current_analysis: dict[str, Any],
metric_diff: dict[str, Any],
valid_scenario_ids: set[str],
chat_client: ChatClient,
) -> dict[str, Any]:
"""在两期分析结论与机械 diff 之上产出结构化演进叙述(单次调用)。
输出 schematrend / summary / problem_evolution / suggestion_tracking。
模型虚构的 scenario_id 按白名单剔除非法枚举归一trend→stable、
演化 status→persisting、建议 status→unaddressed解析失败抛
``ComparisonError``(由调用方落 failed 状态)。
"""
system_prompt = (
"你是智能客服质量评估平台的趋势研判专家,负责对照相邻两期评估活动的"
"智能分析结论,产出结构化的演进叙述。只输出一个 JSON 对象:"
'{"trend": "improving|stable|regressing", '
'"summary": "总体演进结论(一段话)", '
'"problem_evolution": [{"status": "new|persisting|resolved", "title": "...", '
'"detail": "该问题的演化说明", "scenario_ids": ["涉及场景 id"]}], '
'"suggestion_tracking": [{"text": "上期建议原文", '
'"status": "addressed|partial|unaddressed|new", "note": "落实情况的说明"}]}'
"problem_evolution 覆盖上期问题在本期的新增/持续/消解,"
"suggestion_tracking 追踪上期每条建议的落实情况并列出本期新增建议。"
"全部使用中文。"
)
user_prompt = json.dumps(
{
"上期分析": baseline_analysis,
"本期分析": current_analysis,
"指标变化(确定性计算,可信)": metric_diff,
},
ensure_ascii=False,
)
content = await chat_client(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
)
try:
parsed = parse_json_from_llm_text(content)
except Exception as exc:
raise ComparisonError(f"对比叙述输出解析失败: {exc}") from exc
if not isinstance(parsed, dict):
raise ComparisonError("对比叙述输出不是 JSON 对象")
summary = parsed.get("summary")
if not isinstance(summary, str) or not summary.strip():
raise ComparisonError("对比叙述缺少 summary")
trend = parsed.get("trend")
if trend not in _VALID_TRENDS:
trend = "stable"
problem_evolution = []
for item in parsed.get("problem_evolution") or []:
if not isinstance(item, dict):
continue
status = item.get("status")
problem_evolution.append(
{
"status": status if status in _VALID_EVOLUTION_STATUSES else "persisting",
"title": str(item.get("title", "")),
"detail": str(item.get("detail", "")),
"scenario_ids": [s for s in item.get("scenario_ids") or [] if s in valid_scenario_ids],
}
)
suggestion_tracking = []
for item in parsed.get("suggestion_tracking") or []:
if not isinstance(item, dict):
continue
status = item.get("status")
suggestion_tracking.append(
{
"text": str(item.get("text", "")),
"status": status if status in _VALID_SUGGESTION_STATUSES else "unaddressed",
"note": str(item.get("note", "")),
}
)
return {
"trend": trend,
"summary": summary,
"problem_evolution": problem_evolution,
"suggestion_tracking": suggestion_tracking,
}