Files
Video-Tree-TRM5/research-wiki/designs/2026-07-07-search-module-design.md
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

17 KiB
Raw Permalink Blame History

type, node_id, title, date
type node_id title date
design design:2026-07-07-search-module-design 搜索 Agent 装配层设计(app/search/ 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 依赖方向

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

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.pyget_tool_descriptions() 获取(职责归属修正)
  • format_user_prompt 参数从 dict 改为显式 question / options / l1_node_idsP4

4.2 SkillRegistry + discover_skillsskills.py

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

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 ValueErrorAgentLoop 捕获,不计步)

与 TRM4 有意变更

  • 自由函数 + 大量位置参数 → 类封装(构造时注入依赖)
  • 工具描述 get_tool_descriptions() 移入此文件
  • LLM 摘要从 environment 拆出到 summarizer.pyenvironment 回归纯数据层)
  • 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 负责第三个的视觉摘要。

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 顺移)

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 新增)

@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

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 消费。需新增结构化查询方法:

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 交互流程

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 个工具,子包过度组织