Files
Video-Tree-TRM5/app/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

322 lines
11 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.
"""搜索 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"])