feat(tree): 质量校验 — 交叉验证 entities/visible_text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 01:57:32 -04:00
parent fb6f9964d8
commit 12f20493c1
2 changed files with 447 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
"""质量校验模块单元测试。"""
from __future__ import annotations
from app.tree.index import (
IndexMeta,
L1Card,
L1Node,
L2Card,
L2Node,
L3Card,
L3Node,
TreeIndex,
)
from app.tree.verify import VerifyStats, _normalize, fuzzy_match, verify_tree
class TestNormalize:
def test_lowercase(self):
assert _normalize("Hello World") == "hello world"
def test_strip_punctuation(self):
assert _normalize("Hello, World!") == "hello world"
def test_empty(self):
assert _normalize("") == ""
class TestFuzzyMatch:
def test_exact_match(self):
assert fuzzy_match("hello", "hello world")
def test_case_insensitive(self):
assert fuzzy_match("Hello", "say hello world")
def test_no_match(self):
assert not fuzzy_match("xyz", "hello world")
def test_none_entity(self):
assert not fuzzy_match(None, "hello")
def test_none_corpus(self):
assert not fuzzy_match("hello", None)
class TestVerifyTree:
def _make_tree(self):
"""构造一棵树,L2 有混合实体(有出处/无出处)。"""
l3_0 = L3Node(
id="l1_0_l2_0_l3_0",
card=L3Card(
frame_summary="一个运动员在跑步",
visible_entities=["运动员", "跑道"],
ongoing_actions=["跑步"],
visible_text=["Nike", "2024"],
spatial_layout="居中",
visual_attributes={},
),
timestamp=1.0,
subtitle="the athlete is running fast",
)
l3_1 = L3Node(
id="l1_0_l2_0_l3_1",
card=L3Card(
frame_summary="观众在欢呼",
visible_entities=["观众"],
ongoing_actions=["欢呼"],
visible_text=["Stadium"],
spatial_layout="广角",
visual_attributes={},
),
timestamp=3.0,
)
l2 = L2Node(
id="l1_0_l2_0",
card=L2Card(
event_description="比赛片段",
entities=["运动员", "裁判", "幻觉实体"], # "裁判"和"幻觉实体"无 L3 出处
actions=["跑步"],
action_subjects=["运动员"],
visible_text=["Nike", "不存在的文字"], # "不存在的文字"无 L3 出处
spatial_relations="",
state_changes=None,
),
time_range=(0.0, 10.0),
children=[l3_0, l3_1],
)
l1 = L1Node(
id="l1_0",
card=L1Card(
scene_summary="体育比赛",
main_setting="体育场",
key_entities=["运动员", "不存在的人"], # "不存在的人"无出处
main_actions=["比赛"],
topic_keywords=["体育"],
visible_text=["Nike", "Ghost"], # "Ghost"无出处
temporal_flow="从左到右",
),
time_range=(0.0, 10.0),
children=[l2],
)
return TreeIndex(metadata=IndexMeta("/test.mp4", "video"), roots=[l1])
def test_removes_ungrounded_l2_entities(self):
index = self._make_tree()
stats = verify_tree(index)
l2 = index.roots[0].children[0]
assert "运动员" in l2.card.entities
assert "幻觉实体" not in l2.card.entities
assert stats.l2_entities_removed >= 1
def test_removes_ungrounded_l2_visible_text(self):
index = self._make_tree()
stats = verify_tree(index)
l2 = index.roots[0].children[0]
assert "Nike" in l2.card.visible_text
assert "不存在的文字" not in l2.card.visible_text
assert stats.l2_visible_text_removed >= 1
def test_removes_ungrounded_l1_visible_text(self):
index = self._make_tree()
stats = verify_tree(index)
l1 = index.roots[0]
assert "Nike" in l1.card.visible_text
assert "Ghost" not in l1.card.visible_text
assert stats.l1_visible_text_removed >= 1
def test_removes_ungrounded_l1_key_entities(self):
index = self._make_tree()
stats = verify_tree(index)
l1 = index.roots[0]
assert "运动员" in l1.card.key_entities
assert "不存在的人" not in l1.card.key_entities
assert stats.l1_key_entities_removed >= 1
def test_preserves_grounded_entities(self):
index = self._make_tree()
verify_tree(index)
l2 = index.roots[0].children[0]
assert "运动员" in l2.card.entities
def test_returns_verify_stats(self):
index = self._make_tree()
stats = verify_tree(index)
assert isinstance(stats, VerifyStats)
total_kept = stats.l2_entities_kept + stats.l1_key_entities_kept
assert total_kept > 0
def test_frozen_card_replaced(self):
"""验证 Card 被替换为新实例(frozen dataclass 不能原地修改)。"""
index = self._make_tree()
old_l2_card = index.roots[0].children[0].card
verify_tree(index)
new_l2_card = index.roots[0].children[0].card
# Card should be a different object if anything was removed
assert old_l2_card is not new_l2_card