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>
This commit is contained in:
2026-07-07 06:07:27 -04:00
parent ca3ea1cdf2
commit f4f92b0938
3 changed files with 788 additions and 1 deletions
+321
View File
@@ -0,0 +1,321 @@
"""搜索 Agent 工具调度器 — 工具描述与 dispatch 分发。
实现 ``core/agent/protocols.ToolDispatcher`` Protocol。
连接 TreeEnvironment(数据)、summarizerLLM 摘要)、
visionVLM 观察)和 skills(策略加载)。
与 TRM4 ``core/tree/tools.py`` 的差异:
- 自由函数 ``dispatch()`` → ``SearchToolDispatcher`` 类(依赖注入);
- 同步 → 全异步;
- view_node / search_similar 内部拆分为 env 数据读取 + summarizer LLM 摘要。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from app.search.summarizer import summarize_children, summarize_node, summarize_nodes_batch
from app.search.vision import observe_frame
from app.tree.environment import _LEVEL_LABEL, TreeEnvironment, _node_level
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
import numpy as np
from app.ports import OCRProvider
from app.search.skills import SkillRegistry
from core.protocols import LLMProvider, VLMProvider
# ── 工具描述文本(与 TRM4 core/tree/tools.py 完全一致) ─────────────────
_BASE_DESCRIPTIONS = """\
## 可用工具
在 action 中指定 tool 和 args 来调用工具。
### view_node
查看节点信息,获取与问题相关的内容摘要和子节点概览。
- args: {"node_id": "节点 ID", "question": "当前关注的具体问题"}
### search_similar
语义检索最相关的节点,返回与问题相关的内容摘要。
- args: {"query": "搜索关键词(2-4 词)", "question": "当前关注的具体问题", "k": 返回数量(可选,默认 5}
### observe_frame
调用视觉模型查看关键帧图像,回答针对性的视觉问题。
- args: {"node_ids": ["L3 节点 ID 列表(1-4 个),或单个 L2 节点 ID"], "question": "针对帧内容的具体视觉问题"}
### submit_answer
提交最终答案。
- args: {"answer": "选项字母 A/B/C/D", "evidence": "关键证据摘要", "reasoning": "每个选项的判断理由"}"""
_SKILL_DESCRIPTION = """
### read_skill
加载指定题型技能的详细搜索策略。
- args: {"name": "技能名称"}"""
def get_tool_descriptions(include_read_skill: bool = False) -> str:
"""返回工具描述文本,用于写入 system prompt。
参数:
include_read_skill: 是否包含 read_skill 工具(manual 模式用)。
返回:
Markdown 格式的工具描述文本。
"""
text = _BASE_DESCRIPTIONS
if include_read_skill:
text += _SKILL_DESCRIPTION
return text
# ── SearchToolDispatcher ──────────────────────────────────────────────
class SearchToolDispatcher:
"""搜索 Agent 工具调度器,实现 ToolDispatcher Protocol。
按工具名路由到对应私有处理方法。未知工具抛 ValueError
(AgentLoop 捕获后不计步数);节点不存在等运行时错误
捕获后返回错误文本。
参数:
env: 视频树运行时环境(纯数据访问)。
tool_llm: 摘要用 LLM 端口。
vlm: 视觉模型端口。
ocr: 帧文字转录端口(None 不启用)。
prompts_dir: prompt 文件目录。
skills: 技能注册表(None 不启用 read_skill)。
embed_fn: 文本嵌入函数(search_similar 用)。
verify_vision: observe_frame 是否执行验证轮。
anchor: view_node 是否启用行号锚模式。
assemble_mode: 锚模式装配形态("ids"/"ids_expand"/"expand_only")。
stats_sink: 统计回调(None 不收集)。
"""
def __init__(
self,
env: TreeEnvironment,
tool_llm: LLMProvider,
vlm: VLMProvider,
ocr: OCRProvider | None,
prompts_dir: Path,
skills: SkillRegistry | None,
*,
embed_fn: Callable[[str | list[str]], np.ndarray],
verify_vision: bool,
anchor: bool,
assemble_mode: str,
stats_sink: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self._env = env
self._tool_llm = tool_llm
self._vlm = vlm
self._ocr = ocr
self._prompts_dir = prompts_dir
self._skills = skills
self._embed_fn = embed_fn
self._verify_vision = verify_vision
self._anchor = anchor
self._assemble_mode = assemble_mode
self._stats_sink = stats_sink
# ── ToolDispatcher Protocol 实现 ──────────────────────────────────
async def dispatch(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""按工具名分发到对应处理方法。
参数:
tool_name: 工具名称。
args: 工具参数字典。
context: 调用上下文(含 session_id、parent_call_id 等遥测字段)。
返回:
工具执行结果文本。
异常:
ValueError: 未知工具名——上抛给 AgentLoop,不计步数。
"""
try:
if tool_name == "view_node":
return await self._handle_view_node(args, context)
if tool_name == "search_similar":
return await self._handle_search_similar(args, context)
if tool_name == "observe_frame":
return await self._handle_observe_frame(args, context)
if tool_name == "submit_answer":
return f"[ok] 答案已提交: {args['answer']}"
if tool_name == "read_skill":
return self._handle_read_skill(args)
except (KeyError, FileNotFoundError) as e:
return f"工具执行错误: {e}"
raise ValueError(f"未知工具: {tool_name}")
# ── 私有处理方法 ──────────────────────────────────────────────────
async def _handle_view_node(self, args: dict[str, Any], context: dict[str, Any]) -> str:
"""view_node:节点摘要 + 子节点概览。
参数:
args: {"node_id": str, "question": str}。
context: 遥测上下文。
返回:
"[节点] {id} | {level} | {time}\\n\\n{summary}\\n\\n[子节点概览] ..."
"""
node_id: str = args["node_id"]
question: str = args["question"]
session_id = context.get("session_id")
parent_call_id = context.get("parent_call_id")
# Phase 1: 节点元数据(头部格式化)
node = self._env._id_to_node[node_id]
level = _node_level(node)
level_label = _LEVEL_LABEL[level]
time_str = TreeEnvironment._format_time_range(node)
# Phase 2: 节点内容摘要
raw_text, anchor_map = self._env.get_node_text(node_id, anchor=self._anchor)
summary = await summarize_node(
self._tool_llm,
raw_text,
question,
self._prompts_dir,
anchor_map=anchor_map,
assemble_mode=self._assemble_mode,
stats_sink=self._stats_sink,
session_id=session_id,
parent_call_id=parent_call_id,
)
parts: list[str] = [
f"[节点] {node_id} | {level_label} | {time_str}",
"",
summary,
]
# Phase 3: 子节点概览
children_info = self._env.get_children_info(node_id)
if children_info:
children_text = await summarize_children(
self._tool_llm,
children_info,
question,
self._prompts_dir,
session_id=session_id,
parent_call_id=parent_call_id,
)
parts.append(f"\n[子节点概览] {len(children_info)} 个子节点\n{children_text}")
return "\n".join(parts)
async def _handle_search_similar(self, args: dict[str, Any], context: dict[str, Any]) -> str:
"""search_similar:语义检索 + 批量摘要。
参数:
args: {"query": str, "question": str, "k": int (可选)}。
context: 遥测上下文。
返回:
"[搜索结果] 查询 \\"{query}\\" → N 个相关节点\\n\\n1. ..."
"""
query: str = args["query"]
question: str = args["question"]
top_k: int = args.get("k", 5)
session_id = context.get("session_id")
parent_call_id = context.get("parent_call_id")
# Phase 1: 语义检索
results = self._env.search_similar(query, top_k=top_k, embed_fn=self._embed_fn)
if not results:
return f'[搜索结果] 查询 "{query}" → 0 个相关节点'
# Phase 2: 构建摘要输入
items: list[tuple[str, str, str]] = []
for nid, score in results:
node = self._env._id_to_node[nid]
raw_text, _ = self._env.get_node_text(nid)
level = _node_level(node)
time_str = TreeEnvironment._format_time_range(node)
extra = f"{level} score={score:.4f} [{time_str}]"
items.append((nid, raw_text, extra))
# Phase 3: 并发批量摘要
summaries = await summarize_nodes_batch(
self._tool_llm,
items,
question,
self._prompts_dir,
session_id=session_id,
parent_call_id=parent_call_id,
)
# Phase 4: 格式化输出
lines: list[str] = []
for i, (nid, summary_text) in enumerate(summaries):
_, _, extra = items[i]
lines.append(f"{i + 1}. {nid} | {extra}\n {summary_text}")
header = f'[搜索结果] 查询 "{query}"{len(results)} 个相关节点'
return header + "\n\n" + "\n\n".join(lines)
async def _handle_observe_frame(self, args: dict[str, Any], context: dict[str, Any]) -> str:
"""observe_frameVLM 帧观察 + 字幕前置。
参数:
args: {"node_ids": list[str], "question": str}。
context: 遥测上下文。
返回:
"[字幕上下文] ...\\n[视觉观察] ...""[视觉观察] ..."
"""
node_ids: list[str] = args["node_ids"]
question: str = args.get("question", "")
session_id = context.get("session_id")
parent_call_id = context.get("parent_call_id")
if not question.strip():
return "工具执行错误: question 不能为空"
# Phase 1: 解析帧路径和字幕
frame_paths = self._env.resolve_frame_paths(node_ids)
subtitle = self._env.get_subtitle(node_ids[0])
# Phase 2: VLM 调用
result = await observe_frame(
self._vlm,
frame_paths,
question,
self._prompts_dir,
ocr=self._ocr,
verify=self._verify_vision,
stats_sink=self._stats_sink,
session_id=session_id,
parent_call_id=parent_call_id,
)
# Phase 3: 字幕前置拼接
if subtitle:
return f"[字幕上下文] {subtitle}\n{result}"
return result
def _handle_read_skill(self, args: dict[str, Any]) -> str:
"""read_skill:加载指定技能的搜索策略正文。
参数:
args: {"name": str}。
返回:
技能正文或错误提示。
"""
if self._skills is None:
return "错误: skills 未启用"
return self._skills.read(args["name"])
+21 -1
View File
@@ -339,6 +339,7 @@ class TreeEnvironment:
返回:
子节点信息列表,每项包含 {"id", "time_range", "summary"}。
time_range 为 (start, end) 数值元组(L3 节点退化为 (ts, ts))。
L3 叶子节点返回空列表。
异常:
@@ -357,7 +358,7 @@ class TreeEnvironment:
result.append(
{
"id": child.id,
"time_range": self._format_time_range(child),
"time_range": self._node_time_range_raw(child),
"summary": desc,
}
)
@@ -504,6 +505,25 @@ class TreeEnvironment:
return f"{node.timestamp:.1f}s"
return "N/A"
@staticmethod
def _node_time_range_raw(node: AnyNode) -> tuple[float, float]:
"""提取节点时间范围的原始数值元组。
L1/L2 返回 time_range 元组;L3 退化为 (timestamp, timestamp)
全部为 None 时兜底 (0.0, 0.0)。
参数:
node: 树节点。
返回:
(start, end) 秒级数值元组。
"""
if isinstance(node, (L1Node, L2Node)) and node.time_range:
return node.time_range
if isinstance(node, L3Node) and node.timestamp is not None:
return (node.timestamp, node.timestamp)
return (0.0, 0.0)
@staticmethod
def _get_children(node: AnyNode) -> list[AnyNode]:
"""获取节点的直接子节点列表。