Files
Video-Tree-TRM5/research-wiki/designs/2026-07-07-search-module-design.md
T
iomgaa 9e1f39c147 docs(design): 修复 Codex 审查发现 — 10 项修正
Critical: OCRProvider 移至 app/ports.py;新增 TreeEnvironment 结构化 API;
         observe_frame 补充字幕上下文拼接;search_similar 补充节点文本获取
Important: 遥测链路透传 session_id/parent_call_id;异常降级边界明确化;
           verify_vision/anchor/assemble_mode 改为必传;Prompt 路径逐文件列出;
           测试目录规范化到 tests/unit/
Minor: 依赖图补全 VLMProvider 连线
2026-07-07 05:29:52 -04:00

438 lines
17 KiB
Markdown
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.
---
type: design
node_id: design:2026-07-07-search-module-design
title: "搜索 Agent 装配层设计(app/search/"
date: 2026-07-07
---
# 搜索 Agent 装配层设计(app/search/
**日期** 2026-07-07 · **状态** 已批准 · **关联** TRM4 `core/search/` + `core/tree/tools.py` + `core/tree/vision.py` + `core/tree/summarizer.py`
---
## §1 定位
`app/search/` 是搜索 Agent 的"装配层"——为 `core/agent/loop.py` AgentLoop 提供 **prompt 组装**、**skill 管理**、**工具定义/分发** 和 **视觉观察**。它不控制推理循环,只被 AgentLoop 调用;编排责任在 `app/harness/inference.py`
### 与 TRM4 的映射
| TRM4 | TRM5 | 变更类型 |
|------|------|---------|
| `core/search/prompt.py` | `app/search/prompt.py` | 保真迁移 + P4 显式参数 |
| `core/search/skills.py` | `app/search/skills.py` | 保真迁移 |
| `core/tree/tools.py` | `app/search/tools.py` | 重组为 `SearchToolDispatcher` 类 |
| `core/tree/vision.py` | `app/search/vision.py` | 异步化 + Protocol 注入 |
| `core/tree/summarizer.py` | `app/search/summarizer.py` | 异步化 + Protocol 注入;含 anchor 锚模式 |
| `core/tree/ocr.py` | `adapters/ocr.py` | 异步化 + OCRProvider Protocol |
---
## §2 模块结构
```
app/search/
├── __init__.py # 公开 API 重导出
├── prompt.py # PromptManager — prompt 加载与拼装
├── skills.py # SkillRegistry + discover_skills — skill 扫描与注册
├── tools.py # SearchToolDispatcher(实现 ToolDispatcher Protocol
├── summarizer.py # question-conditioned 两轮 LLM 摘要(view_node / search_similar 用)
└── vision.py # observe_frameVLM 两轮 + OCR 注入)
adapters/
└── ocr.py # MonkeyOCRClient(实现 OCRProvider Protocol
app/ports.py # 新增 OCRProvider Protocol(应用层端口,与 EmbeddingProvider 同级)
store/prompts/ # 初始种子,逐文件从 TRM4 store/prompts/v2/ 字节级复制,不修改
├── system.md # ← TRM4 store/prompts/v2/system.md
├── observe_frame_extract.md # ← TRM4 store/prompts/v2/observe_frame_extract.md
├── observe_frame_verify.md # ← TRM4 store/prompts/v2/observe_frame_verify.md
├── view_node_extract.md # ← TRM4 store/prompts/v2/view_node_extract.md
├── view_node_verify.md # ← TRM4 store/prompts/v2/view_node_verify.md
├── view_node_children_extract.md # ← TRM4 store/prompts/v2/view_node_children_extract.md
├── view_node_children_verify.md # ← TRM4 store/prompts/v2/view_node_children_verify.md
├── search_similar_extract.md # ← TRM4 store/prompts/v2/search_similar_extract.md
└── search_similar_verify.md # ← TRM4 store/prompts/v2/search_similar_verify.md
```
---
## §3 依赖方向
```mermaid
flowchart TB
subgraph adapters
OCR_IMPL["adapters/ocr.py\nMonkeyOCRClient"]
end
subgraph core
PROTO["app/ports.py\nOCRProvider Protocol"]
AGENT_PROTO["core/agent/protocols.py\nToolDispatcher Protocol"]
end
subgraph app/search
PROMPT["prompt.py\nPromptManager"]
SKILLS["skills.py\nSkillRegistry"]
TOOLS["tools.py\nSearchToolDispatcher"]
SUMM["summarizer.py\nsummarize_node / _children / _batch"]
VISION["vision.py\nobserve_frame"]
end
subgraph app/tree
ENV["environment.py\nTreeEnvironment"]
end
OCR_IMPL -->|实现| PROTO
TOOLS -->|实现| AGENT_PROTO
TOOLS --> ENV
TOOLS --> SKILLS
TOOLS --> VISION
TOOLS --> SUMM
SUMM -->|依赖| PROTO_LLM["core/protocols.py\nLLMProvider"]
VISION -->|依赖| PROTO
VISION -->|依赖| PROTO_VLM["core/protocols.py\nVLMProvider"]
PROMPT --> SKILLS
PROMPT -.->|读取| STORE["store/prompts/*.md"]
SUMM -.->|读取| STORE
```
依赖只向内或同层,`core/` 不认识 `app/search/`
---
## §4 公开 API
### 4.1 PromptManagerprompt.py
```python
class PromptManager:
def __init__(self, prompts_dir: Path) -> None: ...
def build_inference_prompt(
self,
skill_mode: str,
task_type: str,
always_skills_text: str,
task_skill_map: dict[str, str],
catalog_text: str,
) -> str: ...
def format_user_prompt(
self,
question: str,
options: list[str],
l1_node_ids: list[str],
task_type: str | None = None,
) -> str: ...
def load(self, name: str) -> str: ...
```
**与 TRM4 有意变更**
- 工具描述从 `app/search/tools.py``get_tool_descriptions()` 获取(职责归属修正)
- `format_user_prompt` 参数从 `dict` 改为显式 `question` / `options` / `l1_node_ids`P4
### 4.2 SkillRegistry + discover_skillsskills.py
```python
def parse_frontmatter(text: str) -> dict[str, str]: ...
def strip_frontmatter(text: str) -> str: ...
class SkillRegistry:
def set_paths(self, mapping: dict[str, Path]) -> None: ...
def read(self, name: str) -> str: ...
def discover_skills(skills_dir: Path) -> tuple[str, dict[str, str], str, SkillRegistry]: ...
```
与 TRM4 逻辑完全一致,无有意变更。
### 4.3 SearchToolDispatchertools.py
```python
def get_tool_descriptions(include_read_skill: bool = False) -> str: ...
class SearchToolDispatcher:
"""实现 core/agent/protocols.ToolDispatcher。"""
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, int]], None] | None = None,
) -> None: ...
async def dispatch(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str: ...
```
| 工具 | 实现路径 |
|------|---------|
| `view_node` | → `env.get_node_text(node_id)` 获取原始文本 + `env.get_children_info(node_id)` 获取子节点结构化信息 → `summarizer.summarize_node(...)` 两轮 LLM 摘要 + `summarizer.summarize_children(...)` 子节点标注 |
| `search_similar` | → `env.search_similar(query, top_k, embed_fn=...)` 获取 `[(node_id, score)]` → 对每个 node_id 调 `env.get_node_text(node_id)``summarizer.summarize_nodes_batch(...)` 并发两轮 LLM 摘要 + 格式化 |
| `observe_frame` | → `env.resolve_frame_paths(...)` + `env.get_subtitle(node_ids[0])` 获取字幕 → `vision.observe_frame(...)` → 输出前拼接 `[字幕上下文]`(保真 TRM4 tools.py:136-153 |
| `submit_answer` | → 返回确认文本 |
| `read_skill` | → `skills.read(name)` |
| 未知工具 | → `raise ValueError`AgentLoop 捕获,不计步) |
**与 TRM4 有意变更**
- 自由函数 + 大量位置参数 → 类封装(构造时注入依赖)
- 工具描述 `get_tool_descriptions()` 移入此文件
- LLM 摘要从 environment 拆出到 `summarizer.py`environment 回归纯数据层)
- `SearchToolDispatcher.__init__` 新增 `tool_llm: LLMProvider` 参数(工具级 LLMthinking=False,用于 summarizer
- `dispatch()``context` 中提取 `session_id` / `parent_call_id`,透传给 summarizer / vision 的 LLM/VLM 调用,确保遥测链路完整
### 4.4 summarizersummarizer.py
从 TRM4 `core/tree/summarizer.py` 迁移。三个工具(view_node / search_similar / observe_frame)共享同构的"提取→验证"两轮模式。summarizer 负责前两个工具的文本摘要,vision 负责第三个的视觉摘要。
```python
async def summarize_node(
llm: LLMProvider,
raw_text: str,
question: str,
prompts_dir: Path,
*,
anchor_map: dict[str, str] | None,
assemble_mode: str,
stats_sink: Callable | None = None,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> str: ...
async def summarize_children(
llm: LLMProvider,
children_info: list[dict[str, Any]],
question: str,
prompts_dir: Path,
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> str: ...
async def summarize_nodes_batch(
llm: LLMProvider,
items: list[tuple[str, str, str]],
question: str,
prompts_dir: Path,
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> list[tuple[str, str]]: ...
```
| 函数 | Prompt 文件 | 输出格式 |
|------|-------------|---------|
| `summarize_node` | `view_node_extract.md` + `view_node_verify.md` | `"[内容摘要] ...\n[核实] ..."` |
| `summarize_children` | `view_node_children_extract.md` + `view_node_children_verify.md` | `"★★/★ 标注\n[核实] ..."` |
| `summarize_nodes_batch` | `search_similar_extract.md` + `search_similar_verify.md` | `[("node_id", "[内容摘要] ..."), ...]` |
**anchor 锚模式**`check_anchors` / `assemble_anchored_output`)保真迁移:给原始文本每行编号(`[c1]` `[s1]`),LLM 摘要引用行号,代码端校验合法性并展开引文。当前生产 `anchor=False`,但代码路径完整保留供后续 A/B 实验。
**与 TRM4 有意变更**
| 项目 | TRM4 | TRM5 |
|------|------|------|
| 归属 | `core/tree/summarizer.py`(嵌入 environment | `app/search/summarizer.py`(独立模块) |
| 异步 | `_call_llm` 同步 | `await llm.chat()` |
| LLM 接口 | 裸 LLMClient | LLMProvider Protocol |
| 并发 | `ThreadPoolExecutor` | `asyncio.gather`(搜索结果批量摘要) |
| Prompt 内容 | store/prompts/v2/ | 原封不动复制 |
### 4.5 observe_framevision.py
(原 §4.4,编号因插入 summarizer 顺移)
```python
async def observe_frame(
vlm: VLMProvider,
frame_paths: list[Path],
question: str,
prompts_dir: Path,
*,
ocr: OCRProvider | None,
verify: bool,
stats_sink: Callable[[dict[str, int]], None] | None = None,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> str: ...
```
两轮 VLM 调用保真:
```
1. [可选] OCR 转录 → 事前并置到 user_content
2. 提取轮: VLM + observe_frame_extract.md
3. [可选] 验证轮: VLM + observe_frame_verify.md
4. 返回 "[视觉观察] {证据}\n[验证] {核实结果}"
```
**与 TRM4 有意变更**
| 项目 | TRM4 | TRM5 |
|------|------|------|
| 异步 | `_call_vl` 同步 | `await vlm.chat_with_images()` |
| VLM 接口 | 裸 LLMClient + 手动 base64 | VLMProvider Protocolimages 传 Path |
| OCR 接口 | `Callable[[list[Path]], str]` | `OCRProvider` Protocolasync |
| Prompt 内容 | store/prompts/v2/ | 原封不动复制 |
### 4.6 OCRProvider Protocolapp/ports.py 新增)
```python
@runtime_checkable
class OCRProvider(Protocol):
"""帧文字转录端口。"""
async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
```
放置在 `app/ports.py`(与 `EmbeddingProvider` 同级),而非 `core/protocols.py`——OCR 只被 `app/search/` 使用,不是 core 共享端口。
### 4.7 MonkeyOCRClientadapters/ocr.py
```python
class MonkeyOCRClient:
"""实现 OCRProvider Protocol。多端点轮询 + 单帧降级。"""
def __init__(self, urls: list[str]) -> None: ...
async def check_health(self) -> None: ...
async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
```
内部同步 HTTP 调用通过 `asyncio.to_thread` 包装。端点轮询 + 线程安全 Session 保留 TRM4 逻辑。
### 4.8 TreeEnvironment 新增 APIapp/tree/environment.py 扩展)
现有 `view_node()` 返回格式化字符串,不适合 summarizer 消费。需新增结构化查询方法:
```python
def get_node_text(self, node_id: str, *, anchor: bool = False) -> tuple[str, dict[str, str] | None]:
"""返回节点原始文本(或带行号锚的文本)+ anchor_map。"""
...
def get_children_info(self, node_id: str) -> list[dict[str, Any]]:
"""返回子节点结构化信息列表 [{id, time_range, summary}, ...]。"""
...
```
现有 `view_node()``search_similar()` 保持不变(向后兼容),新方法专供 `SearchToolDispatcher` 使用。
---
## §5 交互流程
```mermaid
sequenceDiagram
participant H as harness/inference
participant PM as PromptManager
participant SK as discover_skills
participant AL as AgentLoop
participant TD as SearchToolDispatcher
participant ENV as TreeEnvironment
participant S as summarizer
participant V as vision.observe_frame
participant LLM as LLMProvider(tool)
participant VLM as VLMProvider
participant OCR as OCRProvider
H->>SK: discover_skills(skills_dir)
SK-->>H: (always_text, task_skill_map, catalog_text, registry)
H->>PM: build_inference_prompt(...)
PM-->>H: system_prompt
H->>PM: format_user_prompt(question, options, l1_ids)
PM-->>H: user_prompt
H->>TD: 构造(env, tool_llm, vlm, ocr, prompts_dir, registry, embed_fn)
H->>AL: run(system_prompt, user_prompt, tool_dispatcher)
loop AgentLoop 每步推理
AL->>TD: dispatch("view_node", {node_id, question}, context)
TD->>ENV: view_node(node_id)
ENV-->>TD: 原始 card 文本 + 子节点列表
TD->>S: summarize_node(llm, raw_text, question, ...)
S->>LLM: extract 轮
LLM-->>S: raw_summary
S->>LLM: verify 轮
LLM-->>S: verify_result
S-->>TD: "[内容摘要] ...\n[核实] ..."
TD->>S: summarize_children(llm, children_info, question, ...)
S-->>TD: "★★/★ 标注\n[核实] ..."
TD-->>AL: 完整输出
AL->>TD: dispatch("search_similar", {query, question, k}, context)
TD->>ENV: search_similar(query, top_k, embed_fn)
ENV-->>TD: [(node_id, score), ...]
TD->>S: summarize_nodes_batch(llm, items, question, ...)
S-->>TD: 并发两轮摘要结果
TD-->>AL: 格式化输出
AL->>TD: dispatch("observe_frame", {node_ids, question}, context)
TD->>ENV: resolve_frame_paths(node_ids)
ENV-->>TD: list[Path]
TD->>V: observe_frame(vlm, paths, question, ...)
V->>OCR: transcribe_frames(paths)
OCR-->>V: ocr_text
V->>VLM: extract 轮(图片+OCR+问题)
VLM-->>V: raw_evidence
V->>VLM: verify 轮(图片+证据)
VLM-->>V: verify_result
V-->>TD: "[视觉观察] ...\n[验证] ..."
TD-->>AL: 输出
AL->>TD: dispatch("submit_answer", args, context)
TD-->>AL: "[ok] 答案已提交"
end
AL-->>H: LoopResult
```
---
## §6 错误处理
| 场景 | 处理 | 与 TRM4 一致性 |
|------|------|---------------|
| 节点不存在 | env 抛 KeyErrordispatcher 捕获返回错误文本 | 一致 |
| summarize_node 提取轮失败 | 捕获 Exception,返回 `[摘要错误]` | 一致 |
| summarize_node 验证轮失败 | 降级返回 `[核实] 跳过(调用失败)` | 一致 |
| summarize_children 提取轮失败 | 降级回退原始子节点列表 | 一致 |
| 帧文件不存在 | FileNotFoundErrorvision 返回 `[VL错误]` | 一致 |
| VLM 提取轮失败 | 捕获 Exception,返回 `[VL错误]` | 一致 |
| VLM 验证轮失败 | 降级返回 `[验证] 跳过(调用失败)` | 一致 |
| OCR 失败 | 降级不注入,stats `ocr_failed=1` | 一致 |
| 未知工具名 | raise ValueErrorAgentLoop 不计步 | 一致 |
| read_skill 未注册 | KeyError 透传,dispatcher 捕获返回错误文本 | 一致 |
**原则**:工具执行错误不中断 AgentLoop。未知工具名 `raise ValueError`(由 AgentLoop 捕获不计步);已知工具的运行时错误在 dispatcher 层转为错误文本返回。
**允许的降级边界**(刻意宽泛捕获,与 TRM4 一致):
- OCR 转录失败 → 降级不注入(`ocr_fn` 是外部依赖,任何异常不得中断工具主流程)
- VLM 验证轮失败 → 降级跳过验证(提取结果仍然有效)
- summarize_children 失败 → 回退原始子节点列表
其他异常(如节点不存在、帧文件缺失)捕获特定异常类型,不做宽泛降级。
---
## §7 测试策略
| 测试文件 | 覆盖 | 方法 |
|----------|------|------|
| `tests/unit/test_search_prompt.py` | PromptManager 加载/拼装/格式化 | 临时目录写真实 TRM4 v2 prompt 文件 |
| `tests/unit/test_search_skills.py` | frontmatter 解析、discover_skills 分类 | 临时目录写 .md |
| `tests/unit/test_search_tools.py` | SearchToolDispatcher 5 个工具分发 + 摘要集成 | 假 env/LLM/VLM/OCR 通过 Protocol 注入 |
| `tests/unit/test_search_summarizer.py` | summarize_node(含 anchor 模式)、summarize_children、summarize_nodes_batchcheck_anchors / assemble 纯函数用真实输入 | 假 LLMProvider |
| `tests/unit/test_search_vision.py` | observe_frame 两轮、OCR 注入/降级、stats、字幕拼接 | 假 VLMProvider + 假 OCRProvider |
| `tests/unit/test_ocr_adapter.py` | MonkeyOCRClient 健康检查/轮询/降级 | `responses` 库 mock HTTP |
---
## §8 被否决的方案
| 方案 | 否决理由 |
|------|---------|
| vision.py 放 app/tree/ | observe_frame 是搜索工具实现,不是建树管线;tree/ 是离线预处理模块 |
| tools/ 子包 | 当前仅 5 个工具,子包过度组织 |