2306 lines
78 KiB
Python
2306 lines
78 KiB
Python
"""诊断引擎 — 指标计算、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 返回 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,
|
||
)
|
||
|
||
|
||
# =========================================================================
|
||
# 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,
|
||
)
|