1021 lines
32 KiB
Python
1021 lines
32 KiB
Python
"""诊断引擎 — 指标计算与 judge 辅助函数。
|
||
|
||
Stage 1 指标管线的可提取内核。包含:
|
||
- 7 个规则指标的纯函数计算
|
||
- JSON 提取工具
|
||
- 5 个 LLM judge 评估函数(async)
|
||
- 单题指标编排 compute_question_metrics
|
||
- 错误归因瀑布 attribute_error
|
||
- defect/lapse 病因判别 classify_defect_vs_lapse
|
||
- 降级指标生成 _make_degraded_metrics
|
||
|
||
不依赖 app/ 或 adapters/。所有 LLM 交互通过 LLMProvider Protocol 注入。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from collections import Counter
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from json_repair import repair_json
|
||
from loguru import logger
|
||
|
||
from core.evolution.types import (
|
||
DiagnosePrompts,
|
||
ErrorAttribution,
|
||
QuestionMetrics,
|
||
SkillStepAdherence,
|
||
SpanMetrics,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from core.protocols import LLMProvider
|
||
|
||
# =========================================================================
|
||
# 常量
|
||
# =========================================================================
|
||
|
||
_SPAN_EVAL_TOOLS: frozenset[str] = frozenset({"view_node", "search_similar", "observe_frame"})
|
||
"""span 级评估涵盖的工具集合。"""
|
||
|
||
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
|
||
"""执行/解析层失败导致排除的 stop_reason 集合。"""
|
||
|
||
|
||
# =========================================================================
|
||
# A. 规则指标 — 7 个纯函数 + 辅助工具
|
||
# =========================================================================
|
||
|
||
|
||
def _parse_json_object(raw: str) -> dict | None:
|
||
"""将原始字符串解析为字典;失败时返回 None。
|
||
|
||
参数:
|
||
raw: 待解析的原始字符串。
|
||
|
||
返回:
|
||
解析成功返回 dict,否则返回 None。
|
||
"""
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
try:
|
||
parsed = json.loads(repair_json(raw))
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
return None
|
||
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
return None
|
||
|
||
|
||
def _trigrams(text: str) -> set[str]:
|
||
"""返回字符串的字符级 trigram 集合。
|
||
|
||
参数:
|
||
text: 输入文本。
|
||
|
||
返回:
|
||
长度为 3 的子串集合;文本不足 3 字符时返回空集。
|
||
"""
|
||
if len(text) < 3:
|
||
return set()
|
||
return {text[index : index + 3] for index in range(len(text) - 2)}
|
||
|
||
|
||
def _extract_last_confidence(raw_contents: list[str]) -> float:
|
||
"""从末步 raw_content 提取 reflect.confidence。失败时返回 0.5。
|
||
|
||
参数:
|
||
raw_contents: 各步原始输出内容列表。
|
||
|
||
返回:
|
||
置信度浮点值,提取失败时返回 0.5。
|
||
"""
|
||
try:
|
||
parsed = _parse_json_object(raw_contents[-1])
|
||
if parsed is None:
|
||
raise ValueError("末步内容不是字典。")
|
||
return float(parsed["reflect"]["confidence"])
|
||
except Exception:
|
||
return 0.5
|
||
|
||
|
||
def calc_format_compliance(raw_contents: list[str]) -> float:
|
||
"""每步 JSON 是否包含 reflect/plan/action 三个字段。合规步数/总步数。
|
||
|
||
参数:
|
||
raw_contents: 各步原始输出内容列表。
|
||
|
||
返回:
|
||
合规比例 [0.0, 1.0];空列表返回 1.0。
|
||
"""
|
||
if not raw_contents:
|
||
return 1.0
|
||
|
||
compliant_count = 0
|
||
for raw in raw_contents:
|
||
parsed = _parse_json_object(raw)
|
||
if parsed is not None and all(key in parsed for key in ("reflect", "plan", "action")):
|
||
compliant_count += 1
|
||
|
||
return compliant_count / len(raw_contents)
|
||
|
||
|
||
def calc_budget_usage(steps_used: int, max_steps: int) -> float:
|
||
"""预算使用比例。
|
||
|
||
参数:
|
||
steps_used: 已使用步数。
|
||
max_steps: 最大步数预算。
|
||
|
||
返回:
|
||
steps_used / max_steps。
|
||
|
||
异常:
|
||
ZeroDivisionError: max_steps 为 0 时抛出(P5: 不掩盖错误)。
|
||
"""
|
||
return steps_used / max_steps
|
||
|
||
|
||
def calc_confidence_calibration(confidence: float, correct: bool) -> str:
|
||
"""置信度校准分类。
|
||
|
||
参数:
|
||
confidence: 模型置信度 [0.0, 1.0]。
|
||
correct: 是否答对。
|
||
|
||
返回:
|
||
'high_conf_wrong' | 'low_conf_right' | 'calibrated'。
|
||
"""
|
||
if confidence >= 0.7 and not correct:
|
||
return "high_conf_wrong"
|
||
if confidence < 0.5 and correct:
|
||
return "low_conf_right"
|
||
return "calibrated"
|
||
|
||
|
||
def calc_repeat_visit_rate(view_node_ids: list[str]) -> float:
|
||
"""重复访问率。
|
||
|
||
参数:
|
||
view_node_ids: 访问的节点 ID 列表。
|
||
|
||
返回:
|
||
1 - (unique / total);空列表返回 0.0。
|
||
"""
|
||
if not view_node_ids:
|
||
return 0.0
|
||
return 1 - (len(set(view_node_ids)) / len(view_node_ids))
|
||
|
||
|
||
def calc_search_keyword_repetition(queries: list[str]) -> float:
|
||
"""连续 search_similar 查询的最大字符级 trigram Jaccard 相似度。
|
||
|
||
参数:
|
||
queries: 搜索查询列表。
|
||
|
||
返回:
|
||
连续查询对的最大 Jaccard 值;不足 2 个查询时返回 0.0。
|
||
"""
|
||
if len(queries) < 2:
|
||
return 0.0
|
||
|
||
max_score = 0.0
|
||
for left, right in zip(queries, queries[1:], strict=False):
|
||
left_trigrams = _trigrams(left)
|
||
right_trigrams = _trigrams(right)
|
||
union = left_trigrams | right_trigrams
|
||
score = 0.0 if not union else len(left_trigrams & right_trigrams) / len(union)
|
||
if score > max_score:
|
||
max_score = score
|
||
return max_score
|
||
|
||
|
||
def calc_level_jump_pattern(view_node_ids: list[str]) -> str:
|
||
"""从 node_id 提取层级,拼成 'L1→L2→L3' 格式。
|
||
|
||
参数:
|
||
view_node_ids: 节点 ID 列表。
|
||
|
||
返回:
|
||
层级跳转模式字符串;无匹配时返回空字符串。
|
||
"""
|
||
levels: list[str] = []
|
||
for node_id in view_node_ids:
|
||
match = re.search(r"_L(\d+)_", node_id)
|
||
if match is not None:
|
||
levels.append(f"L{match.group(1)}")
|
||
return "→".join(levels)
|
||
|
||
|
||
def calc_tool_usage(tool_names: list[str]) -> dict[str, int]:
|
||
"""按 tool_name 计数。
|
||
|
||
参数:
|
||
tool_names: 工具名称列表。
|
||
|
||
返回:
|
||
{工具名: 调用次数} 映射。
|
||
"""
|
||
return dict(Counter(tool_names))
|
||
|
||
|
||
def extract_rule_metrics(prediction: dict, raw_contents: list[str], max_steps: int) -> dict:
|
||
"""从 prediction 和 raw_contents 提取全部 7 个规则指标。
|
||
|
||
参数:
|
||
prediction: 单题预测记录,含 steps_json / correct / answer_confidence。
|
||
raw_contents: 各步原始输出内容列表。
|
||
max_steps: 最大步数预算。
|
||
|
||
返回:
|
||
包含 7 个规则指标的字典。
|
||
"""
|
||
view_node_ids: list[str] = []
|
||
search_queries: list[str] = []
|
||
tool_names: list[str] = []
|
||
|
||
for step in prediction.get("steps_json", []):
|
||
tool_call = step.get("tool_call", {})
|
||
if not isinstance(tool_call, dict):
|
||
continue
|
||
|
||
tool_name = tool_call.get("tool")
|
||
args = tool_call.get("args", {})
|
||
if not isinstance(args, dict):
|
||
args = {}
|
||
|
||
if isinstance(tool_name, str):
|
||
tool_names.append(tool_name)
|
||
|
||
if tool_name == "view_node":
|
||
node_id = args.get("node_id")
|
||
if isinstance(node_id, str):
|
||
view_node_ids.append(node_id)
|
||
|
||
if tool_name == "search_similar":
|
||
query = args.get("query")
|
||
if isinstance(query, str):
|
||
search_queries.append(query)
|
||
|
||
# 置信度优先级:末步 JSON reflect.confidence > prediction["answer_confidence"]
|
||
confidence = prediction.get("answer_confidence", 0.5)
|
||
if raw_contents:
|
||
last_step = _parse_json_object(raw_contents[-1])
|
||
if isinstance(last_step, dict):
|
||
confidence = _extract_last_confidence(raw_contents)
|
||
|
||
correct = bool(prediction.get("correct", False))
|
||
steps_used = len(prediction.get("steps_json", []))
|
||
|
||
return {
|
||
"format_compliance": calc_format_compliance(raw_contents),
|
||
"budget_usage": calc_budget_usage(steps_used, max_steps),
|
||
"confidence_calibration": calc_confidence_calibration(confidence, correct),
|
||
"repeat_visit_rate": calc_repeat_visit_rate(view_node_ids),
|
||
"search_keyword_repetition": calc_search_keyword_repetition(search_queries),
|
||
"level_jump_pattern": calc_level_jump_pattern(view_node_ids),
|
||
"tool_usage": calc_tool_usage(tool_names),
|
||
}
|
||
|
||
|
||
# =========================================================================
|
||
# B. JSON 提取
|
||
# =========================================================================
|
||
|
||
|
||
def extract_json_from_response(raw: str) -> dict:
|
||
"""从 LLM 回复中提取 JSON。
|
||
|
||
三策略依序尝试:
|
||
1. markdown 代码块 ```json ... ``` 或 ``` ... ```
|
||
2. 最外层花括号 { ... }
|
||
3. json_repair 修复后解析
|
||
|
||
参数:
|
||
raw: LLM 原始回复字符串。
|
||
|
||
返回:
|
||
解析后的字典。
|
||
|
||
异常:
|
||
ValueError: 三种策略均无法提取合法 JSON 字典时抛出。
|
||
"""
|
||
# 策略 1: fenced code block
|
||
block_match = re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.DOTALL)
|
||
if block_match is not None:
|
||
try:
|
||
parsed = json.loads(block_match.group(1))
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
pass
|
||
else:
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
|
||
# 策略 2: outermost braces
|
||
start = raw.find("{")
|
||
end = raw.rfind("}")
|
||
if start != -1 and end != -1 and start <= end:
|
||
try:
|
||
parsed = json.loads(raw[start : end + 1])
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
pass
|
||
else:
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
|
||
# 策略 3: json_repair
|
||
try:
|
||
parsed = json.loads(repair_json(raw))
|
||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
raise ValueError("无法从 LLM 回复中提取 JSON。") from exc
|
||
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
raise ValueError("无法从 LLM 回复中提取 JSON。")
|
||
|
||
|
||
# =========================================================================
|
||
# C. Judge 辅助函数
|
||
# =========================================================================
|
||
|
||
|
||
async def _call_judge(
|
||
llm: LLMProvider,
|
||
system_prompt: str,
|
||
user_prompt: str,
|
||
*,
|
||
max_retries: int = 2,
|
||
session_id: str | None = None,
|
||
) -> dict:
|
||
"""调用 judge 模型,解析 JSON 返回。解析失败时重试。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
system_prompt: 系统提示词。
|
||
user_prompt: 用户提示词。
|
||
max_retries: 解析失败后的额外重试次数(默认 2,即总共最多调用 3 次)。
|
||
session_id: 会话标识(可选,传入 LLMProvider 用于遥测)。
|
||
|
||
返回:
|
||
解析后的 JSON 字典。
|
||
|
||
异常:
|
||
ValueError: 所有尝试均无法从回复中提取合法 JSON 时抛出。
|
||
其他 API 异常直接传播,不在此处捕获。
|
||
"""
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
]
|
||
last_exc: ValueError | None = None
|
||
for attempt in range(1 + max_retries):
|
||
response = await llm.chat(messages, session_id=session_id)
|
||
raw = response.content
|
||
try:
|
||
return extract_json_from_response(raw)
|
||
except ValueError as exc:
|
||
last_exc = exc
|
||
logger.warning("judge JSON 解析失败 (attempt {}/{})", attempt + 1, 1 + max_retries)
|
||
raise last_exc # type: ignore[misc]
|
||
|
||
|
||
def question_soft_score(span_metrics: list[SpanMetrics]) -> float | None:
|
||
"""按题 soft 分 = 各 span 的 mean(completeness, 1-hallucination) 再对 spans 取均值。
|
||
|
||
参数:
|
||
span_metrics: 该题的 SpanMetrics 列表。
|
||
|
||
返回:
|
||
题级 soft 连续分 [0,1];无 span_metrics 返回 None(invalid)。
|
||
|
||
关键实现:
|
||
无 span 时返回 None(invalid),绝不补 0 掩盖(守 P5)——
|
||
分析阶段按 None 跳过该题,而非把缺失误判为 0 分。
|
||
"""
|
||
if not span_metrics:
|
||
return None
|
||
per_span = [
|
||
(s.extraction_completeness + (1.0 - s.hallucination_rate)) / 2.0 for s in span_metrics
|
||
]
|
||
return sum(per_span) / len(per_span)
|
||
|
||
|
||
def aggregate_soft(scores: list[float | None]) -> float | None:
|
||
"""对一组按题 soft 分取均值,跳过 invalid(None)。
|
||
|
||
参数:
|
||
scores: 各题 soft 分,None 表示该题 invalid(无 span)。
|
||
|
||
返回:
|
||
有效题 soft 均值;全部 invalid 返回 None。
|
||
"""
|
||
valid = [s for s in scores if s is not None]
|
||
if not valid:
|
||
return None
|
||
return sum(valid) / len(valid)
|
||
|
||
|
||
# =========================================================================
|
||
# D. 5 个 Judge 评估函数(async)
|
||
# =========================================================================
|
||
|
||
|
||
def _stringify_tool_args(tool_args: Any) -> str:
|
||
"""将工具参数转换为紧凑文本。
|
||
|
||
参数:
|
||
tool_args: 工具参数(str 或可序列化对象)。
|
||
|
||
返回:
|
||
紧凑 JSON 字符串。
|
||
"""
|
||
if isinstance(tool_args, str):
|
||
return tool_args
|
||
return json.dumps(tool_args, ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
def _parse_tool_args(tool_args: Any) -> dict[str, object]:
|
||
"""解析 trace 中的工具参数。
|
||
|
||
参数:
|
||
tool_args: 原始工具参数(dict 或 JSON 字符串)。
|
||
|
||
返回:
|
||
解析后的参数字典;解析失败返回空字典。
|
||
"""
|
||
if isinstance(tool_args, dict):
|
||
return tool_args
|
||
if isinstance(tool_args, str):
|
||
try:
|
||
parsed = json.loads(tool_args)
|
||
except json.JSONDecodeError:
|
||
logger.warning("tool_args 解析失败,回退为空字典: {}", tool_args)
|
||
return {}
|
||
if isinstance(parsed, dict):
|
||
return parsed
|
||
return {}
|
||
|
||
|
||
async def evaluate_span(
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
question: str,
|
||
tool_name: str,
|
||
tool_args: dict,
|
||
tool_output: str,
|
||
ground_truth: str,
|
||
step: int,
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> SpanMetrics:
|
||
"""评估单次 span 级工具调用质量。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
question: 题目文本。
|
||
tool_name: 工具名称。
|
||
tool_args: 工具参数。
|
||
tool_output: 工具输出。
|
||
ground_truth: 对应节点的 ground truth。
|
||
step: 步骤编号。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
SpanMetrics 实例。
|
||
"""
|
||
user_prompt = (
|
||
f"## 问题\n{question}\n\n"
|
||
f"## 工具调用\n工具: {tool_name}\n"
|
||
f"参数: {json.dumps(tool_args, ensure_ascii=False)}\n\n"
|
||
f"## 工具输出\n{tool_output}\n\n"
|
||
f"## 原始数据(ground truth)\n{ground_truth}"
|
||
)
|
||
parsed = await _call_judge(llm, prompts.span_eval_system, user_prompt, session_id=session_id)
|
||
return SpanMetrics(
|
||
step=int(step),
|
||
tool_name=tool_name,
|
||
extraction_completeness=float(parsed.get("extraction_completeness", 0.0)),
|
||
hallucination_rate=float(parsed.get("hallucination_rate", 0.0)),
|
||
missed_info_tags=list(parsed.get("missed_info_tags", [])),
|
||
hallucination_tags=list(parsed.get("hallucination_tags", [])),
|
||
)
|
||
|
||
|
||
async def judge_missed_nodes(
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
question: str,
|
||
options: list[str] | str,
|
||
answer: str,
|
||
tree_content: str,
|
||
visited_node_ids: list[str],
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> list[str]:
|
||
"""评估是否遗漏关键节点。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
question: 题目文本。
|
||
options: 选项列表或文本。
|
||
answer: 正确答案。
|
||
tree_content: 树结构文本。
|
||
visited_node_ids: 已访问的节点 ID 列表。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
遗漏的节点 ID 列表。
|
||
"""
|
||
options_text = "\n".join(options) if isinstance(options, list | tuple) else str(options)
|
||
user_prompt = (
|
||
f"## 问题\n{question}\n\n"
|
||
f"## 选项\n{options_text}\n\n"
|
||
f"## 答案\n{answer}\n\n"
|
||
f"## 树内容\n{tree_content}\n\n"
|
||
f"## 已访问节点\n{json.dumps(visited_node_ids, ensure_ascii=False)}"
|
||
)
|
||
parsed = await _call_judge(llm, prompts.missed_nodes, user_prompt, session_id=session_id)
|
||
missed = parsed.get("missed_nodes", [])
|
||
if isinstance(missed, list):
|
||
return [str(nid) for nid in missed]
|
||
return []
|
||
|
||
|
||
async def judge_skill_adherence(
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
skill_content: str,
|
||
trace_text: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> list[SkillStepAdherence]:
|
||
"""评估技能步骤遵循情况。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
skill_content: 技能文件全文。
|
||
trace_text: 格式化后的执行轨迹文本。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
SkillStepAdherence 列表。
|
||
"""
|
||
user_prompt = f"## Skill 内容\n{skill_content}\n\n## 执行轨迹\n{trace_text}"
|
||
parsed = await _call_judge(llm, prompts.skill_adherence, user_prompt, session_id=session_id)
|
||
steps = parsed.get("steps", [])
|
||
if not isinstance(steps, list):
|
||
return []
|
||
|
||
results: list[SkillStepAdherence] = []
|
||
for item in steps:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
results.append(
|
||
SkillStepAdherence(
|
||
step_label=str(item.get("step_label", "")),
|
||
adhered=bool(item.get("adhered", False)),
|
||
description=str(item.get("description", "")),
|
||
)
|
||
)
|
||
return results
|
||
|
||
|
||
async def judge_confirmation_bias(
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
question: str,
|
||
options: list[str] | str,
|
||
trace_text: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> tuple[bool, str]:
|
||
"""评估是否存在确认偏误。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
question: 题目文本。
|
||
options: 选项列表或文本。
|
||
trace_text: 格式化后的执行轨迹文本。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
(has_bias, evidence) 元组。
|
||
"""
|
||
options_text = "\n".join(options) if isinstance(options, list | tuple) else str(options)
|
||
user_prompt = f"## 问题\n{question}\n\n## 选项\n{options_text}\n\n## 执行轨迹\n{trace_text}"
|
||
parsed = await _call_judge(llm, prompts.confirmation_bias, user_prompt, session_id=session_id)
|
||
return bool(parsed.get("has_bias", False)), str(parsed.get("evidence", ""))
|
||
|
||
|
||
async def judge_evidence_sufficiency(
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
question: str,
|
||
options: list[str] | str,
|
||
answer: str,
|
||
all_tool_outputs: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> tuple[bool, str]:
|
||
"""评估当前证据是否充足。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
question: 题目文本。
|
||
options: 选项列表或文本。
|
||
answer: 正确答案。
|
||
all_tool_outputs: 全部工具输出拼接文本。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
(sufficient, reasoning) 元组。
|
||
"""
|
||
options_text = "\n".join(options) if isinstance(options, list | tuple) else str(options)
|
||
user_prompt = (
|
||
f"## 问题\n{question}\n\n"
|
||
f"## 选项\n{options_text}\n\n"
|
||
f"## 答案\n{answer}\n\n"
|
||
f"## 所有工具输出\n{all_tool_outputs}"
|
||
)
|
||
parsed = await _call_judge(
|
||
llm, prompts.evidence_sufficiency, user_prompt, session_id=session_id
|
||
)
|
||
return bool(parsed.get("sufficient", False)), str(parsed.get("reasoning", ""))
|
||
|
||
|
||
# =========================================================================
|
||
# E. compute_question_metrics(async 编排)
|
||
# =========================================================================
|
||
|
||
|
||
def _format_trace_text(traces: list[dict]) -> str:
|
||
"""将 trace 列表格式化为 judge 可读文本(指标版本:截断 thought/tool_output)。
|
||
|
||
参数:
|
||
traces: trace 字典列表。
|
||
|
||
返回:
|
||
格式化后的多行文本。
|
||
"""
|
||
lines: list[str] = []
|
||
for trace in traces:
|
||
step = trace.get("step", "")
|
||
thought = str(trace.get("thought", ""))[:100]
|
||
tool_name = trace.get("tool_name", "")
|
||
tool_args = _stringify_tool_args(trace.get("tool_args", {}))
|
||
tool_output = str(trace.get("tool_output", ""))[:200]
|
||
lines.append(
|
||
f'Step {step}: thinking="{thought}" → {tool_name}({tool_args}) → {tool_output}'
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _load_tree_content(tree_data: dict) -> str:
|
||
"""将树结构内容整理为文本。
|
||
|
||
参数:
|
||
tree_data: 树结构字典,含 "nodes" 键。
|
||
|
||
返回:
|
||
格式化后的树结构文本。
|
||
"""
|
||
nodes = tree_data.get("nodes", {})
|
||
if not isinstance(nodes, dict):
|
||
return ""
|
||
|
||
chunks: list[str] = []
|
||
for node_id in sorted(nodes):
|
||
node = nodes.get(node_id, {})
|
||
if not isinstance(node, dict):
|
||
continue
|
||
level = node.get("level", "")
|
||
time_range = node.get("time_range", [0, 0])
|
||
if not isinstance(time_range, list | tuple) or len(time_range) < 2:
|
||
time_range = [0, 0]
|
||
t_start, t_end = time_range[0], time_range[1]
|
||
card_json = json.dumps(node.get("card", {}), ensure_ascii=False, sort_keys=True)
|
||
chunks.append(
|
||
f"### {node_id} | L{level} | {float(t_start):.0f}-{float(t_end):.0f}s\n{card_json}"
|
||
)
|
||
return "\n\n".join(chunks)
|
||
|
||
|
||
def _get_ground_truth_for_trace(tree_data: dict, tool_name: str, tool_args: dict) -> str:
|
||
"""按工具类型获取对应节点的 ground truth。
|
||
|
||
参数:
|
||
tree_data: 树结构字典。
|
||
tool_name: 工具名称。
|
||
tool_args: 工具参数字典。
|
||
|
||
返回:
|
||
节点 card 的 JSON 字符串;无匹配时返回空字符串。
|
||
"""
|
||
nodes = tree_data.get("nodes", {})
|
||
if not isinstance(nodes, dict):
|
||
return ""
|
||
|
||
node_id = ""
|
||
if tool_name == "observe_frame":
|
||
node_ids = tool_args.get("node_ids", [])
|
||
if isinstance(node_ids, list) and node_ids:
|
||
node_id = str(node_ids[0])
|
||
else:
|
||
node_id = str(tool_args.get("node_id", ""))
|
||
if not node_id:
|
||
node_ids = tool_args.get("node_ids", [])
|
||
if isinstance(node_ids, list) and node_ids:
|
||
node_id = str(node_ids[0])
|
||
|
||
node = nodes.get(node_id, {})
|
||
if not isinstance(node, dict):
|
||
return ""
|
||
return json.dumps(node.get("card", {}), ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
async def compute_question_metrics(
|
||
prediction: dict[str, Any],
|
||
traces: list[dict[str, Any]],
|
||
tree_data: dict[str, Any],
|
||
skill_content: str,
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
max_steps: int,
|
||
raw_contents: list[str] | None = None,
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> QuestionMetrics:
|
||
"""编排单题规则指标与 LLM judge 指标。
|
||
|
||
参数:
|
||
prediction: 单题预测记录。
|
||
traces: 该题的执行轨迹列表。
|
||
tree_data: 树结构字典。
|
||
skill_content: 技能文件全文。
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
max_steps: 最大步数预算。
|
||
raw_contents: 各步原始输出(可选,默认从 steps_json 提取)。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
QuestionMetrics 实例。
|
||
"""
|
||
if raw_contents is None:
|
||
raw_contents = [
|
||
str(step.get("tool_output", "")) for step in prediction.get("steps_json", [])
|
||
]
|
||
|
||
rule_metrics_dict = extract_rule_metrics(prediction, raw_contents, max_steps)
|
||
|
||
# Phase 1: span 评估 + 收集已访问节点
|
||
span_evals_list: list[SpanMetrics] = []
|
||
visited_node_ids: list[str] = []
|
||
seen_node_ids: set[str] = set()
|
||
|
||
for trace in traces:
|
||
tool_name = trace.get("tool_name")
|
||
tool_args = _parse_tool_args(trace.get("tool_args", {}))
|
||
if tool_name in _SPAN_EVAL_TOOLS:
|
||
span_evals_list.append(
|
||
await evaluate_span(
|
||
llm=llm,
|
||
prompts=prompts,
|
||
question=prediction.get("question", ""),
|
||
tool_name=str(tool_name),
|
||
tool_args=tool_args,
|
||
tool_output=str(trace.get("tool_output", "")),
|
||
ground_truth=_get_ground_truth_for_trace(tree_data, str(tool_name), tool_args),
|
||
step=int(trace.get("step", 0)),
|
||
session_id=session_id,
|
||
)
|
||
)
|
||
|
||
if tool_name == "view_node":
|
||
node_id = tool_args.get("node_id")
|
||
if isinstance(node_id, str) and node_id and node_id not in seen_node_ids:
|
||
seen_node_ids.add(node_id)
|
||
visited_node_ids.append(node_id)
|
||
|
||
# Phase 2: 全局 judge 评估
|
||
all_tool_outputs = "\n".join(
|
||
str(trace.get("tool_output", ""))
|
||
for trace in traces
|
||
if trace.get("tool_name") in _SPAN_EVAL_TOOLS
|
||
)
|
||
options_list = (
|
||
prediction.get("options", "").split("\n")
|
||
if isinstance(prediction.get("options"), str)
|
||
else prediction.get("options", [])
|
||
)
|
||
trace_text = _format_trace_text(traces)
|
||
tree_content = _load_tree_content(tree_data)
|
||
|
||
missed_nodes_list = await judge_missed_nodes(
|
||
llm=llm,
|
||
prompts=prompts,
|
||
question=prediction.get("question", ""),
|
||
options=options_list,
|
||
answer=prediction.get("answer", ""),
|
||
tree_content=tree_content,
|
||
visited_node_ids=visited_node_ids,
|
||
session_id=session_id,
|
||
)
|
||
skill_adherence_list = await judge_skill_adherence(
|
||
llm=llm,
|
||
prompts=prompts,
|
||
skill_content=skill_content,
|
||
trace_text=trace_text,
|
||
session_id=session_id,
|
||
)
|
||
has_bias, _bias_evidence = await judge_confirmation_bias(
|
||
llm=llm,
|
||
prompts=prompts,
|
||
question=prediction.get("question", ""),
|
||
options=options_list,
|
||
trace_text=trace_text,
|
||
session_id=session_id,
|
||
)
|
||
sufficient, _reasoning = await judge_evidence_sufficiency(
|
||
llm=llm,
|
||
prompts=prompts,
|
||
question=prediction.get("question", ""),
|
||
options=options_list,
|
||
answer=prediction.get("answer", ""),
|
||
all_tool_outputs=all_tool_outputs,
|
||
session_id=session_id,
|
||
)
|
||
|
||
return QuestionMetrics(
|
||
question_id=prediction["question_id"],
|
||
video_id=prediction["video_id"],
|
||
task_type=prediction["task_type"],
|
||
correct=bool(prediction.get("correct", False)),
|
||
format_compliance=rule_metrics_dict["format_compliance"],
|
||
budget_usage=rule_metrics_dict["budget_usage"],
|
||
confidence_calibration=rule_metrics_dict["confidence_calibration"],
|
||
repeat_visit_rate=rule_metrics_dict["repeat_visit_rate"],
|
||
search_keyword_repetition=rule_metrics_dict["search_keyword_repetition"],
|
||
level_jump_pattern=rule_metrics_dict["level_jump_pattern"],
|
||
tool_usage=rule_metrics_dict["tool_usage"],
|
||
span_metrics=span_evals_list,
|
||
missed_nodes=missed_nodes_list,
|
||
skill_adherence=skill_adherence_list,
|
||
confirmation_bias=has_bias,
|
||
evidence_sufficient=sufficient,
|
||
)
|
||
|
||
|
||
# =========================================================================
|
||
# F. 错误归因
|
||
# =========================================================================
|
||
|
||
|
||
def _mean(values: list[float]) -> float:
|
||
"""计算均值;空列表返回 0.0。
|
||
|
||
参数:
|
||
values: 浮点值列表。
|
||
|
||
返回:
|
||
均值或 0.0。
|
||
"""
|
||
if not values:
|
||
return 0.0
|
||
return sum(values) / len(values)
|
||
|
||
|
||
def attribute_error(qm: QuestionMetrics) -> ErrorAttribution:
|
||
"""按瀑布规则归因单题错误类型。
|
||
|
||
瀑布顺序:
|
||
1. extraction: completeness<0.5 或 hallucination>0.5
|
||
2. search: 有遗漏节点
|
||
3. reasoning: evidence_sufficient=True(证据够但推理错)
|
||
4. mixed: 其余
|
||
|
||
参数:
|
||
qm: 单题指标。
|
||
|
||
返回:
|
||
ErrorAttribution 实例。
|
||
"""
|
||
avg_completeness = _mean([span.extraction_completeness for span in qm.span_metrics])
|
||
max_hallucination = max((span.hallucination_rate for span in qm.span_metrics), default=0.0)
|
||
|
||
if avg_completeness < 0.5 or max_hallucination > 0.5:
|
||
error_type = "extraction_failure"
|
||
elif len(qm.missed_nodes) > 0:
|
||
error_type = "search_failure"
|
||
elif qm.evidence_sufficient is True:
|
||
error_type = "reasoning_failure"
|
||
else:
|
||
error_type = "mixed"
|
||
|
||
return ErrorAttribution(
|
||
question_id=qm.question_id,
|
||
error_type=error_type,
|
||
reasoning_failure_type=None,
|
||
)
|
||
|
||
|
||
async def classify_defect_vs_lapse(
|
||
llm: LLMProvider,
|
||
prompts: DiagnosePrompts,
|
||
prediction: dict[str, Any],
|
||
traces: list[dict[str, Any]],
|
||
prompt_content: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
) -> tuple[str, str]:
|
||
"""判别错题病因:defect(改正文)vs lapse(记提醒)。
|
||
|
||
判不准默认 lapse(保护正文),这是设计明确的保护性 fallback。
|
||
|
||
参数:
|
||
llm: LLM 调用端口。
|
||
prompts: 诊断模板束。
|
||
prediction: 单题预测记录(含 question/answer/prediction)。
|
||
traces: 该题执行轨迹。
|
||
prompt_content: Agent 当时所用的 prompt 全文。
|
||
session_id: 会话标识(可选)。
|
||
|
||
返回:
|
||
(category, note);category 取值 'defect' 或 'lapse',
|
||
note 为 lapse 提醒文本。
|
||
|
||
异常:
|
||
API 基础设施异常(网络/超时等)直接传播,不掩盖。
|
||
"""
|
||
trace_text = _format_trace_text(traces)
|
||
user_prompt = (
|
||
f"## 题目\n{prediction.get('question', '')}\n\n"
|
||
f"## 正确答案\n{prediction.get('answer', '')}\n\n"
|
||
f"## Agent 错误预测\n{prediction.get('prediction', '')}\n\n"
|
||
f"## 当前 prompt 全文\n{prompt_content}\n\n"
|
||
f"## 执行轨迹\n{trace_text}"
|
||
)
|
||
# chat() 的基础设施失败(网络/API)刻意不在此捕获——按 P5,应向上传播报错,
|
||
# 不能用默认值掩盖。保护性 fallback 只针对"judge 回复无法解析/判不准"这一语义歧义。
|
||
response = await llm.chat(
|
||
[
|
||
{"role": "system", "content": prompts.defect_vs_lapse},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
session_id=session_id,
|
||
)
|
||
try:
|
||
parsed = extract_json_from_response(response.content)
|
||
except ValueError:
|
||
parsed = None # judge 回复无法解析 → 落入保护性 fallback
|
||
category = parsed.get("category") if isinstance(parsed, dict) else None
|
||
if category not in ("defect", "lapse"):
|
||
category = "lapse" # 保护性 fallback
|
||
note = parsed.get("note", "") if isinstance(parsed, dict) else ""
|
||
return category, (note if isinstance(note, str) else "")
|
||
|
||
|
||
def _make_degraded_metrics(prediction: dict[str, Any], max_steps: int) -> QuestionMetrics:
|
||
"""生成降级版 QuestionMetrics:规则指标正常计算,judge 指标标记为不可用。
|
||
|
||
在 judge JSON 解析失败(ValueError)时调用。
|
||
其他异常类型不由本函数处理,应向上传播。
|
||
|
||
参数:
|
||
prediction: 单题预测记录。
|
||
max_steps: 最大步数预算。
|
||
|
||
返回:
|
||
degraded=True 的 QuestionMetrics,judge 字段置为 None/空。
|
||
"""
|
||
raw_contents = [str(step.get("tool_output", "")) for step in prediction.get("steps_json", [])]
|
||
rule = extract_rule_metrics(prediction, raw_contents, max_steps)
|
||
return QuestionMetrics(
|
||
question_id=prediction["question_id"],
|
||
video_id=prediction["video_id"],
|
||
task_type=prediction["task_type"],
|
||
correct=bool(prediction.get("correct", False)),
|
||
format_compliance=rule["format_compliance"],
|
||
budget_usage=rule["budget_usage"],
|
||
confidence_calibration=rule["confidence_calibration"],
|
||
repeat_visit_rate=rule["repeat_visit_rate"],
|
||
search_keyword_repetition=rule["search_keyword_repetition"],
|
||
level_jump_pattern=rule["level_jump_pattern"],
|
||
tool_usage=rule["tool_usage"],
|
||
span_metrics=[],
|
||
missed_nodes=[],
|
||
skill_adherence=[],
|
||
confirmation_bias=None,
|
||
evidence_sufficient=None,
|
||
degraded=True,
|
||
)
|