775 lines
26 KiB
Python
775 lines
26 KiB
Python
"""core/evolution/diagnose.py 单元测试。
|
||
|
||
覆盖:
|
||
- 7 个规则指标(空输入、边界、典型值)
|
||
- extract_json_from_response(三策略 + 拒绝非 dict + 垃圾输入)
|
||
- attribute_error 瀑布(4 条路径)
|
||
- question_soft_score(空→None)
|
||
- aggregate_soft(跳过 None、全 None→None)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
|
||
from core.evolution.diagnose import (
|
||
_percentile,
|
||
_trigrams,
|
||
aggregate_d2,
|
||
aggregate_d3,
|
||
aggregate_d4,
|
||
aggregate_d5,
|
||
aggregate_soft,
|
||
attribute_error,
|
||
calc_budget_usage,
|
||
calc_confidence_calibration,
|
||
calc_format_compliance,
|
||
calc_level_jump_pattern,
|
||
calc_repeat_visit_rate,
|
||
calc_search_keyword_repetition,
|
||
calc_tool_usage,
|
||
extract_json_from_response,
|
||
extract_rule_metrics,
|
||
merge_system_packs,
|
||
merge_tool_packs,
|
||
question_soft_score,
|
||
run_diagnosis,
|
||
)
|
||
from core.evolution.types import (
|
||
CaseSample,
|
||
DiagnosePrompts,
|
||
DiagnosisResult,
|
||
QuestionMetrics,
|
||
SkillStepAdherence,
|
||
SpanMetrics,
|
||
SystemCasePack,
|
||
ToolCasePack,
|
||
)
|
||
|
||
# =========================================================================
|
||
# 工厂函数
|
||
# =========================================================================
|
||
|
||
|
||
def _make_qm(
|
||
question_id: str = "q1",
|
||
video_id: str = "v1",
|
||
task_type: str = "Action Reasoning",
|
||
correct: bool = False,
|
||
format_compliance: float = 1.0,
|
||
budget_usage: float = 0.5,
|
||
confidence_calibration: str = "calibrated",
|
||
repeat_visit_rate: float = 0.0,
|
||
search_keyword_repetition: float = 0.0,
|
||
level_jump_pattern: str = "",
|
||
tool_usage: dict[str, int] | None = None,
|
||
span_metrics: list[SpanMetrics] | None = None,
|
||
missed_nodes: list[str] | None = None,
|
||
skill_adherence: list[Any] | None = None,
|
||
confirmation_bias: bool | None = None,
|
||
evidence_sufficient: bool | None = None,
|
||
degraded: bool = False,
|
||
) -> QuestionMetrics:
|
||
"""构造 QuestionMetrics,非关键字段使用合理默认值。"""
|
||
return QuestionMetrics(
|
||
question_id=question_id,
|
||
video_id=video_id,
|
||
task_type=task_type,
|
||
correct=correct,
|
||
format_compliance=format_compliance,
|
||
budget_usage=budget_usage,
|
||
confidence_calibration=confidence_calibration,
|
||
repeat_visit_rate=repeat_visit_rate,
|
||
search_keyword_repetition=search_keyword_repetition,
|
||
level_jump_pattern=level_jump_pattern,
|
||
tool_usage=tool_usage or {},
|
||
span_metrics=span_metrics or [],
|
||
missed_nodes=missed_nodes or [],
|
||
skill_adherence=skill_adherence or [],
|
||
confirmation_bias=confirmation_bias,
|
||
evidence_sufficient=evidence_sufficient,
|
||
degraded=degraded,
|
||
)
|
||
|
||
|
||
def _make_span(
|
||
step: int = 0,
|
||
tool_name: str = "view_node",
|
||
extraction_completeness: float = 0.8,
|
||
hallucination_rate: float = 0.1,
|
||
) -> SpanMetrics:
|
||
"""构造 SpanMetrics 快捷工厂。"""
|
||
return SpanMetrics(
|
||
step=step,
|
||
tool_name=tool_name,
|
||
extraction_completeness=extraction_completeness,
|
||
hallucination_rate=hallucination_rate,
|
||
)
|
||
|
||
|
||
# =========================================================================
|
||
# A. 规则指标测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestCalcFormatCompliance:
|
||
"""calc_format_compliance 测试。"""
|
||
|
||
def test_empty_returns_one(self) -> None:
|
||
"""空列表返回 1.0。"""
|
||
assert calc_format_compliance([]) == 1.0
|
||
|
||
def test_all_compliant(self) -> None:
|
||
"""全部合规返回 1.0。"""
|
||
raw = json.dumps({"reflect": {}, "plan": {}, "action": {}})
|
||
assert calc_format_compliance([raw, raw]) == 1.0
|
||
|
||
def test_none_compliant(self) -> None:
|
||
"""全部不合规返回 0.0。"""
|
||
assert calc_format_compliance(["not json", '{"foo": 1}']) == 0.0
|
||
|
||
def test_partial_compliance(self) -> None:
|
||
"""部分合规返回正确比例。"""
|
||
good = json.dumps({"reflect": {}, "plan": {}, "action": {}})
|
||
bad = json.dumps({"reflect": {}, "plan": {}})
|
||
assert calc_format_compliance([good, bad]) == 0.5
|
||
|
||
|
||
class TestCalcBudgetUsage:
|
||
"""calc_budget_usage 测试。"""
|
||
|
||
def test_typical(self) -> None:
|
||
"""典型值。"""
|
||
assert calc_budget_usage(5, 10) == 0.5
|
||
|
||
def test_full_budget(self) -> None:
|
||
"""用满预算。"""
|
||
assert calc_budget_usage(10, 10) == 1.0
|
||
|
||
def test_zero_steps(self) -> None:
|
||
"""未使用步数。"""
|
||
assert calc_budget_usage(0, 10) == 0.0
|
||
|
||
def test_zero_max_steps_raises(self) -> None:
|
||
"""max_steps=0 应抛出 ZeroDivisionError(P5: 不掩盖错误)。"""
|
||
with pytest.raises(ZeroDivisionError):
|
||
calc_budget_usage(5, 0)
|
||
|
||
|
||
class TestCalcConfidenceCalibration:
|
||
"""calc_confidence_calibration 测试。"""
|
||
|
||
def test_high_conf_wrong(self) -> None:
|
||
"""高置信度答错。"""
|
||
assert calc_confidence_calibration(0.7, correct=False) == "high_conf_wrong"
|
||
assert calc_confidence_calibration(0.9, correct=False) == "high_conf_wrong"
|
||
|
||
def test_low_conf_right(self) -> None:
|
||
"""低置信度答对。"""
|
||
assert calc_confidence_calibration(0.3, correct=True) == "low_conf_right"
|
||
assert calc_confidence_calibration(0.49, correct=True) == "low_conf_right"
|
||
|
||
def test_calibrated(self) -> None:
|
||
"""正常校准。"""
|
||
assert calc_confidence_calibration(0.5, correct=True) == "calibrated"
|
||
assert calc_confidence_calibration(0.7, correct=True) == "calibrated"
|
||
assert calc_confidence_calibration(0.3, correct=False) == "calibrated"
|
||
|
||
def test_boundary_high(self) -> None:
|
||
"""边界值: 0.7 答错。"""
|
||
assert calc_confidence_calibration(0.7, correct=False) == "high_conf_wrong"
|
||
|
||
def test_boundary_low(self) -> None:
|
||
"""边界值: 0.5 答对不算 low_conf_right。"""
|
||
assert calc_confidence_calibration(0.5, correct=True) == "calibrated"
|
||
|
||
|
||
class TestCalcRepeatVisitRate:
|
||
"""calc_repeat_visit_rate 测试。"""
|
||
|
||
def test_empty(self) -> None:
|
||
"""空列表返回 0.0。"""
|
||
assert calc_repeat_visit_rate([]) == 0.0
|
||
|
||
def test_no_repeats(self) -> None:
|
||
"""无重复。"""
|
||
assert calc_repeat_visit_rate(["a", "b", "c"]) == 0.0
|
||
|
||
def test_all_same(self) -> None:
|
||
"""全重复。"""
|
||
rate = calc_repeat_visit_rate(["a", "a", "a"])
|
||
assert abs(rate - (1 - 1 / 3)) < 1e-9
|
||
|
||
def test_partial_repeats(self) -> None:
|
||
"""部分重复。"""
|
||
rate = calc_repeat_visit_rate(["a", "b", "a"])
|
||
assert abs(rate - (1 - 2 / 3)) < 1e-9
|
||
|
||
|
||
class TestTrigrams:
|
||
"""_trigrams 辅助函数测试。"""
|
||
|
||
def test_short_string(self) -> None:
|
||
"""不足 3 字符返回空集。"""
|
||
assert _trigrams("ab") == set()
|
||
assert _trigrams("") == set()
|
||
|
||
def test_exact_three(self) -> None:
|
||
"""恰好 3 字符。"""
|
||
assert _trigrams("abc") == {"abc"}
|
||
|
||
def test_longer(self) -> None:
|
||
"""多字符。"""
|
||
assert _trigrams("abcd") == {"abc", "bcd"}
|
||
|
||
|
||
class TestCalcSearchKeywordRepetition:
|
||
"""calc_search_keyword_repetition 测试。"""
|
||
|
||
def test_single_query(self) -> None:
|
||
"""单条查询返回 0.0。"""
|
||
assert calc_search_keyword_repetition(["hello"]) == 0.0
|
||
|
||
def test_empty(self) -> None:
|
||
"""空列表返回 0.0。"""
|
||
assert calc_search_keyword_repetition([]) == 0.0
|
||
|
||
def test_identical_queries(self) -> None:
|
||
"""完全相同的连续查询,Jaccard=1.0。"""
|
||
assert calc_search_keyword_repetition(["hello world", "hello world"]) == 1.0
|
||
|
||
def test_disjoint_queries(self) -> None:
|
||
"""完全不同的查询,Jaccard 接近 0。"""
|
||
score = calc_search_keyword_repetition(["aaa", "zzz"])
|
||
assert score == 0.0
|
||
|
||
def test_takes_max_across_pairs(self) -> None:
|
||
"""取连续对的最大值。"""
|
||
score = calc_search_keyword_repetition(["aaa", "zzz", "zzz"])
|
||
assert score == 1.0 # 第二对完全相同
|
||
|
||
|
||
class TestCalcLevelJumpPattern:
|
||
"""calc_level_jump_pattern 测试。"""
|
||
|
||
def test_empty(self) -> None:
|
||
"""空列表返回空字符串。"""
|
||
assert calc_level_jump_pattern([]) == ""
|
||
|
||
def test_typical(self) -> None:
|
||
"""典型节点 ID 序列。"""
|
||
result = calc_level_jump_pattern(["vid_L1_001", "vid_L2_003", "vid_L3_005"])
|
||
assert result == "L1→L2→L3"
|
||
|
||
def test_no_match(self) -> None:
|
||
"""无匹配的节点 ID 被跳过。"""
|
||
assert calc_level_jump_pattern(["no_level_here"]) == ""
|
||
|
||
def test_mixed(self) -> None:
|
||
"""混合匹配和非匹配。"""
|
||
result = calc_level_jump_pattern(["vid_L2_001", "bad_id", "vid_L1_003"])
|
||
assert result == "L2→L1"
|
||
|
||
|
||
class TestCalcToolUsage:
|
||
"""calc_tool_usage 测试。"""
|
||
|
||
def test_empty(self) -> None:
|
||
"""空列表返回空字典。"""
|
||
assert calc_tool_usage([]) == {}
|
||
|
||
def test_counts(self) -> None:
|
||
"""正确计数。"""
|
||
result = calc_tool_usage(["view_node", "search_similar", "view_node"])
|
||
assert result == {"view_node": 2, "search_similar": 1}
|
||
|
||
|
||
class TestExtractRuleMetrics:
|
||
"""extract_rule_metrics 测试。"""
|
||
|
||
def test_basic_extraction(self) -> None:
|
||
"""基本规则指标提取。"""
|
||
prediction = {
|
||
"steps_json": [
|
||
{
|
||
"tool_call": {
|
||
"tool": "view_node",
|
||
"args": {"node_id": "vid_L1_001"},
|
||
}
|
||
},
|
||
{
|
||
"tool_call": {
|
||
"tool": "search_similar",
|
||
"args": {"query": "test query"},
|
||
}
|
||
},
|
||
],
|
||
"correct": True,
|
||
}
|
||
result = extract_rule_metrics(prediction, [], max_steps=10)
|
||
assert result["budget_usage"] == 0.2
|
||
assert result["tool_usage"] == {"view_node": 1, "search_similar": 1}
|
||
assert "L1" in result["level_jump_pattern"]
|
||
|
||
def test_empty_prediction(self) -> None:
|
||
"""空预测。"""
|
||
result = extract_rule_metrics({}, [], max_steps=10)
|
||
assert result["format_compliance"] == 1.0
|
||
assert result["budget_usage"] == 0.0
|
||
|
||
|
||
# =========================================================================
|
||
# B. JSON 提取测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestExtractJsonFromResponse:
|
||
"""extract_json_from_response 测试。"""
|
||
|
||
def test_fenced_block(self) -> None:
|
||
"""从 markdown 代码块提取。"""
|
||
raw = '```json\n{"key": "value"}\n```'
|
||
assert extract_json_from_response(raw) == {"key": "value"}
|
||
|
||
def test_fenced_block_no_json_tag(self) -> None:
|
||
"""从无 json 标签的代码块提取。"""
|
||
raw = '```\n{"key": "value"}\n```'
|
||
assert extract_json_from_response(raw) == {"key": "value"}
|
||
|
||
def test_outermost_braces(self) -> None:
|
||
"""从最外层花括号提取。"""
|
||
raw = 'Some text before {"result": 42} and after'
|
||
assert extract_json_from_response(raw) == {"result": 42}
|
||
|
||
def test_non_dict_raises(self) -> None:
|
||
"""非 dict 类型抛出 ValueError。"""
|
||
raw = "[1, 2, 3]"
|
||
with pytest.raises(ValueError, match="无法从 LLM 回复中提取 JSON"):
|
||
extract_json_from_response(raw)
|
||
|
||
def test_garbage_raises(self) -> None:
|
||
"""完全无法解析的输入抛出 ValueError。"""
|
||
with pytest.raises(ValueError, match="无法从 LLM 回复中提取 JSON"):
|
||
extract_json_from_response("this is not json at all !!!")
|
||
|
||
def test_nested_braces(self) -> None:
|
||
"""嵌套花括号正确处理。"""
|
||
inner = {"nested": {"deep": True}}
|
||
raw = f"Result: {json.dumps(inner)}"
|
||
assert extract_json_from_response(raw) == inner
|
||
|
||
def test_fenced_block_non_dict_falls_through(self) -> None:
|
||
"""代码块中是列表时,回退到后续策略。"""
|
||
raw = '```json\n[1,2,3]\n``` {"fallback": true}'
|
||
result = extract_json_from_response(raw)
|
||
assert result == {"fallback": True}
|
||
|
||
|
||
# =========================================================================
|
||
# C. question_soft_score / aggregate_soft 测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestQuestionSoftScore:
|
||
"""question_soft_score 测试。"""
|
||
|
||
def test_empty_returns_none(self) -> None:
|
||
"""空 span 列表返回 None。"""
|
||
assert question_soft_score([]) is None
|
||
|
||
def test_single_span(self) -> None:
|
||
"""单个 span 的计算。"""
|
||
span = _make_span(extraction_completeness=0.8, hallucination_rate=0.2)
|
||
score = question_soft_score([span])
|
||
# (0.8 + (1.0 - 0.2)) / 2 = (0.8 + 0.8) / 2 = 0.8
|
||
assert score is not None
|
||
assert abs(score - 0.8) < 1e-9
|
||
|
||
def test_multiple_spans(self) -> None:
|
||
"""多个 span 取均值。"""
|
||
span1 = _make_span(extraction_completeness=1.0, hallucination_rate=0.0)
|
||
span2 = _make_span(extraction_completeness=0.6, hallucination_rate=0.4)
|
||
score = question_soft_score([span1, span2])
|
||
# span1: (1.0 + 1.0) / 2 = 1.0
|
||
# span2: (0.6 + 0.6) / 2 = 0.6
|
||
# mean: (1.0 + 0.6) / 2 = 0.8
|
||
assert score is not None
|
||
assert abs(score - 0.8) < 1e-9
|
||
|
||
def test_perfect_span(self) -> None:
|
||
"""完美 span。"""
|
||
span = _make_span(extraction_completeness=1.0, hallucination_rate=0.0)
|
||
score = question_soft_score([span])
|
||
assert score == 1.0
|
||
|
||
|
||
class TestAggregateSoft:
|
||
"""aggregate_soft 测试。"""
|
||
|
||
def test_all_none_returns_none(self) -> None:
|
||
"""全部 None 返回 None。"""
|
||
assert aggregate_soft([None, None, None]) is None
|
||
|
||
def test_empty_returns_none(self) -> None:
|
||
"""空列表返回 None。"""
|
||
assert aggregate_soft([]) is None
|
||
|
||
def test_skip_none(self) -> None:
|
||
"""跳过 None 计算均值。"""
|
||
result = aggregate_soft([0.8, None, 0.6])
|
||
assert result is not None
|
||
assert abs(result - 0.7) < 1e-9
|
||
|
||
def test_all_valid(self) -> None:
|
||
"""全部有效。"""
|
||
result = aggregate_soft([0.5, 0.7, 0.9])
|
||
assert result is not None
|
||
assert abs(result - 0.7) < 1e-9
|
||
|
||
|
||
# =========================================================================
|
||
# D. attribute_error 瀑布测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestAttributeError:
|
||
"""attribute_error 瀑布规则测试。"""
|
||
|
||
def test_extraction_failure_low_completeness(self) -> None:
|
||
"""avg completeness < 0.5 → extraction_failure。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(extraction_completeness=0.3, hallucination_rate=0.1),
|
||
_make_span(extraction_completeness=0.4, hallucination_rate=0.1),
|
||
],
|
||
missed_nodes=["node_1"], # 有遗漏,但 extraction 优先
|
||
)
|
||
result = attribute_error(qm)
|
||
assert result.error_type == "extraction_failure"
|
||
assert result.question_id == "q1"
|
||
|
||
def test_extraction_failure_high_hallucination(self) -> None:
|
||
"""max hallucination > 0.5 → extraction_failure。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(extraction_completeness=0.9, hallucination_rate=0.6),
|
||
],
|
||
)
|
||
result = attribute_error(qm)
|
||
assert result.error_type == "extraction_failure"
|
||
|
||
def test_search_failure(self) -> None:
|
||
"""有遗漏节点 → search_failure。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
|
||
],
|
||
missed_nodes=["node_1", "node_2"],
|
||
)
|
||
result = attribute_error(qm)
|
||
assert result.error_type == "search_failure"
|
||
|
||
def test_reasoning_failure(self) -> None:
|
||
"""evidence_sufficient=True → reasoning_failure。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
|
||
],
|
||
missed_nodes=[],
|
||
evidence_sufficient=True,
|
||
)
|
||
result = attribute_error(qm)
|
||
assert result.error_type == "reasoning_failure"
|
||
|
||
def test_mixed_evidence_none(self) -> None:
|
||
"""evidence_sufficient=None → mixed。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
|
||
],
|
||
missed_nodes=[],
|
||
evidence_sufficient=None,
|
||
)
|
||
result = attribute_error(qm)
|
||
assert result.error_type == "mixed"
|
||
|
||
def test_mixed_evidence_false(self) -> None:
|
||
"""evidence_sufficient=False → mixed。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(extraction_completeness=0.8, hallucination_rate=0.1),
|
||
],
|
||
missed_nodes=[],
|
||
evidence_sufficient=False,
|
||
)
|
||
result = attribute_error(qm)
|
||
assert result.error_type == "mixed"
|
||
|
||
def test_no_spans_extraction(self) -> None:
|
||
"""无 span 时 avg_completeness=0 (< 0.5) → extraction_failure。"""
|
||
qm = _make_qm(span_metrics=[], missed_nodes=["node_x"])
|
||
result = attribute_error(qm)
|
||
# _mean([]) = 0.0 < 0.5 → extraction_failure
|
||
assert result.error_type == "extraction_failure"
|
||
|
||
def test_reasoning_failure_type_is_none(self) -> None:
|
||
"""attribute_error 不设 reasoning_failure_type(由后续阶段补充)。"""
|
||
qm = _make_qm(evidence_sufficient=True)
|
||
result = attribute_error(qm)
|
||
assert result.reasoning_failure_type is None
|
||
|
||
|
||
# =========================================================================
|
||
# E. D2-D5 聚合测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestPercentile:
|
||
"""_percentile 辅助函数测试。"""
|
||
|
||
def test_empty_returns_zero(self) -> None:
|
||
"""空列表返回 0.0。"""
|
||
assert _percentile([], 0.5) == 0.0
|
||
|
||
def test_single_element(self) -> None:
|
||
"""单元素返回该元素。"""
|
||
assert _percentile([42.0], 0.5) == 42.0
|
||
|
||
def test_median_two_elements(self) -> None:
|
||
"""两元素中位数。"""
|
||
assert _percentile([1.0, 3.0], 0.5) == 2.0
|
||
|
||
def test_quartiles(self) -> None:
|
||
"""四分位线性插值。"""
|
||
values = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||
assert _percentile(values, 0.0) == 1.0
|
||
assert _percentile(values, 1.0) == 5.0
|
||
p25 = _percentile(values, 0.25)
|
||
assert abs(p25 - 2.0) < 1e-9
|
||
|
||
|
||
class TestAggregation:
|
||
"""D2-D5 聚合函数测试。"""
|
||
|
||
def test_d2_empty(self) -> None:
|
||
"""空输入返回空字典。"""
|
||
assert aggregate_d2([]) == {}
|
||
|
||
def test_d2_groups_by_tool(self) -> None:
|
||
"""按工具名分组聚合。"""
|
||
qm = _make_qm(
|
||
span_metrics=[
|
||
_make_span(
|
||
step=0,
|
||
tool_name="view_node",
|
||
extraction_completeness=0.8,
|
||
hallucination_rate=0.2,
|
||
),
|
||
_make_span(
|
||
step=1,
|
||
tool_name="view_node",
|
||
extraction_completeness=0.6,
|
||
hallucination_rate=0.1,
|
||
),
|
||
_make_span(
|
||
step=2,
|
||
tool_name="search_similar",
|
||
extraction_completeness=0.9,
|
||
hallucination_rate=0.0,
|
||
),
|
||
]
|
||
)
|
||
result = aggregate_d2([qm])
|
||
assert "view_node" in result
|
||
assert "search_similar" in result
|
||
assert result["view_node"]["n_calls"] == 2
|
||
assert result["search_similar"]["n_calls"] == 1
|
||
assert abs(result["view_node"]["avg_completeness"] - 0.7) < 1e-9
|
||
|
||
def test_d3_empty(self) -> None:
|
||
"""空输入返回空字典。"""
|
||
assert aggregate_d3([]) == {}
|
||
|
||
def test_d3_correct_vs_incorrect(self) -> None:
|
||
"""按正误拆分。"""
|
||
qm_correct = _make_qm(correct=True, task_type="T1", budget_usage=0.5)
|
||
qm_wrong = _make_qm(correct=False, task_type="T1", budget_usage=0.8)
|
||
result = aggregate_d3([qm_correct, qm_wrong])
|
||
assert "T1" in result
|
||
assert result["T1"]["correct"]["n_questions"] == 1
|
||
assert result["T1"]["incorrect"]["n_questions"] == 1
|
||
# avg_steps 存储 budget_usage 均值
|
||
assert result["T1"]["correct"]["avg_steps"] == 0.5
|
||
assert result["T1"]["incorrect"]["avg_steps"] == 0.8
|
||
|
||
def test_d4_empty(self) -> None:
|
||
"""空输入返回空字典。"""
|
||
assert aggregate_d4([]) == {}
|
||
|
||
def test_d4_adherence_rate(self) -> None:
|
||
"""技能遵循率计算。"""
|
||
qm = _make_qm(
|
||
task_type="T1",
|
||
correct=True,
|
||
skill_adherence=[
|
||
SkillStepAdherence(step_label="S1", adhered=True, description=""),
|
||
SkillStepAdherence(step_label="S1", adhered=False, description=""),
|
||
],
|
||
)
|
||
result = aggregate_d4([qm])
|
||
assert "T1" in result
|
||
assert result["T1"]["overall_adherence"] == 0.5
|
||
|
||
def test_d5_empty_returns_zero_structure(self) -> None:
|
||
"""空输入返回完整零结构。"""
|
||
result = aggregate_d5([])
|
||
assert "early_submit_rate" in result
|
||
assert result["early_submit_rate"] == 0.0
|
||
assert "format_compliance_rate" in result
|
||
assert "budget_usage_median" in result
|
||
assert "confirmation_bias_rate" in result
|
||
assert "per_type_bias" in result
|
||
assert result["per_type_bias"] == {}
|
||
|
||
def test_d5_with_data(self) -> None:
|
||
"""有数据时正确计算。"""
|
||
qm1 = _make_qm(
|
||
correct=True,
|
||
budget_usage=0.5,
|
||
format_compliance=1.0,
|
||
confidence_calibration="calibrated",
|
||
confirmation_bias=False,
|
||
)
|
||
qm2 = _make_qm(
|
||
correct=False,
|
||
budget_usage=0.2,
|
||
format_compliance=0.8,
|
||
confidence_calibration="high_conf_wrong",
|
||
confirmation_bias=True,
|
||
)
|
||
result = aggregate_d5([qm1, qm2])
|
||
assert result["format_compliance_rate"] == 0.9
|
||
assert result["high_conf_wrong_rate"] == 0.5
|
||
assert result["early_submit_rate"] == 1.0 # 1 wrong with budget<0.3
|
||
|
||
|
||
# =========================================================================
|
||
# F. Merge 函数测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestMerge:
|
||
"""merge_system_packs / merge_tool_packs 测试。"""
|
||
|
||
def test_merge_system_packs_none_on_empty(self) -> None:
|
||
"""空列表返回 None。"""
|
||
assert merge_system_packs([]) is None
|
||
|
||
def test_merge_system_packs_wraps_stats(self) -> None:
|
||
"""stats 包裹为 per_step 列表。"""
|
||
pack = SystemCasePack(stats={"a": 1}, failure_cases=[], success_cases=[])
|
||
merged = merge_system_packs([pack, pack])
|
||
assert merged is not None
|
||
assert "per_step" in merged.stats
|
||
assert len(merged.stats["per_step"]) == 2
|
||
|
||
def test_merge_system_packs_concats_cases(self) -> None:
|
||
"""failure/success cases 拼接。"""
|
||
case = CaseSample(
|
||
question_id="q1",
|
||
video_id="v1",
|
||
task_type="T1",
|
||
question="q",
|
||
options=[],
|
||
answer="a",
|
||
prediction="b",
|
||
correct=False,
|
||
error_type="mixed",
|
||
selection_reason="test",
|
||
metrics={},
|
||
trace=[],
|
||
)
|
||
p1 = SystemCasePack(stats={}, failure_cases=[case], success_cases=[])
|
||
p2 = SystemCasePack(stats={}, failure_cases=[case], success_cases=[case])
|
||
merged = merge_system_packs([p1, p2])
|
||
assert merged is not None
|
||
assert len(merged.failure_cases) == 2
|
||
assert len(merged.success_cases) == 1
|
||
|
||
def test_merge_tool_packs_empty(self) -> None:
|
||
"""空列表返回空字典。"""
|
||
assert merge_tool_packs([]) == {}
|
||
|
||
def test_merge_tool_packs_groups_by_name(self) -> None:
|
||
"""同名工具合并。"""
|
||
p1 = ToolCasePack(
|
||
tool_name="view_node",
|
||
target_files=["f1.md"],
|
||
stats={"x": 1},
|
||
failure_spans=[{"a": 1}],
|
||
success_spans=[],
|
||
)
|
||
p2 = ToolCasePack(
|
||
tool_name="view_node",
|
||
target_files=["f1.md"],
|
||
stats={"x": 2},
|
||
failure_spans=[{"b": 2}],
|
||
success_spans=[{"c": 3}],
|
||
)
|
||
merged = merge_tool_packs([p1, p2])
|
||
assert "view_node" in merged
|
||
vn = merged["view_node"]
|
||
assert len(vn.failure_spans) == 2
|
||
assert len(vn.success_spans) == 1
|
||
assert "per_step" in vn.stats
|
||
|
||
|
||
# =========================================================================
|
||
# G. run_diagnosis 入口测试
|
||
# =========================================================================
|
||
|
||
|
||
class TestRunDiagnosis:
|
||
"""run_diagnosis 入口测试。"""
|
||
|
||
def test_empty_predictions_returns_empty_result(self) -> None:
|
||
"""无预测时返回空 DiagnosisResult。"""
|
||
import asyncio
|
||
|
||
mock_log = AsyncMock()
|
||
mock_log.get_predictions.return_value = []
|
||
mock_log.get_traces.return_value = []
|
||
mock_llm = AsyncMock()
|
||
mock_store = MagicMock()
|
||
mock_store.list_skill_files.return_value = []
|
||
prompts = DiagnosePrompts(
|
||
defect_vs_lapse="",
|
||
reasoning_sub="",
|
||
span_eval_system="",
|
||
span_eval_user="",
|
||
missed_nodes="",
|
||
skill_adherence="",
|
||
confirmation_bias="",
|
||
evidence_sufficiency="",
|
||
)
|
||
result = asyncio.run(
|
||
run_diagnosis(
|
||
"run1",
|
||
[],
|
||
{},
|
||
mock_llm,
|
||
mock_log,
|
||
mock_store,
|
||
prompts,
|
||
concurrency=1,
|
||
)
|
||
)
|
||
assert isinstance(result, DiagnosisResult)
|
||
assert result.run_id == "run1"
|
||
assert result.error_attributions == []
|
||
assert result.degraded_count == 0
|