Files
Video-Tree-TRM5/core/evolution/diagnose.py
T

2306 lines
78 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.
"""诊断引擎 — 指标计算、judge 辅助、聚合、案例包构建与入口。
两阶段诊断管线的可提取内核。包含:
- 7 个规则指标的纯函数计算
- JSON 提取工具
- 5 个 LLM judge 评估函数(async
- 单题指标编排 compute_question_metrics
- 错误归因瀑布 attribute_error
- defect/lapse 病因判别 classify_defect_vs_lapse
- 降级指标生成 _make_degraded_metrics
- D2-D5 聚合函数
- 案例包构建(skill / system / tool
- merge 函数
- run_diagnosis 入口
不依赖 app/ 或 adapters/。所有 LLM 交互通过 LLMProvider Protocol 注入。
"""
from __future__ import annotations
import asyncio
import json
import re
from collections import Counter, defaultdict
from statistics import median
from typing import TYPE_CHECKING, Any
from json_repair import repair_json
from loguru import logger
from core.evolution.types import (
CaseSample,
DiagnosePrompts,
DiagnosisResult,
ErrorAttribution,
QuestionMetrics,
SkillCasePack,
SkillStepAdherence,
SpanMetrics,
SystemCasePack,
ToolCasePack,
)
if TYPE_CHECKING:
from core.evolution.protocols import RunLog, SkillStore
from core.protocols import LLMProvider
from core.types import GeneratedQuestion
# =========================================================================
# 常量
# =========================================================================
_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 返回 Noneinvalid)。
关键实现:
无 span 时返回 Noneinvalid),绝不补 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_metricsasync 编排)
# =========================================================================
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 的 QuestionMetricsjudge 字段置为 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,
)
# =========================================================================
# G. 辅助函数 — _percentile
# =========================================================================
def _percentile(values: list[float], pct: float) -> float:
"""按线性插值计算分位数。
参数:
values: 浮点值列表。
pct: 分位数位置 [0.0, 1.0]。
返回:
分位数值;空列表返回 0.0;单元素返回该元素。
"""
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
position = pct * (len(ordered) - 1)
lower = int(position)
upper = min(lower + 1, len(ordered) - 1)
weight = position - lower
return ordered[lower] * (1 - weight) + ordered[upper] * weight
# =========================================================================
# H. D2-D5 聚合函数
# =========================================================================
def _parse_level_sequence(level_jump_pattern: str) -> list[str]:
"""从层级跳转文本中提取层级序列。
参数:
level_jump_pattern: 层级跳转模式字符串。
返回:
层级标签列表。
"""
return re.findall(r"L\d+", level_jump_pattern or "")
def _extract_level_from_node(node_id: str) -> str | None:
"""从节点 ID 中提取 L1/L2/L3 层级。
参数:
node_id: 节点标识字符串。
返回:
层级标签或 None。
"""
match = re.search(r"L([123])", node_id or "")
if match is None:
return None
return f"L{match.group(1)}"
def aggregate_d2(all_metrics: list[QuestionMetrics]) -> dict[str, dict]:
"""D2: 按工具聚合 span 级质量指标。
参数:
all_metrics: 全部题目的 Stage 1 指标。
返回:
{tool_name: {avg_completeness, avg_hallucination, n_calls, top_missed, top_hallucinated}}。
"""
grouped: dict[str, list[SpanMetrics]] = defaultdict(list)
for qm in all_metrics:
for span in qm.span_metrics:
grouped[span.tool_name].append(span)
result: dict[str, dict] = {}
for tool_name, spans in grouped.items():
missed_counter: Counter[str] = Counter()
hallucinated_counter: Counter[str] = Counter()
for span in spans:
missed_counter.update(span.missed_info_tags)
hallucinated_counter.update(span.hallucination_tags)
result[tool_name] = {
"avg_completeness": _mean([span.extraction_completeness for span in spans]),
"avg_hallucination": _mean([span.hallucination_rate for span in spans]),
"n_calls": len(spans),
"top_missed": [[tag, count] for tag, count in missed_counter.most_common()],
"top_hallucinated": [[tag, count] for tag, count in hallucinated_counter.most_common()],
}
return result
def aggregate_d3(all_metrics: list[QuestionMetrics]) -> dict[str, dict]:
"""D3: 按题型与正误拆分搜索行为统计。
注意: 键 ``avg_steps`` 实际存储 budget_usage 均值(TRM4 历史命名,保持兼容)。
参数:
all_metrics: 全部题目的 Stage 1 指标。
返回:
{task_type: {correct: {...}, incorrect: {...}}}。
"""
grouped: dict[str, dict[str, list[QuestionMetrics]]] = defaultdict(
lambda: {"correct": [], "incorrect": []}
)
for qm in all_metrics:
bucket = "correct" if qm.correct else "incorrect"
grouped[qm.task_type][bucket].append(qm)
result: dict[str, dict] = {}
for task_type, task_groups in grouped.items():
task_result: dict[str, Any] = {}
for bucket_name, metrics_group in task_groups.items():
task_result[bucket_name] = {
"repeat_visit_rate": _mean([qm.repeat_visit_rate for qm in metrics_group]),
"keyword_repetition": _mean([qm.search_keyword_repetition for qm in metrics_group]),
"l3_usage_rate": _mean(
[
1.0 if "L3" in _parse_level_sequence(qm.level_jump_pattern) else 0.0
for qm in metrics_group
]
),
"observe_frame_rate": _mean(
[
1.0 if qm.tool_usage.get("observe_frame", 0) > 0 else 0.0
for qm in metrics_group
]
),
"avg_steps": _mean([qm.budget_usage for qm in metrics_group]),
"n_questions": len(metrics_group),
}
incorrect_group = task_groups["incorrect"]
level_counts = {"L1": 0, "L2": 0, "L3": 0}
for qm in incorrect_group:
for node_id in qm.missed_nodes:
level = _extract_level_from_node(node_id)
if level in level_counts:
level_counts[level] += 1
task_result["incorrect"]["missed_nodes_rate"] = _mean(
[1.0 if qm.missed_nodes else 0.0 for qm in incorrect_group]
)
task_result["incorrect"]["missed_node_levels"] = level_counts
result[task_type] = task_result
return result
def aggregate_d4(all_metrics: list[QuestionMetrics]) -> dict[str, dict]:
"""D4: 按题型聚合 skill step 遵循与收益差异。
除以零时返回 0.0。
参数:
all_metrics: 全部题目的 Stage 1 指标。
返回:
{task_type: {overall_adherence, n_questions, steps: {step_label: {...}}}}。
"""
grouped: dict[str, list[QuestionMetrics]] = defaultdict(list)
for qm in all_metrics:
grouped[qm.task_type].append(qm)
result: dict[str, dict] = {}
for task_type, metrics_group in grouped.items():
total_steps = 0
adhered_steps = 0
step_stats: dict[str, dict[str, int]] = defaultdict(
lambda: {
"adhered": 0,
"deviated": 0,
"correct_adhered": 0,
"correct_deviated": 0,
}
)
for qm in metrics_group:
for step in qm.skill_adherence:
total_steps += 1
if step.adhered:
adhered_steps += 1
step_stats[step.step_label]["adhered"] += 1
step_stats[step.step_label]["correct_adhered"] += int(qm.correct)
else:
step_stats[step.step_label]["deviated"] += 1
step_stats[step.step_label]["correct_deviated"] += int(qm.correct)
task_steps: dict[str, dict[str, float]] = {}
for step_label, stats in step_stats.items():
adhered_count = stats["adhered"]
deviated_count = stats["deviated"]
total_count = adhered_count + deviated_count
acc_adhered = stats["correct_adhered"] / adhered_count if adhered_count > 0 else 0.0
acc_deviated = stats["correct_deviated"] / deviated_count if deviated_count > 0 else 0.0
task_steps[step_label] = {
"adherence_rate": adhered_count / total_count if total_count else 0.0,
"acc_adhered": acc_adhered,
"acc_deviated": acc_deviated,
"delta": acc_adhered - acc_deviated,
}
result[task_type] = {
"overall_adherence": adhered_steps / total_steps if total_steps else 0.0,
"n_questions": len(metrics_group),
"steps": task_steps,
}
return result
def aggregate_d5(all_metrics: list[QuestionMetrics]) -> dict[str, Any]:
"""D5: 跨题型聚合决策与校准模式。
空输入返回完整零结构(非空字典)。confirmation_bias_rate 过滤 None。
参数:
all_metrics: 全部题目的 Stage 1 指标。
返回:
包含各模式比率的字典。
"""
if not all_metrics:
return {
"format_compliance_rate": 0.0,
"budget_usage_median": 0.0,
"budget_usage_p25": 0.0,
"budget_usage_p75": 0.0,
"early_submit_rate": 0.0,
"high_conf_wrong_rate": 0.0,
"low_conf_right_rate": 0.0,
"confirmation_bias_rate": 0.0,
"per_type_bias": {},
}
budget_values = [qm.budget_usage for qm in all_metrics]
wrong_metrics = [qm for qm in all_metrics if not qm.correct]
per_type_groups: dict[str, list[QuestionMetrics]] = defaultdict(list)
for qm in all_metrics:
per_type_groups[qm.task_type].append(qm)
return {
"format_compliance_rate": _mean([qm.format_compliance for qm in all_metrics]),
"budget_usage_median": median(budget_values),
"budget_usage_p25": _percentile(budget_values, 0.25),
"budget_usage_p75": _percentile(budget_values, 0.75),
"early_submit_rate": (
sum(1 for qm in wrong_metrics if qm.budget_usage < 0.3) / len(wrong_metrics)
if wrong_metrics
else 0.0
),
"high_conf_wrong_rate": _mean(
[1.0 if qm.confidence_calibration == "high_conf_wrong" else 0.0 for qm in all_metrics]
),
"low_conf_right_rate": _mean(
[1.0 if qm.confidence_calibration == "low_conf_right" else 0.0 for qm in all_metrics]
),
"confirmation_bias_rate": _mean(
[
1.0 if qm.confirmation_bias else 0.0
for qm in all_metrics
if qm.confirmation_bias is not None
]
),
"per_type_bias": {
task_type: _mean(
[
1.0 if qm.confirmation_bias else 0.0
for qm in group
if qm.confirmation_bias is not None
]
)
for task_type, group in per_type_groups.items()
},
}
# =========================================================================
# I. 案例包构建
# =========================================================================
_SEVERITY_FNS: dict[str, Any] = {}
_MIN_PATTERN_COUNT = 3
_TOOL_TARGET_FILES = {
"view_node": [
"view_node_extract.md",
"view_node_verify.md",
"view_node_children_extract.md",
"view_node_children_verify.md",
],
"search_similar": ["search_similar_extract.md", "search_similar_verify.md"],
"observe_frame": ["observe_frame_extract.md", "observe_frame_verify.md"],
}
def _calc_adherence_rate(adherence_list: list[SkillStepAdherence]) -> float:
"""计算 skill adherence 率。
参数:
adherence_list: 技能步骤遵循判定列表。
返回:
遵循率;空列表返回 0.0。
"""
if not adherence_list:
return 0.0
adhered = sum(1 for s in adherence_list if s.adhered)
return adhered / len(adherence_list)
def _severity_search_failure(qm: QuestionMetrics) -> tuple[int, float]:
"""search_failure 严重度:(missed_nodes 数降序, budget_usage 降序)。
参数:
qm: 单题指标。
返回:
严重度排序元组。
"""
return (len(qm.missed_nodes), qm.budget_usage)
def _severity_extraction_failure(qm: QuestionMetrics) -> tuple[float, float]:
"""extraction_failure 严重度:(max hallucination 降序, 1-avg completeness 降序)。
参数:
qm: 单题指标。
返回:
严重度排序元组。
"""
max_hall = max((s.hallucination_rate for s in qm.span_metrics), default=0.0)
avg_comp = _mean([s.extraction_completeness for s in qm.span_metrics])
return (max_hall, 1.0 - avg_comp)
def _severity_reasoning_failure(qm: QuestionMetrics) -> tuple[int, float]:
"""reasoning_failure 严重度:(high_conf_wrong 优先, budget_usage 降序)。
参数:
qm: 单题指标。
返回:
严重度排序元组。
"""
is_high_conf = 1 if qm.confidence_calibration == "high_conf_wrong" else 0
return (is_high_conf, qm.budget_usage)
def _severity_mixed(qm: QuestionMetrics) -> tuple[float, int]:
"""mixed 严重度:(budget_usage 降序, missed_nodes 数降序)。
参数:
qm: 单题指标。
返回:
严重度排序元组。
"""
return (qm.budget_usage, len(qm.missed_nodes))
_SEVERITY_FNS = {
"search_failure": _severity_search_failure,
"extraction_failure": _severity_extraction_failure,
"reasoning_failure": _severity_reasoning_failure,
"mixed": _severity_mixed,
}
def _make_case_sample(
qm: QuestionMetrics,
prediction: dict[str, Any],
trace: list[dict[str, Any]],
error_type: str | None,
selection_reason: str,
) -> CaseSample:
"""从 QuestionMetrics 和 prediction 构造 CaseSample。
参数:
qm: 单题指标。
prediction: 单题预测记录。
trace: 完整推理轨迹。
error_type: 错误类型;正确题为 None。
selection_reason: 被选为案例的原因说明。
返回:
CaseSample 实例。
"""
return CaseSample(
question_id=qm.question_id,
video_id=qm.video_id,
task_type=qm.task_type,
question=prediction.get("question", ""),
options=prediction.get("options", []),
answer=prediction.get("answer", ""),
prediction=prediction.get("prediction"),
correct=qm.correct,
error_type=error_type,
selection_reason=selection_reason,
metrics={
"correct": qm.correct,
"error_type": error_type,
"budget_usage": qm.budget_usage,
"confidence_calibration": qm.confidence_calibration,
"repeat_visit_rate": qm.repeat_visit_rate,
"tool_usage": qm.tool_usage,
"missed_nodes": qm.missed_nodes,
"adherence_rate": _calc_adherence_rate(qm.skill_adherence),
"confirmation_bias": qm.confirmation_bias,
"evidence_sufficient": qm.evidence_sufficient,
},
trace=trace,
)
def _build_skill_case_packs(
all_metrics: list[QuestionMetrics],
error_attributions: list[ErrorAttribution],
traces_by_question: dict[tuple[str, str], list[dict[str, Any]]],
predictions: list[dict[str, Any]],
d3_stats: dict[str, dict],
d4_stats: dict[str, dict],
) -> dict[str, SkillCasePack]:
"""按题型构建 Skill 案例包。
C3 分流:cause_category=='lapse' 路由进 lapse_notes,不进 failure_cases。
单例 fallback:仅 1 条 defect → 降级为 lapse_note。
参数:
all_metrics: 全部题目的 Stage 1 指标。
error_attributions: 错题归因列表。
traces_by_question: (video_id, question_id) -> trace 列表。
predictions: 归一化后的 prediction 字典列表。
d3_stats: D3 搜索有效性聚合。
d4_stats: D4 技能遵循聚合。
返回:
{task_type: SkillCasePack} 映射。
"""
attribution_map: dict[str, ErrorAttribution] = {a.question_id: a for a in error_attributions}
prediction_map: dict[str, dict[str, Any]] = {p["question_id"]: p for p in predictions}
by_task: dict[str, list[QuestionMetrics]] = defaultdict(list)
for qm in all_metrics:
by_task[qm.task_type].append(qm)
packs: dict[str, SkillCasePack] = {}
for task_type, metrics_group in by_task.items():
target_file = task_type.lower().replace(" ", "-") + ".md"
# C3 分流
wrong_by_error: dict[str, list[QuestionMetrics]] = defaultdict(list)
lapse_notes: list[str] = []
for qm in metrics_group:
if qm.correct:
continue
attr = attribution_map.get(qm.question_id)
if attr is not None and attr.cause_category == "lapse":
if attr.lapse_note and attr.lapse_note.strip():
lapse_notes.append(attr.lapse_note)
continue
et = attr.error_type if attr else "mixed"
wrong_by_error[et].append(qm)
# 单条 fallback
n_body_failures = sum(len(group) for group in wrong_by_error.values())
if n_body_failures == 1:
[lone_qm] = next(iter(wrong_by_error.values()))
wrong_by_error.clear()
lone_attr = attribution_map.get(lone_qm.question_id)
note = lone_attr.lapse_note if lone_attr and lone_attr.lapse_note else None
lapse_notes.append(
note.strip() if note and note.strip() else "复核该类已有规则,避免重复此类单例失败"
)
failure_cases: list[CaseSample] = []
for error_type, wrong_group in wrong_by_error.items():
severity_fn = _SEVERITY_FNS.get(error_type, _severity_mixed)
sorted_group = sorted(wrong_group, key=severity_fn, reverse=True)
for qm in sorted_group[:2]:
trace = traces_by_question.get((qm.video_id, qm.question_id), [])
pred = prediction_map.get(qm.question_id, {})
sv = severity_fn(qm)
reason = f"error_type={error_type}, severity={sv}"
failure_cases.append(_make_case_sample(qm, pred, trace, error_type, reason))
# 成功案例
correct_group = [qm for qm in metrics_group if qm.correct]
n_correct = len(correct_group)
n_total = len(metrics_group)
accuracy = n_correct / n_total if n_total > 0 else 0.0
n_success = max(2, len(failure_cases) // 2)
low_accuracy = accuracy <= 0.3
if low_accuracy:
sorted_correct = sorted(correct_group, key=lambda qm: qm.budget_usage)
else:
sorted_correct = sorted(
correct_group,
key=lambda qm: (
-_calc_adherence_rate(qm.skill_adherence),
qm.budget_usage,
),
)
success_cases: list[CaseSample] = []
for qm in sorted_correct[:n_success]:
trace = traces_by_question.get((qm.video_id, qm.question_id), [])
pred = prediction_map.get(qm.question_id, {})
adh = _calc_adherence_rate(qm.skill_adherence)
reason = f"adherence={adh:.2f}, budget_usage={qm.budget_usage:.2f}"
if low_accuracy:
reason += ", low_accuracy_pool"
success_cases.append(_make_case_sample(qm, pred, trace, None, reason))
# D1 按题型拆分 attribution_distribution
attr_dist: dict[str, int] = Counter(
attribution_map[qm.question_id].error_type
for qm in metrics_group
if not qm.correct and qm.question_id in attribution_map
)
stats: dict[str, Any] = {
"n_total": n_total,
"n_correct": n_correct,
"accuracy": accuracy,
"attribution_distribution": dict(attr_dist),
}
if task_type in d3_stats:
stats["correct_vs_incorrect"] = d3_stats[task_type]
if task_type in d4_stats:
stats["overall_adherence"] = d4_stats[task_type].get("overall_adherence", 0.0)
stats["steps"] = d4_stats[task_type].get("steps", {})
packs[task_type] = SkillCasePack(
task_type=task_type,
target_file=target_file,
stats=stats,
failure_cases=failure_cases,
success_cases=success_cases,
lapse_notes=lapse_notes,
)
return packs
def _build_system_case_pack(
all_metrics: list[QuestionMetrics],
traces_by_question: dict[tuple[str, str], list[dict[str, Any]]],
predictions: list[dict[str, Any]],
d5_stats: dict[str, Any],
) -> SystemCasePack | None:
"""构建跨题型行为模式案例包。
3 个模式:early_submit / high_conf_wrong / confirmation_bias。
每个模式 >= _MIN_PATTERN_COUNT 才纳入。全部不达标则返回 None。
参数:
all_metrics: 全部题目的 Stage 1 指标。
traces_by_question: (video_id, question_id) -> trace 列表。
predictions: 归一化后的 prediction 字典列表。
d5_stats: D5 决策模式聚合。
返回:
SystemCasePack 或 None。
"""
prediction_map: dict[str, dict[str, Any]] = {p["question_id"]: p for p in predictions}
early_submit = [qm for qm in all_metrics if not qm.correct and qm.budget_usage < 0.3]
high_conf_wrong = [qm for qm in all_metrics if qm.confidence_calibration == "high_conf_wrong"]
confirmation_bias_cases = [
qm for qm in all_metrics if qm.confirmation_bias is True and not qm.correct
]
patterns: list[tuple[str, list[QuestionMetrics], bool]] = [
("early_submit", early_submit, True),
("high_conf_wrong", high_conf_wrong, False),
("confirmation_bias", confirmation_bias_cases, False),
]
failure_cases: list[CaseSample] = []
for pattern_name, candidates, sort_asc in patterns:
if len(candidates) < _MIN_PATTERN_COUNT:
continue
sorted_cands = sorted(candidates, key=lambda qm: qm.budget_usage, reverse=not sort_asc)
for qm in sorted_cands[:2]:
trace = traces_by_question.get((qm.video_id, qm.question_id), [])
pred = prediction_map.get(qm.question_id, {})
reason = f"pattern={pattern_name}, budget_usage={qm.budget_usage:.2f}"
failure_cases.append(_make_case_sample(qm, pred, trace, pattern_name, reason))
if not failure_cases:
return None
# 成功案例
good_candidates = [
qm
for qm in all_metrics
if qm.correct
and qm.confidence_calibration == "calibrated"
and qm.confirmation_bias is False
and 0.3 <= qm.budget_usage <= 0.8
]
sorted_good = sorted(good_candidates, key=lambda qm: abs(qm.budget_usage - 0.5))
n_success = max(2, len(failure_cases) // 2)
success_cases: list[CaseSample] = []
for qm in sorted_good[:n_success]:
trace = traces_by_question.get((qm.video_id, qm.question_id), [])
pred = prediction_map.get(qm.question_id, {})
reason = f"calibrated, budget_usage={qm.budget_usage:.2f}"
success_cases.append(_make_case_sample(qm, pred, trace, None, reason))
stats = dict(d5_stats)
stats["early_submit_count"] = len(early_submit)
stats["high_conf_wrong_count"] = len(high_conf_wrong)
stats["confirmation_bias_count"] = len(confirmation_bias_cases)
return SystemCasePack(
stats=stats,
failure_cases=failure_cases,
success_cases=success_cases,
)
def _build_tool_case_packs(
all_metrics: list[QuestionMetrics],
traces_by_question: dict[tuple[str, str], list[dict[str, Any]]],
d2_stats: dict[str, dict],
tree_data_by_video: dict[str, dict[str, Any]],
) -> dict[str, ToolCasePack]:
"""按工具构建 Tool Prompt 案例包。
失败 span: 低 completeness 优先取 up to 4,高 hallucination 填满到 4。
成功 span: completeness>=0.9 且 hallucination==0.0(精确零)。
参数:
all_metrics: 全部题目的 Stage 1 指标。
traces_by_question: (video_id, question_id) -> trace 列表。
d2_stats: D2 工具质量聚合。
tree_data_by_video: {video_id: tree_data} 缓存。
返回:
{tool_name: ToolCasePack} 映射。
"""
# 收集所有 span 及其来源信息
all_spans: list[dict[str, Any]] = []
for qm in all_metrics:
for span in qm.span_metrics:
traces = traces_by_question.get((qm.video_id, qm.question_id), [])
trace_step: dict[str, Any] = {}
for t in traces:
if t.get("step") == span.step and t.get("tool_name") == span.tool_name:
trace_step = t
break
raw_args = trace_step.get("tool_args", {})
if isinstance(raw_args, str):
try:
raw_args = json.loads(raw_args)
except (json.JSONDecodeError, ValueError):
raw_args = {}
if not isinstance(raw_args, dict):
raw_args = {}
all_spans.append(
{
"video_id": qm.video_id,
"question_id": qm.question_id,
"step": span.step,
"tool_name": span.tool_name,
"extraction_completeness": span.extraction_completeness,
"hallucination_rate": span.hallucination_rate,
"missed_info_tags": list(span.missed_info_tags),
"hallucination_tags": list(span.hallucination_tags),
"tool_args": raw_args,
"tool_output": str(trace_step.get("tool_output", "")),
"ground_truth": _get_ground_truth_for_trace(
tree_data_by_video.get(qm.video_id, {}),
span.tool_name,
raw_args,
),
}
)
by_tool: dict[str, list[dict[str, Any]]] = defaultdict(list)
for span_record in all_spans:
by_tool[span_record["tool_name"]].append(span_record)
packs: dict[str, ToolCasePack] = {}
for tool_name, spans in by_tool.items():
target_files = _TOOL_TARGET_FILES.get(tool_name, [])
if not target_files:
continue
# 失败 span
by_low_completeness = sorted(spans, key=lambda s: s["extraction_completeness"])
by_high_hallucination = sorted(spans, key=lambda s: s["hallucination_rate"], reverse=True)
selected_keys: set[tuple[str, str, int]] = set()
failure_spans: list[dict[str, Any]] = []
for source, label in [
(by_low_completeness, "low_completeness"),
(by_high_hallucination, "high_hallucination"),
]:
for span_record in source:
key = (span_record["video_id"], span_record["question_id"], span_record["step"])
if key in selected_keys:
for fs in failure_spans:
if (fs["video_id"], fs["question_id"], fs["step"]) == key:
if label not in fs["selection_reason"]:
fs["selection_reason"] += f", {label}"
break
continue
if len(selected_keys) >= 4 and label == "high_hallucination":
break
selected_keys.add(key)
failure_spans.append(
{
"video_id": span_record["video_id"],
"question_id": span_record["question_id"],
"step": span_record["step"],
"tool_name": tool_name,
"tool_args": span_record["tool_args"],
"tool_output": span_record["tool_output"],
"ground_truth": span_record["ground_truth"],
"extraction_completeness": span_record["extraction_completeness"],
"hallucination_rate": span_record["hallucination_rate"],
"missed_info_tags": span_record["missed_info_tags"],
"hallucination_tags": span_record["hallucination_tags"],
"selection_reason": label,
}
)
if len(failure_spans) >= 4:
break
# 成功 span
good_spans = [
s
for s in spans
if s["extraction_completeness"] >= 0.9 and s["hallucination_rate"] == 0.0
]
good_spans.sort(key=lambda s: s["extraction_completeness"], reverse=True)
n_success = max(2, len(failure_spans) // 2)
success_spans: list[dict[str, Any]] = []
for span_record in good_spans[:n_success]:
success_spans.append(
{
"video_id": span_record["video_id"],
"question_id": span_record["question_id"],
"step": span_record["step"],
"tool_name": tool_name,
"tool_args": span_record["tool_args"],
"tool_output": span_record["tool_output"],
"ground_truth": span_record["ground_truth"],
"extraction_completeness": span_record["extraction_completeness"],
"hallucination_rate": span_record["hallucination_rate"],
"missed_info_tags": span_record["missed_info_tags"],
"hallucination_tags": span_record["hallucination_tags"],
"selection_reason": "good_quality",
}
)
packs[tool_name] = ToolCasePack(
tool_name=tool_name,
target_files=target_files,
stats=d2_stats.get(tool_name, {}),
failure_spans=failure_spans,
success_spans=success_spans,
)
return packs
# =========================================================================
# J. Merge 函数
# =========================================================================
def _collect_step_stats(packs_stats: list[dict[str, Any]]) -> dict[str, Any]:
"""将各 step 的 stats 按 step 收集为列表,不做跨 step 数值聚合。
参数:
packs_stats: 各 step pack 的 stats 字典列表。
返回:
{"per_step": [...]},列表元素为各非空 step 的 stats 浅拷贝。
"""
return {"per_step": [dict(stats) for stats in packs_stats if stats]}
def merge_system_packs(packs: list[SystemCasePack]) -> SystemCasePack | None:
"""将多个 step 的 SystemCasePack 累加为单个。
参数:
packs: 一个 epoch 内各 step 产出的 SystemCasePack 列表。
返回:
累加后的 SystemCasePack;输入为空列表时返回 None。
"""
if not packs:
return None
failure_cases: list[CaseSample] = []
success_cases: list[CaseSample] = []
for pack in packs:
failure_cases.extend(pack.failure_cases)
success_cases.extend(pack.success_cases)
return SystemCasePack(
stats=_collect_step_stats([pack.stats for pack in packs]),
failure_cases=failure_cases,
success_cases=success_cases,
)
def merge_tool_packs(packs: list[ToolCasePack]) -> dict[str, ToolCasePack]:
"""将多个 step 的 ToolCasePack 按 tool_name 分组累加。
参数:
packs: 一个 epoch 内各 step 产出的 ToolCasePack 列表。
返回:
{tool_name: 合并后的 ToolCasePack};输入为空列表时返回空字典。
"""
by_name: dict[str, list[ToolCasePack]] = defaultdict(list)
for pack in packs:
by_name[pack.tool_name].append(pack)
merged: dict[str, ToolCasePack] = {}
for tool_name, group in by_name.items():
failure_spans: list[dict[str, Any]] = []
success_spans: list[dict[str, Any]] = []
for pack in group:
failure_spans.extend(pack.failure_spans)
success_spans.extend(pack.success_spans)
merged[tool_name] = ToolCasePack(
tool_name=tool_name,
target_files=list(group[0].target_files),
stats=_collect_step_stats([pack.stats for pack in group]),
failure_spans=failure_spans,
success_spans=success_spans,
)
return merged
# =========================================================================
# K. 推理失败子分类
# =========================================================================
async def _classify_reasoning_failure(
llm: LLMProvider,
prompts: DiagnosePrompts,
prediction: dict[str, Any],
traces: list[dict[str, Any]],
) -> str | None:
"""调用 judge 模型细分推理失败类型。
参数:
llm: LLM 调用端口。
prompts: 诊断模板束。
prediction: 单题预测记录。
traces: 该题执行轨迹。
返回:
推理失败子类型字符串;解析失败返回 None(不崩溃)。
"""
trace_text = _format_trace_text_diagnose(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"## 执行轨迹\n{trace_text}"
)
try:
response = await llm.chat(
[
{"role": "system", "content": prompts.reasoning_sub},
{"role": "user", "content": user_prompt},
],
)
parsed = extract_json_from_response(response.content)
failure_type = parsed.get("type")
if not isinstance(failure_type, str) or not failure_type.strip():
return None
return failure_type
except (ValueError, KeyError):
return None
# =========================================================================
# L. 诊断版 trace 格式化(不截断)
# =========================================================================
def _format_trace_text_diagnose(traces: list[dict]) -> str:
"""将 trace 列表格式化为完整文本(诊断版,不截断 thought/output)。
与指标版 _format_trace_text 不同:此版本保留全文。
参数:
traces: trace 字典列表。
返回:
格式化后的多行文本。
"""
lines: list[str] = []
for trace in traces:
args = trace.get("tool_args", {})
if not isinstance(args, str):
args = json.dumps(args, ensure_ascii=False, sort_keys=True)
lines.append(
f"Step {trace.get('step', '')}: thought={trace.get('thought', '')} | "
f"tool={trace.get('tool_name', '')} | args={args} | "
f"output={trace.get('tool_output', '')}"
)
return "\n".join(lines)
# =========================================================================
# M. Skill 文件解析辅助
# =========================================================================
def _resolve_skill_file(skill_store: SkillStore, task_type: str) -> str:
"""按题型解析对应 skill 文件名并读取内容。
优先精确匹配 ``{task_type}.md``(小写 + 空格转连字符),
找不到则回退 ``default-strategy.md``。
注意: 此为临时本地实现。Task 7 将在 evolve.py 中创建规范版本,
Task 9 会统一收口。
参数:
skill_store: 技能文件读取端口。
task_type: 题目任务类型。
返回:
skill 文件全文。
"""
task_filename = f"{task_type.lower().replace(' ', '-')}.md"
available = skill_store.list_skill_files()
if task_filename in available:
return skill_store.read_skill(task_filename)
if "default-strategy.md" in available:
return skill_store.read_skill("default-strategy.md")
return ""
# =========================================================================
# N. INFRA 统计
# =========================================================================
def _count_infra_excluded(
prediction_rows: list[dict[str, Any]],
) -> tuple[int, list[str]]:
"""统计因执行/解析层失败(INFRA)被排除的题。
参数:
prediction_rows: 该 run 的预测行。
返回:
(INFRA 题数, question_id 列表)。
"""
qids = [
row["question_id"]
for row in prediction_rows
if row.get("stop_reason") in _INFRA_STOP_REASONS
]
return len(qids), qids
# =========================================================================
# O. run_diagnosis 入口
# =========================================================================
async def run_diagnosis(
run_id: str,
questions: list[GeneratedQuestion],
tree_data: dict[str, Any],
llm: LLMProvider,
run_log: RunLog,
skill_store: SkillStore,
prompts: DiagnosePrompts,
*,
concurrency: int,
question_ids: list[str] | None = None,
task_types: list[str] | None = None,
only_incorrect: bool = False,
) -> DiagnosisResult:
"""执行两阶段诊断流水线。
流程:
1. 从 RunLog 获取 predictions 和 traces
2. 按 question_ids / task_types / only_incorrect 过滤,排除 INFRA stop_reasons
3. Stage 1: 并发计算单题指标 + 错误归因 + defect/lapse 判别
4. 推理失败子分类(串行)
5. Stage 2: D2-D5 聚合,构建案例包
6. 计算 INFRA 统计
7. 返回 DiagnosisResult
参数:
run_id: 本次运行标识。
questions: 题目列表。
tree_data: 树结构字典(多视频时为 {video_id: tree_data}
单视频时为单棵树)。
llm: LLM 调用端口。
run_log: 实验日志查询端口。
skill_store: 技能文件读取端口。
prompts: 诊断模板束。
concurrency: 并发限制。
question_ids: 可选的题目 ID 过滤列表。
task_types: 可选的题型过滤列表。
only_incorrect: 是否仅处理错题。
返回:
DiagnosisResult 实例。
"""
# Phase 0: 获取 predictions 和 traces
all_predictions = await run_log.get_predictions(run_id, question_ids=question_ids)
all_trace_rows = await run_log.get_traces(run_id, question_ids=question_ids)
# 构建 question lookup
question_lookup: dict[str, GeneratedQuestion] = {q.question_id: q for q in questions}
# 构建 tree_data_by_video
# tree_data 可能是 {video_id: {...}} 或单棵树
tree_data_by_video: dict[str, dict[str, Any]] = {}
if tree_data and "nodes" in tree_data:
# 单棵树:所有视频共用
for q in questions:
tree_data_by_video[q.video_id] = tree_data
else:
tree_data_by_video = tree_data # type: ignore[assignment]
# 过滤 predictions
task_type_filter = set(task_types or [])
question_filter = set(question_ids or [])
filtered_predictions: list[dict[str, Any]] = []
for row in all_predictions:
stop_reason = row.get("stop_reason")
if stop_reason in _INFRA_STOP_REASONS:
continue
if task_type_filter and row.get("task_type") not in task_type_filter:
continue
if question_filter and row.get("question_id") not in question_filter:
continue
is_correct = row.get("prediction") == row.get("answer")
if only_incorrect and is_correct:
continue
# 补全 question 信息
q = question_lookup.get(row.get("question_id", ""))
if q is not None:
row.setdefault("question", q.question)
row.setdefault("options", list(q.options))
row.setdefault("task_type", q.task_type)
row.setdefault("answer", q.answer)
row.setdefault("question", "")
row.setdefault("options", [])
row["correct"] = row.get("prediction") == row.get("answer")
# 解析 steps_json
raw_steps = row.get("steps_json")
if isinstance(raw_steps, str):
try:
row["steps_json"] = json.loads(raw_steps)
except json.JSONDecodeError:
row["steps_json"] = []
elif not isinstance(raw_steps, list):
row["steps_json"] = []
filtered_predictions.append(row)
# 构建 traces_by_question
traces_by_question: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
for trace_row in all_trace_rows:
key = (trace_row.get("video_id", ""), trace_row.get("question_id", ""))
traces_by_question[key].append(trace_row)
# 推断 max_steps
observed_steps = [
len(p.get("steps_json", []))
for p in filtered_predictions
if isinstance(p.get("steps_json"), list)
]
max_steps = max(max(observed_steps, default=0), 1)
# 加载 skill 内容
skill_cache: dict[str, str] = {}
for p in filtered_predictions:
tt = p.get("task_type", "")
if tt and tt not in skill_cache:
skill_cache[tt] = _resolve_skill_file(skill_store, tt)
# Stage 1: 并发单题指标 + 归因 + C3
semaphore = asyncio.Semaphore(concurrency)
worker_results: list[dict[str, Any]] = []
degraded_question_ids: list[str] = []
async def _process_question(prediction: dict[str, Any]) -> dict[str, Any]:
"""处理单题:计算指标、归因、C3 判别。"""
async with semaphore:
key = (prediction.get("video_id", ""), prediction.get("question_id", ""))
traces = traces_by_question.get(key, [])
vid = prediction.get("video_id", "")
td = tree_data_by_video.get(vid, {})
skill_content = skill_cache.get(prediction.get("task_type", ""), "")
try:
qm = await compute_question_metrics(
prediction=prediction,
traces=traces,
tree_data=td,
skill_content=skill_content,
llm=llm,
prompts=prompts,
max_steps=max_steps,
session_id=run_id,
)
except ValueError:
logger.warning(
"诊断降级: {} / {} — judge JSON 解析失败",
prediction.get("video_id"),
prediction.get("question_id"),
)
qm = _make_degraded_metrics(prediction, max_steps)
attribution: ErrorAttribution | None = None
if not qm.correct:
attribution = attribute_error(qm)
try:
category, note = await classify_defect_vs_lapse(
llm,
prompts,
prediction,
traces,
skill_content,
session_id=run_id,
)
attribution = ErrorAttribution(
question_id=attribution.question_id,
error_type=attribution.error_type,
reasoning_failure_type=attribution.reasoning_failure_type,
cause_category=category,
lapse_note=note if category == "lapse" else None,
)
except Exception:
logger.warning(
"C3 判别失败: {} / {}",
prediction.get("video_id"),
prediction.get("question_id"),
)
return {
"prediction": prediction,
"traces": traces,
"metrics": qm,
"attribution": attribution,
}
tasks = [_process_question(p) for p in filtered_predictions]
worker_results = list(await asyncio.gather(*tasks)) if tasks else []
# 收集降级题
for item in worker_results:
if item["metrics"].degraded:
degraded_question_ids.append(item["metrics"].question_id)
# 推理失败子分类(串行)
for item in worker_results:
attribution = item["attribution"]
if attribution is None or attribution.error_type != "reasoning_failure":
continue
reasoning_type = await _classify_reasoning_failure(
llm, prompts, item["prediction"], item["traces"]
)
if reasoning_type is not None:
item["attribution"] = ErrorAttribution(
question_id=attribution.question_id,
error_type=attribution.error_type,
reasoning_failure_type=reasoning_type,
cause_category=attribution.cause_category,
lapse_note=attribution.lapse_note,
)
# Stage 2: 聚合
all_metrics = [item["metrics"] for item in worker_results]
error_attributions = [
item["attribution"] for item in worker_results if item["attribution"] is not None
]
attribution_distribution = dict(Counter(attr.error_type for attr in error_attributions))
defect_count = sum(1 for a in error_attributions if a.cause_category == "defect")
lapse_count = sum(1 for a in error_attributions if a.cause_category == "lapse")
reasoning_failure_types = dict(
Counter(
attr.reasoning_failure_type
for attr in error_attributions
if attr.reasoning_failure_type
)
)
d2_stats = aggregate_d2(all_metrics)
d3_stats = aggregate_d3(all_metrics)
d4_stats = aggregate_d4(all_metrics)
d5_stats = aggregate_d5(all_metrics)
# 构建案例包
prediction_list = [item["prediction"] for item in worker_results]
skill_packs = _build_skill_case_packs(
all_metrics=all_metrics,
error_attributions=error_attributions,
traces_by_question=traces_by_question,
predictions=prediction_list,
d3_stats=d3_stats,
d4_stats=d4_stats,
)
system_pack = _build_system_case_pack(
all_metrics=all_metrics,
traces_by_question=traces_by_question,
predictions=prediction_list,
d5_stats=d5_stats,
)
tool_packs = _build_tool_case_packs(
all_metrics=all_metrics,
traces_by_question=traces_by_question,
d2_stats=d2_stats,
tree_data_by_video=tree_data_by_video,
)
# INFRA 统计:限定在 filtered scope(过滤 task_type/question_ids),但不过滤 stop_reason
scoped_rows = [
row
for row in all_predictions
if not (task_type_filter and row.get("task_type") not in task_type_filter)
and not (question_filter and row.get("question_id") not in question_filter)
]
infra_count, infra_qids = _count_infra_excluded(scoped_rows)
total = len(scoped_rows)
return DiagnosisResult(
run_id=run_id,
filter_summary={
"task_types": sorted(task_type_filter),
"question_ids": sorted(question_filter),
"only_incorrect": only_incorrect,
"total_predictions": len(all_predictions),
"selected_predictions": len(filtered_predictions),
},
error_attributions=error_attributions,
attribution_distribution=attribution_distribution,
defect_count=defect_count,
lapse_count=lapse_count,
reasoning_failure_types=reasoning_failure_types,
tool_quality=d2_stats,
search_effectiveness=d3_stats,
skill_compliance=d4_stats,
decision_patterns=d5_stats,
skill_case_packs=skill_packs,
system_case_pack=system_pack,
tool_case_packs=tool_packs,
infra_excluded_count=infra_count,
infra_excluded_ratio=(infra_count / total if total else 0.0),
infra_question_ids=infra_qids,
degraded_count=len(degraded_question_ids),
degraded_question_ids=degraded_question_ids,
)