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

1021 lines
32 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 辅助函数。
Stage 1 指标管线的可提取内核。包含:
- 7 个规则指标的纯函数计算
- JSON 提取工具
- 5 个 LLM judge 评估函数(async
- 单题指标编排 compute_question_metrics
- 错误归因瀑布 attribute_error
- defect/lapse 病因判别 classify_defect_vs_lapse
- 降级指标生成 _make_degraded_metrics
不依赖 app/ 或 adapters/。所有 LLM 交互通过 LLMProvider Protocol 注入。
"""
from __future__ import annotations
import json
import re
from collections import Counter
from typing import TYPE_CHECKING, Any
from json_repair import repair_json
from loguru import logger
from core.evolution.types import (
DiagnosePrompts,
ErrorAttribution,
QuestionMetrics,
SkillStepAdherence,
SpanMetrics,
)
if TYPE_CHECKING:
from core.protocols import LLMProvider
# =========================================================================
# 常量
# =========================================================================
_SPAN_EVAL_TOOLS: frozenset[str] = frozenset({"view_node", "search_similar", "observe_frame"})
"""span 级评估涵盖的工具集合。"""
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
"""执行/解析层失败导致排除的 stop_reason 集合。"""
# =========================================================================
# A. 规则指标 — 7 个纯函数 + 辅助工具
# =========================================================================
def _parse_json_object(raw: str) -> dict | None:
"""将原始字符串解析为字典;失败时返回 None。
参数:
raw: 待解析的原始字符串。
返回:
解析成功返回 dict,否则返回 None。
"""
try:
parsed = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
try:
parsed = json.loads(repair_json(raw))
except (TypeError, ValueError, json.JSONDecodeError):
return None
if isinstance(parsed, dict):
return parsed
return None
def _trigrams(text: str) -> set[str]:
"""返回字符串的字符级 trigram 集合。
参数:
text: 输入文本。
返回:
长度为 3 的子串集合;文本不足 3 字符时返回空集。
"""
if len(text) < 3:
return set()
return {text[index : index + 3] for index in range(len(text) - 2)}
def _extract_last_confidence(raw_contents: list[str]) -> float:
"""从末步 raw_content 提取 reflect.confidence。失败时返回 0.5。
参数:
raw_contents: 各步原始输出内容列表。
返回:
置信度浮点值,提取失败时返回 0.5。
"""
try:
parsed = _parse_json_object(raw_contents[-1])
if parsed is None:
raise ValueError("末步内容不是字典。")
return float(parsed["reflect"]["confidence"])
except Exception:
return 0.5
def calc_format_compliance(raw_contents: list[str]) -> float:
"""每步 JSON 是否包含 reflect/plan/action 三个字段。合规步数/总步数。
参数:
raw_contents: 各步原始输出内容列表。
返回:
合规比例 [0.0, 1.0];空列表返回 1.0。
"""
if not raw_contents:
return 1.0
compliant_count = 0
for raw in raw_contents:
parsed = _parse_json_object(raw)
if parsed is not None and all(key in parsed for key in ("reflect", "plan", "action")):
compliant_count += 1
return compliant_count / len(raw_contents)
def calc_budget_usage(steps_used: int, max_steps: int) -> float:
"""预算使用比例。
参数:
steps_used: 已使用步数。
max_steps: 最大步数预算。
返回:
steps_used / max_steps。
异常:
ZeroDivisionError: max_steps 为 0 时抛出(P5: 不掩盖错误)。
"""
return steps_used / max_steps
def calc_confidence_calibration(confidence: float, correct: bool) -> str:
"""置信度校准分类。
参数:
confidence: 模型置信度 [0.0, 1.0]。
correct: 是否答对。
返回:
'high_conf_wrong' | 'low_conf_right' | 'calibrated'。
"""
if confidence >= 0.7 and not correct:
return "high_conf_wrong"
if confidence < 0.5 and correct:
return "low_conf_right"
return "calibrated"
def calc_repeat_visit_rate(view_node_ids: list[str]) -> float:
"""重复访问率。
参数:
view_node_ids: 访问的节点 ID 列表。
返回:
1 - (unique / total);空列表返回 0.0。
"""
if not view_node_ids:
return 0.0
return 1 - (len(set(view_node_ids)) / len(view_node_ids))
def calc_search_keyword_repetition(queries: list[str]) -> float:
"""连续 search_similar 查询的最大字符级 trigram Jaccard 相似度。
参数:
queries: 搜索查询列表。
返回:
连续查询对的最大 Jaccard 值;不足 2 个查询时返回 0.0。
"""
if len(queries) < 2:
return 0.0
max_score = 0.0
for left, right in zip(queries, queries[1:], strict=False):
left_trigrams = _trigrams(left)
right_trigrams = _trigrams(right)
union = left_trigrams | right_trigrams
score = 0.0 if not union else len(left_trigrams & right_trigrams) / len(union)
if score > max_score:
max_score = score
return max_score
def calc_level_jump_pattern(view_node_ids: list[str]) -> str:
"""从 node_id 提取层级,拼成 'L1→L2→L3' 格式。
参数:
view_node_ids: 节点 ID 列表。
返回:
层级跳转模式字符串;无匹配时返回空字符串。
"""
levels: list[str] = []
for node_id in view_node_ids:
match = re.search(r"_L(\d+)_", node_id)
if match is not None:
levels.append(f"L{match.group(1)}")
return "→".join(levels)
def calc_tool_usage(tool_names: list[str]) -> dict[str, int]:
"""按 tool_name 计数。
参数:
tool_names: 工具名称列表。
返回:
{工具名: 调用次数} 映射。
"""
return dict(Counter(tool_names))
def extract_rule_metrics(prediction: dict, raw_contents: list[str], max_steps: int) -> dict:
"""从 prediction 和 raw_contents 提取全部 7 个规则指标。
参数:
prediction: 单题预测记录,含 steps_json / correct / answer_confidence。
raw_contents: 各步原始输出内容列表。
max_steps: 最大步数预算。
返回:
包含 7 个规则指标的字典。
"""
view_node_ids: list[str] = []
search_queries: list[str] = []
tool_names: list[str] = []
for step in prediction.get("steps_json", []):
tool_call = step.get("tool_call", {})
if not isinstance(tool_call, dict):
continue
tool_name = tool_call.get("tool")
args = tool_call.get("args", {})
if not isinstance(args, dict):
args = {}
if isinstance(tool_name, str):
tool_names.append(tool_name)
if tool_name == "view_node":
node_id = args.get("node_id")
if isinstance(node_id, str):
view_node_ids.append(node_id)
if tool_name == "search_similar":
query = args.get("query")
if isinstance(query, str):
search_queries.append(query)
# 置信度优先级:末步 JSON reflect.confidence > prediction["answer_confidence"]
confidence = prediction.get("answer_confidence", 0.5)
if raw_contents:
last_step = _parse_json_object(raw_contents[-1])
if isinstance(last_step, dict):
confidence = _extract_last_confidence(raw_contents)
correct = bool(prediction.get("correct", False))
steps_used = len(prediction.get("steps_json", []))
return {
"format_compliance": calc_format_compliance(raw_contents),
"budget_usage": calc_budget_usage(steps_used, max_steps),
"confidence_calibration": calc_confidence_calibration(confidence, correct),
"repeat_visit_rate": calc_repeat_visit_rate(view_node_ids),
"search_keyword_repetition": calc_search_keyword_repetition(search_queries),
"level_jump_pattern": calc_level_jump_pattern(view_node_ids),
"tool_usage": calc_tool_usage(tool_names),
}
# =========================================================================
# B. JSON 提取
# =========================================================================
def extract_json_from_response(raw: str) -> dict:
"""从 LLM 回复中提取 JSON。
三策略依序尝试:
1. markdown 代码块 ```json ... ``` 或 ``` ... ```
2. 最外层花括号 { ... }
3. json_repair 修复后解析
参数:
raw: LLM 原始回复字符串。
返回:
解析后的字典。
异常:
ValueError: 三种策略均无法提取合法 JSON 字典时抛出。
"""
# 策略 1: fenced code block
block_match = re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.DOTALL)
if block_match is not None:
try:
parsed = json.loads(block_match.group(1))
except (TypeError, ValueError, json.JSONDecodeError):
pass
else:
if isinstance(parsed, dict):
return parsed
# 策略 2: outermost braces
start = raw.find("{")
end = raw.rfind("}")
if start != -1 and end != -1 and start <= end:
try:
parsed = json.loads(raw[start : end + 1])
except (TypeError, ValueError, json.JSONDecodeError):
pass
else:
if isinstance(parsed, dict):
return parsed
# 策略 3: json_repair
try:
parsed = json.loads(repair_json(raw))
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError("无法从 LLM 回复中提取 JSON。") from exc
if isinstance(parsed, dict):
return parsed
raise ValueError("无法从 LLM 回复中提取 JSON。")
# =========================================================================
# C. Judge 辅助函数
# =========================================================================
async def _call_judge(
llm: LLMProvider,
system_prompt: str,
user_prompt: str,
*,
max_retries: int = 2,
session_id: str | None = None,
) -> dict:
"""调用 judge 模型,解析 JSON 返回。解析失败时重试。
参数:
llm: LLM 调用端口。
system_prompt: 系统提示词。
user_prompt: 用户提示词。
max_retries: 解析失败后的额外重试次数(默认 2,即总共最多调用 3 次)。
session_id: 会话标识(可选,传入 LLMProvider 用于遥测)。
返回:
解析后的 JSON 字典。
异常:
ValueError: 所有尝试均无法从回复中提取合法 JSON 时抛出。
其他 API 异常直接传播,不在此处捕获。
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
last_exc: ValueError | None = None
for attempt in range(1 + max_retries):
response = await llm.chat(messages, session_id=session_id)
raw = response.content
try:
return extract_json_from_response(raw)
except ValueError as exc:
last_exc = exc
logger.warning("judge JSON 解析失败 (attempt {}/{})", attempt + 1, 1 + max_retries)
raise last_exc # type: ignore[misc]
def question_soft_score(span_metrics: list[SpanMetrics]) -> float | None:
"""按题 soft 分 = 各 span 的 mean(completeness, 1-hallucination) 再对 spans 取均值。
参数:
span_metrics: 该题的 SpanMetrics 列表。
返回:
题级 soft 连续分 [0,1];无 span_metrics 返回 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,
)