Files
Video-Tree-TRM5/tests/unit/test_search_tools.py
T
iomgaa f4f92b0938 feat(search): 实现 SearchToolDispatcher 工具调度器 (Task 7)
- 新增 app/search/tools.py:
  - get_tool_descriptions() 工具描述文本(与 TRM4 一致)
  - SearchToolDispatcher 类实现 ToolDispatcher Protocol
  - dispatch() 按工具名路由: view_node / search_similar /
    observe_frame / submit_answer / read_skill
  - ValueError(未知工具)上抛,KeyError/FileNotFoundError 捕获返回错误文本
  - view_node: env.get_node_text + summarize_node + get_children_info + summarize_children
  - search_similar: env.search_similar + summarize_nodes_batch
  - observe_frame: env.resolve_frame_paths + get_subtitle + observe_frame + 字幕前置

- 修复 app/tree/environment.py get_children_info():
  - 原实现返回 _format_time_range (str) 导致 summarize_children 解包失败
  - 改为返回原始数值元组 via 新增 _node_time_range_raw 静态方法

- 新增 tests/unit/test_search_tools.py (14 tests):
  - get_tool_descriptions 含/不含 read_skill
  - 五种工具 dispatch 路由验证
  - 未知工具 ValueError + 节点不存在错误文本

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 06:07:27 -04:00

447 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SearchToolDispatcher 与 get_tool_descriptions 单元测试。
验证工具描述生成和五种工具的 dispatch 路由:
view_node、search_similar、observe_frame、submit_answer、read_skill
以及未知工具 ValueError 和节点不存在错误文本。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
import numpy as np
import pytest
from app.search.skills import SkillRegistry
from app.search.tools import SearchToolDispatcher, get_tool_descriptions
from app.tree.environment import TreeEnvironment
from app.tree.index import (
IndexMeta,
L1Card,
L1Node,
L2Card,
L2Node,
L3Card,
L3Node,
TreeIndex,
)
from core.types import LLMResponse
# ── 假实现 ────────────────────────────────────────────────────────────
def _make_llm_response(content: str = "fake summary") -> LLMResponse:
"""构造固定的 LLMResponse 实例。"""
return LLMResponse(
content=content,
thinking="",
model="fake-model",
provider="fake",
prompt_tokens=10,
completion_tokens=5,
latency_ms=50,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="fake-call-id",
)
class FakeLLM:
"""最小 LLMProvider 假实现。"""
async def chat(
self,
messages: list[dict[str, Any]],
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
"""返回固定摘要内容。"""
return _make_llm_response("fake summary")
class FakeVLM:
"""最小 VLMProvider 假实现。"""
async def chat_with_images(
self,
messages: list[dict[str, Any]],
images: list[str | Path],
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
"""返回固定视觉观察内容。"""
return _make_llm_response("fake visual observation")
class FakeOCR:
"""最小 OCRProvider 假实现。"""
async def transcribe_frames(self, frame_paths: list[Path]) -> str:
"""返回固定 OCR 文本。"""
return "OCR text"
def _fake_embed_fn(texts: str | list[str]) -> np.ndarray:
"""返回固定维度的 L2 归一化嵌入向量。"""
if isinstance(texts, str):
vec = np.ones((1, 4), dtype=np.float32)
else:
vec = np.ones((len(texts), 4), dtype=np.float32)
norms = np.linalg.norm(vec, axis=1, keepdims=True)
return vec / norms
# ── Fixtures ──────────────────────────────────────────────────────────
def _make_test_tree() -> TreeIndex:
"""构建包含 L1→L2→L3 的最小测试树。"""
l3 = L3Node(
id="vid_L1_000_L2_000_L3_000",
card=L3Card(
frame_summary="test frame summary",
visible_entities=["person"],
ongoing_actions=["walking"],
visible_text=[],
spatial_layout="center",
visual_attributes={},
),
timestamp=10.0,
frame_path="frames/L1_000_L2_000_L3_000.jpg",
subtitle="test subtitle text",
)
l2 = L2Node(
id="vid_L1_000_L2_000",
card=L2Card(
event_description="test event description",
entities=["person"],
actions=["walking"],
action_subjects=["person"],
visible_text=[],
spatial_relations="none",
state_changes=None,
),
time_range=(5.0, 15.0),
children=[l3],
)
l1 = L1Node(
id="vid_L1_000",
card=L1Card(
scene_summary="test scene summary",
main_setting="outdoor",
key_entities=["person"],
main_actions=["walking"],
topic_keywords=["outdoor"],
visible_text=[],
temporal_flow="linear",
),
time_range=(0.0, 30.0),
children=[l2],
)
return TreeIndex(
metadata=IndexMeta(source_path="test.mp4", modality="video"),
roots=[l1],
)
@pytest.fixture()
def env() -> TreeEnvironment:
"""带最小树的 TreeEnvironment。"""
return TreeEnvironment(_make_test_tree())
@pytest.fixture()
def prompts_dir(tmp_path: Path) -> Path:
"""在 tmp 目录中创建必需的 prompt 文件。"""
prompt_files = [
"view_node_extract.md",
"view_node_verify.md",
"view_node_children_extract.md",
"view_node_children_verify.md",
"search_similar_extract.md",
"search_similar_verify.md",
"observe_frame_extract.md",
"observe_frame_verify.md",
]
for name in prompt_files:
(tmp_path / name).write_text(f"fake prompt for {name}", encoding="utf-8")
return tmp_path
@pytest.fixture()
def skills_registry(tmp_path: Path) -> SkillRegistry:
"""带一个预注册技能的 SkillRegistry。"""
skill_path = tmp_path / "test_skill.md"
skill_path.write_text(
"---\nname: test_skill\ndescription: test\n---\nskill body content",
encoding="utf-8",
)
registry = SkillRegistry()
registry.set_paths({"test_skill": skill_path})
return registry
@pytest.fixture()
def dispatcher(
env: TreeEnvironment,
prompts_dir: Path,
skills_registry: SkillRegistry,
) -> SearchToolDispatcher:
"""标准配置的 SearchToolDispatcher 实例。"""
return SearchToolDispatcher(
env=env,
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",
)
@pytest.fixture()
def dispatcher_no_skills(
env: TreeEnvironment,
prompts_dir: Path,
) -> SearchToolDispatcher:
"""skills=None 的 SearchToolDispatcher 实例。"""
return SearchToolDispatcher(
env=env,
tool_llm=FakeLLM(),
vlm=FakeVLM(),
ocr=None,
prompts_dir=prompts_dir,
skills=None,
embed_fn=_fake_embed_fn,
verify_vision=False,
anchor=False,
assemble_mode="ids",
)
# ── get_tool_descriptions 测试 ───────────────────────────────────────
class TestGetToolDescriptions:
"""get_tool_descriptions 工具描述生成测试。"""
def test_without_read_skill(self) -> None:
"""不含 read_skill 时应包含四个基础工具。"""
text = get_tool_descriptions(include_read_skill=False)
assert "view_node" in text
assert "search_similar" in text
assert "observe_frame" in text
assert "submit_answer" in text
assert "read_skill" not in text
def test_with_read_skill(self) -> None:
"""含 read_skill 时应额外包含 read_skill 工具描述。"""
text = get_tool_descriptions(include_read_skill=True)
assert "view_node" in text
assert "read_skill" in text
assert "加载指定题型技能" in text
# ── dispatch 路由测试 ─────────────────────────────────────────────────
class TestDispatchViewNode:
"""dispatch view_node 工具测试。"""
@pytest.mark.asyncio()
async def test_view_node_returns_header_and_summary(
self, dispatcher: SearchToolDispatcher
) -> None:
"""view_node 应返回含节点头部、摘要和子节点概览的文本。"""
result = await dispatcher.dispatch(
"view_node",
{"node_id": "vid_L1_000", "question": "what happens?"},
context={},
)
# 头部格式
assert "[节点] vid_L1_000 | 场景层 |" in result
assert "0.0-30.0s" in result
# 摘要内容(来自 FakeLLM
assert "fake summary" in result
# 子节点概览(L1 有 L2 子节点)
assert "[子节点概览]" in result
assert "1 个子节点" in result
@pytest.mark.asyncio()
async def test_view_node_l3_no_children(self, dispatcher: SearchToolDispatcher) -> None:
"""L3 叶子节点应无子节点概览段。"""
result = await dispatcher.dispatch(
"view_node",
{"node_id": "vid_L1_000_L2_000_L3_000", "question": "test"},
context={},
)
assert "[节点] vid_L1_000_L2_000_L3_000 | 关键帧层 |" in result
assert "[子节点概览]" not in result
class TestDispatchSearchSimilar:
"""dispatch search_similar 工具测试。"""
@pytest.mark.asyncio()
async def test_search_similar_returns_results(self, dispatcher: SearchToolDispatcher) -> None:
"""search_similar 应返回搜索头部和编号结果列表。"""
result = await dispatcher.dispatch(
"search_similar",
{"query": "walking", "question": "what is the person doing?"},
context={},
)
assert '[搜索结果] 查询 "walking"' in result
assert "个相关节点" in result
# 至少有一个编号结果
assert "1." in result
# 包含分数信息
assert "score=" in result
@pytest.mark.asyncio()
async def test_search_similar_custom_k(self, dispatcher: SearchToolDispatcher) -> None:
"""search_similar 的 k 参数应限制返回数量。"""
result = await dispatcher.dispatch(
"search_similar",
{"query": "test", "question": "test", "k": 1},
context={},
)
assert "1 个相关节点" in result
class TestDispatchObserveFrame:
"""dispatch observe_frame 工具测试。"""
@pytest.mark.asyncio()
async def test_observe_frame_with_subtitle(
self, dispatcher: SearchToolDispatcher, tmp_path: Path
) -> None:
"""有字幕的 L3 节点应在输出前添加字幕上下文。"""
# 创建帧文件使路径存在检查通过
frame_file = tmp_path / "L1_000_L2_000_L3_000.jpg"
frame_file.write_bytes(b"\xff\xd8\xff\xe0")
# 重建 dispatcher 指定 frames_dir
tree = _make_test_tree()
env_with_frames = TreeEnvironment(tree, frames_dir=tmp_path)
d = SearchToolDispatcher(
env=env_with_frames,
tool_llm=FakeLLM(),
vlm=FakeVLM(),
ocr=FakeOCR(),
prompts_dir=dispatcher._prompts_dir,
skills=None,
embed_fn=_fake_embed_fn,
verify_vision=False,
anchor=False,
assemble_mode="ids",
)
result = await d.dispatch(
"observe_frame",
{
"node_ids": ["vid_L1_000_L2_000_L3_000"],
"question": "what is visible?",
},
context={},
)
assert "[字幕上下文] test subtitle text" in result
assert "fake visual observation" in result
@pytest.mark.asyncio()
async def test_observe_frame_empty_question(self, dispatcher: SearchToolDispatcher) -> None:
"""空 question 应返回错误文本。"""
result = await dispatcher.dispatch(
"observe_frame",
{"node_ids": ["vid_L1_000_L2_000_L3_000"], "question": " "},
context={},
)
assert "question 不能为空" in result
class TestDispatchSubmitAnswer:
"""dispatch submit_answer 工具测试。"""
@pytest.mark.asyncio()
async def test_submit_answer_returns_confirmation(
self, dispatcher: SearchToolDispatcher
) -> None:
"""submit_answer 应返回确认文本。"""
result = await dispatcher.dispatch(
"submit_answer",
{"answer": "B", "evidence": "seen in frame", "reasoning": "clear visual"},
context={},
)
assert result == "[ok] 答案已提交: B"
class TestDispatchReadSkill:
"""dispatch read_skill 工具测试。"""
@pytest.mark.asyncio()
async def test_read_skill_returns_body(self, dispatcher: SearchToolDispatcher) -> None:
"""read_skill 应返回去除 frontmatter 后的技能正文。"""
result = await dispatcher.dispatch(
"read_skill",
{"name": "test_skill"},
context={},
)
assert "skill body content" in result
@pytest.mark.asyncio()
async def test_read_skill_disabled(self, dispatcher_no_skills: SearchToolDispatcher) -> None:
"""skills=None 时 read_skill 应返回未启用提示。"""
result = await dispatcher_no_skills.dispatch(
"read_skill",
{"name": "anything"},
context={},
)
assert result == "错误: skills 未启用"
# ── 错误处理测试 ──────────────────────────────────────────────────────
class TestDispatchErrors:
"""dispatch 错误处理测试。"""
@pytest.mark.asyncio()
async def test_unknown_tool_raises_value_error(self, dispatcher: SearchToolDispatcher) -> None:
"""未知工具应抛出 ValueError。"""
with pytest.raises(ValueError, match="未知工具: nonexistent_tool"):
await dispatcher.dispatch("nonexistent_tool", {}, context={})
@pytest.mark.asyncio()
async def test_node_not_found_returns_error_text(
self, dispatcher: SearchToolDispatcher
) -> None:
"""节点不存在时应返回错误文本(非异常)。"""
result = await dispatcher.dispatch(
"view_node",
{"node_id": "nonexistent_node", "question": "test"},
context={},
)
assert "工具执行错误" in result
assert "nonexistent_node" in result
@pytest.mark.asyncio()
async def test_read_skill_not_found_returns_error_text(
self, dispatcher: SearchToolDispatcher
) -> None:
"""未注册的技能名应返回错误文本。"""
result = await dispatcher.dispatch(
"read_skill",
{"name": "nonexistent_skill"},
context={},
)
assert "工具执行错误" in result