"""诊断引擎 — 指标计算、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, )