From 4e0e05210d873b495dc7dce259b21d6e97103f15 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 11 Jul 2026 08:53:56 -0400 Subject: [PATCH] feat(search): append raw entity fields after view_node summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit view_node 按题两轮摘要(summarize_node)会吞掉 entities/visible_text 字段信号,Agent 站在证据节点上仍漏读实体(benchmark 错题 M1,案例 786-2、872-3、750-1)。dispatcher 侧在摘要后确定性追加 [实体]/[画面 文字] 原文区块,LLM 无法吞掉。 - TreeEnvironment.node_entity_fields:按层级取 card 实体字段原文, 去空白、去重、分号拼接;空字段省键;未知节点抛 KeyError - _handle_view_node Phase 2.5:摘要后、子节点概览前追加实体区块 - 附带 ruff format 修正 test_tree_environment.py 两处既有格式 算法 #11(树环境语义搜索)数据访问层扩展,不改搜索算法本身。 --- app/search/tools.py | 4 ++ app/tree/environment.py | 45 ++++++++++++++ tests/unit/test_search_tools.py | 83 +++++++++++++++++++++++++ tests/unit/test_tree_environment.py | 96 ++++++++++++++++++++++++++++- 4 files changed, 226 insertions(+), 2 deletions(-) diff --git a/app/search/tools.py b/app/search/tools.py index f9d8f7a..640e5aa 100644 --- a/app/search/tools.py +++ b/app/search/tools.py @@ -201,6 +201,10 @@ class SearchToolDispatcher: summary, ] + # Phase 2.5: 确定性追加实体/画面文字原文(防按题摘要吞噬,Spec-1 B) + for label, text in self._env.node_entity_fields(node_id).items(): + parts.append(f"[{label}] {text}") + # Phase 3: 子节点概览 children_info = self._env.get_children_info(node_id) if children_info: diff --git a/app/tree/environment.py b/app/tree/environment.py index 2af8b25..df0532e 100644 --- a/app/tree/environment.py +++ b/app/tree/environment.py @@ -86,6 +86,13 @@ def _collect_card_strings( # subtitle 字段在 _node_full_text / _node_anchored_text 中单独处理 _SUBTITLE_SKIP: frozenset[str] = frozenset({"subtitle"}) +# 各层级 card 的实体字段名(B 修复:dispatcher 追加原文用) +_ENTITY_FIELDS_BY_LEVEL: dict[str, tuple[str, ...]] = { + "L1": ("key_entities",), + "L2": ("entities",), + "L3": ("visible_entities",), +} + def _collect_from_obj( obj: object, @@ -214,6 +221,44 @@ class TreeEnvironment: return "\n".join(parts) + def node_entity_fields(self, node_id: str) -> dict[str, str]: + """返回节点 card 的实体/画面文字字段原文。 + + 供 dispatcher 在按题摘要后确定性追加,防止 LLM 摘要吞掉 + entities/visible_text 信号(benchmark 错题 M1 恶化因素)。 + + 参数: + node_id: 节点 ID。 + + 返回: + {"实体": "...", "画面文字": "..."},空字段不含对应键。 + + 异常: + KeyError: 节点不存在。 + """ + node = self._id_to_node.get(node_id) + if node is None: + raise KeyError(f"节点不存在: {node_id}") + level = _node_level(node) + out: dict[str, str] = {} + + entity_values: list[str] = [] + for field_name in _ENTITY_FIELDS_BY_LEVEL[level]: + for value in getattr(node.card, field_name) or []: + if isinstance(value, str) and value.strip(): + entity_values.append(value.strip()) + if entity_values: + out["实体"] = "; ".join(dict.fromkeys(entity_values)) + + text_values = [ + v.strip() + for v in (getattr(node.card, "visible_text", None) or []) + if isinstance(v, str) and v.strip() + ] + if text_values: + out["画面文字"] = "; ".join(dict.fromkeys(text_values)) + return out + def search_similar( self, query: str, diff --git a/tests/unit/test_search_tools.py b/tests/unit/test_search_tools.py index e08feae..bcb221a 100644 --- a/tests/unit/test_search_tools.py +++ b/tests/unit/test_search_tools.py @@ -444,3 +444,86 @@ class TestDispatchErrors: context={}, ) assert "工具执行错误" in result + + +# ── view_node 实体追加测试(Spec-1 B)──────────────────────── + + +def _make_entity_tree() -> TreeIndex: + """L2 带实体字段的最小树(与 _make_test_tree 同构,仅换 card 内容)。""" + l2 = L2Node( + id="vid_L1_000_L2_000", + card=L2Card( + event_description="产品评测", + entities=["Bluetooth headset (both ears)", "reviewer"], + actions=["reviewing"], + action_subjects=["reviewer"], + visible_text=["$9.99"], + spatial_relations="", + state_changes=None, + ), + time_range=(5.0, 15.0), + children=[], + ) + l1 = L1Node( + id="vid_L1_000", + card=L1Card( + scene_summary="评测场景", + main_setting="室内", + key_entities=["reviewer"], + main_actions=["评测"], + topic_keywords=["数码"], + visible_text=[], + temporal_flow="线性", + ), + time_range=(0.0, 30.0), + children=[l2], + ) + return TreeIndex( + metadata=IndexMeta(source_path="test.mp4", modality="video"), + roots=[l1], + ) + + +@pytest.fixture() +def entity_dispatcher( + prompts_dir: Path, + skills_registry: SkillRegistry, +) -> SearchToolDispatcher: + """树含实体字段的 dispatcher(其余配置与 dispatcher fixture 一致)。""" + return SearchToolDispatcher( + env=TreeEnvironment(_make_entity_tree()), + tool_llm=FakeLLM(), + vlm=FakeVLM(), + ocr=FakeOCR(), + prompts_dir=prompts_dir, + skills=skills_registry, + embed_fn=_fake_embed_fn, + verify_vision=False, + anchor=False, + assemble_mode="ids", + ) + + +class TestViewNodeEntityAppendix: + """view_node 实体区块确定性追加测试。""" + + @pytest.mark.asyncio() + async def test_view_node_appends_entity_blocks( + self, monkeypatch: pytest.MonkeyPatch, entity_dispatcher: SearchToolDispatcher + ) -> None: + """摘要后必须出现 [实体]/[画面文字] 区块(确定性追加,不经 LLM)。""" + + async def _stub_summarize(*args: Any, **kwargs: Any) -> str: + return "[内容摘要] 与问题无关的摘要" + + monkeypatch.setattr("app.search.tools.summarize_node", _stub_summarize) + result = await entity_dispatcher.dispatch( + "view_node", + {"node_id": "vid_L1_000_L2_000", "question": "耳机戴哪只耳?"}, + context={}, + ) + assert "[实体]" in result + assert "Bluetooth headset (both ears)" in result + assert "[画面文字]" in result + assert "$9.99" in result diff --git a/tests/unit/test_tree_environment.py b/tests/unit/test_tree_environment.py index d1d154a..defcfc1 100644 --- a/tests/unit/test_tree_environment.py +++ b/tests/unit/test_tree_environment.py @@ -250,7 +250,8 @@ class TestGetNodeText: """锚模式应返回带锚文本和 anchor_map 字典。""" env = TreeEnvironment(_make_test_index()) text, anchor_map = env.get_node_text( - "vid_L1_000_L2_000_L3_000", anchor=True, + "vid_L1_000_L2_000_L3_000", + anchor=True, ) # 锚文本包含 [cN] 标记 assert "[c1]" in text @@ -271,7 +272,8 @@ class TestGetNodeText: """无字幕的 L3 节点锚模式不应产生 [sN] 锚。""" env = TreeEnvironment(_make_test_index()) text, anchor_map = env.get_node_text( - "vid_L1_000_L2_000_L3_001", anchor=True, + "vid_L1_000_L2_000_L3_001", + anchor=True, ) assert anchor_map is not None assert not any(k.startswith("s") for k in anchor_map) @@ -322,3 +324,93 @@ class TestGetChildrenInfo: env = TreeEnvironment(index) children = env.get_children_info("vid_L1_000") assert len(children[0]["summary"]) == 123 # 120 + "..." + + +# ── node_entity_fields 测试(Spec-1 B)─────────────────────── + + +def _make_entity_test_index() -> TreeIndex: + """带实体字段的最小三层树(含一个空字段 L2)。""" + l3 = L3Node( + id="vid_L1_000_L2_000_L3_000", + card=L3Card( + frame_summary="一名男子戴耳机", + visible_entities=["Bluetooth headset (both ears)", "man"], + ongoing_actions=["talking"], + visible_text=["EARPHONE BOTTLE OPENER"], + spatial_layout="man center", + visual_attributes={}, + ), + timestamp=10.0, + ) + l2 = L2Node( + id="vid_L1_000_L2_000", + card=L2Card( + event_description="产品评测", + entities=["Bluetooth headset (both ears)", "reviewer"], + actions=["reviewing"], + action_subjects=["reviewer"], + visible_text=["$9.99"], + spatial_relations="", + state_changes=None, + ), + time_range=(0.0, 60.0), + children=[l3], + ) + l2_empty = L2Node( + id="vid_L1_000_L2_001", + card=L2Card( + event_description="空镜", + entities=[], + actions=[], + action_subjects=[], + visible_text=[], + spatial_relations="", + state_changes=None, + ), + time_range=(60.0, 120.0), + ) + l1 = L1Node( + id="vid_L1_000", + card=L1Card( + scene_summary="评测场景", + main_setting="室内", + key_entities=["reviewer"], + main_actions=["评测"], + topic_keywords=["数码"], + visible_text=[], + temporal_flow="线性", + ), + time_range=(0.0, 120.0), + children=[l2, l2_empty], + ) + return TreeIndex(metadata=IndexMeta("/test.mp4", "video"), roots=[l1]) + + +class TestNodeEntityFields: + """node_entity_fields 方法测试(B 修复:dispatcher 追加原文)。""" + + def test_l2_entities_and_visible_text(self) -> None: + """L2 节点应返回 entities 和 visible_text 原文。""" + env = TreeEnvironment(_make_entity_test_index()) + fields = env.node_entity_fields("vid_L1_000_L2_000") + assert "Bluetooth headset (both ears)" in fields["实体"] + assert "$9.99" in fields["画面文字"] + + def test_l3_visible_entities(self) -> None: + """L3 节点应返回 visible_entities 和 visible_text 原文。""" + env = TreeEnvironment(_make_entity_test_index()) + fields = env.node_entity_fields("vid_L1_000_L2_000_L3_000") + assert "Bluetooth headset (both ears)" in fields["实体"] + assert "EARPHONE BOTTLE OPENER" in fields["画面文字"] + + def test_empty_fields_omitted(self) -> None: + """实体/画面文字均为空时应返回空字典(不含空键)。""" + env = TreeEnvironment(_make_entity_test_index()) + assert env.node_entity_fields("vid_L1_000_L2_001") == {} + + def test_unknown_node_raises(self) -> None: + """查询不存在的节点应抛出 KeyError。""" + env = TreeEnvironment(_make_entity_test_index()) + with pytest.raises(KeyError): + env.node_entity_fields("nonexistent")