feat(search): append raw entity fields after view_node summary
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(树环境语义搜索)数据访问层扩展,不改搜索算法本身。
This commit is contained in:
@@ -201,6 +201,10 @@ class SearchToolDispatcher:
|
|||||||
summary,
|
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: 子节点概览
|
# Phase 3: 子节点概览
|
||||||
children_info = self._env.get_children_info(node_id)
|
children_info = self._env.get_children_info(node_id)
|
||||||
if children_info:
|
if children_info:
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ def _collect_card_strings(
|
|||||||
# subtitle 字段在 _node_full_text / _node_anchored_text 中单独处理
|
# subtitle 字段在 _node_full_text / _node_anchored_text 中单独处理
|
||||||
_SUBTITLE_SKIP: frozenset[str] = frozenset({"subtitle"})
|
_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(
|
def _collect_from_obj(
|
||||||
obj: object,
|
obj: object,
|
||||||
@@ -214,6 +221,44 @@ class TreeEnvironment:
|
|||||||
|
|
||||||
return "\n".join(parts)
|
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(
|
def search_similar(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
|
|||||||
@@ -444,3 +444,86 @@ class TestDispatchErrors:
|
|||||||
context={},
|
context={},
|
||||||
)
|
)
|
||||||
assert "工具执行错误" in result
|
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
|
||||||
|
|||||||
@@ -250,7 +250,8 @@ class TestGetNodeText:
|
|||||||
"""锚模式应返回带锚文本和 anchor_map 字典。"""
|
"""锚模式应返回带锚文本和 anchor_map 字典。"""
|
||||||
env = TreeEnvironment(_make_test_index())
|
env = TreeEnvironment(_make_test_index())
|
||||||
text, anchor_map = env.get_node_text(
|
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] 标记
|
# 锚文本包含 [cN] 标记
|
||||||
assert "[c1]" in text
|
assert "[c1]" in text
|
||||||
@@ -271,7 +272,8 @@ class TestGetNodeText:
|
|||||||
"""无字幕的 L3 节点锚模式不应产生 [sN] 锚。"""
|
"""无字幕的 L3 节点锚模式不应产生 [sN] 锚。"""
|
||||||
env = TreeEnvironment(_make_test_index())
|
env = TreeEnvironment(_make_test_index())
|
||||||
text, anchor_map = env.get_node_text(
|
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 anchor_map is not None
|
||||||
assert not any(k.startswith("s") for k in anchor_map)
|
assert not any(k.startswith("s") for k in anchor_map)
|
||||||
@@ -322,3 +324,93 @@ class TestGetChildrenInfo:
|
|||||||
env = TreeEnvironment(index)
|
env = TreeEnvironment(index)
|
||||||
children = env.get_children_info("vid_L1_000")
|
children = env.get_children_info("vid_L1_000")
|
||||||
assert len(children[0]["summary"]) == 123 # 120 + "..."
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user