--- type: design id: tree-module-vertical-slice title: "建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移" created: 2026-07-07 status: approved --- # 建树模块竖切设计 ## 1. 背景与动机 TRM5 的三大模块(建树、训练 harness、新题构建)中,建树是一切的地基——搜索 Agent、训练循环、检索器全部依赖树结构。当前 `app/tree/` 目录为空,需要从 reference 代码和 TRM4 迁移建树能力。 ### 1.1 现状 | 资产 | 位置 | 状态 | |------|------|------| | TreeIndex 数据结构 | `reference/video_tree_trm/tree_index.py` | 简单 description 字段,需扩展 card | | VideoTreeBuilder | `reference/video_tree_trm/video_tree_builder.py` | L2 轴心策略,需适配 TRM5 Protocol | | EmbeddingModel | `reference/video_tree_trm/embeddings.py` | 双后端,需拆分到 adapters/ | | TreeEnvironment | TRM4 `core/tree/environment.py` | 基于 flat dict,需改为 TreeIndex | | 树增强管线 | TRM4 `core/tree/enhance/` | verify/supplement 逻辑可复用 | | 已建树数据 | TRM4.zip `store/videos/` | 300 视频,原始卡片,质量良好 | ### 1.2 关键决策 | 决策 | 结论 | 理由 | |------|------|------| | 树格式 | 原始卡片(L3 6字段、L2 7字段、L1 7字段,非增强 10 字段) | 数据分析显示原始卡片质量很好;增强新增的 4 字段价值有限——people 空率 42%、emotion_cues 空率 64% | | 数据结构 | 扩展 TreeIndex 统一承载 card + embedding | 一棵树服务两个消费者(Agent 用 card、Retriever 用 embedding),避免双格式同步问题 | | 迁移来源 | TRM4.zip 原始树 | 未经 merge/verify 增强的干净数据,与 VideoTreeBuilder 输出格式一致 | | 修复模式 | 独立 `app/tree/repair/` 目录,可整目录删除 | 修复是历史遗留需求,后续不再需要 | ## 2. 数据结构 ### 2.1 Card 体系(frozen dataclass) 三级卡片与 VideoTreeBuilder VLM 输出对齐。L3 为 6 字段,L2 为 7 字段,L1 为 7 字段(与 TRM4 原始树一致): | Card 类型 | 字段数 | 字段 | 类型 | |-----------|--------|------|------| | **L3Card** | 6 | `frame_summary` | `str` | | | | `visible_entities` | `list[str]` | | | | `ongoing_actions` | `list[str]` | | | | `visible_text` | `list[str]` | | | | `spatial_layout` | `str` | | | | `visual_attributes` | `dict[str, Any]` | | **L2Card** | 7 | `event_description` | `str` | | | | `entities` | `list[str]` | | | | `actions` | `list[str]` | | | | `action_subjects` | `list[str]` | | | | `visible_text` | `list[str]` | | | | `spatial_relations` | `str` | | | | `state_changes` | `str \| None` | | **L1Card** | 7 | `scene_summary` | `str` | | | | `main_setting` | `str` | | | | `key_entities` | `list[str]` | | | | `main_actions` | `list[str]` | | | | `topic_keywords` | `list[str]` | | | | `visible_text` | `list[str]` | | | | `temporal_flow` | `str` | 所有 Card 均为 `frozen=True`,变更时整体替换(创建新 Card 实例赋给节点的 `card` 属性)。 ### 2.2 节点结构 继承 reference 嵌套关系,扩展 card。每个节点均有 `id: str` 字段: ``` TreeIndex ├── metadata: IndexMeta └── roots: list[L1Node] ├── id: str ├── card: L1Card ├── embedding: ndarray | None ├── time_range: tuple[float, float] | None └── children: list[L2Node] ├── id: str ├── card: L2Card ├── embedding: ndarray | None ├── time_range: tuple[float, float] | None └── children: list[L3Node] ├── id: str ├── card: L3Card ├── embedding: ndarray | None ├── frame_path: str | None ├── timestamp: float | None └── subtitle: str | None ``` **ID 规则**:建树时由 VideoTreeBuilder 生成,格式 `l1_{i}_l2_{j}_l3_{k}`。迁移时从 TRM4 flat JSON 的 `node_id` 字段读取。反序列化时校验 ID 唯一性。 **embedding 文本源**通过 property 派生: | 节点 | property | 来源 | |------|----------|------| | L3Node | `description` | `card.frame_summary` | | L2Node | `description` | `card.event_description` | | L1Node | `summary` | `card.scene_summary` | **IndexMeta** 与 reference 一致:`source_path`、`modality`、`embed_model`、`embed_dim`、`created_at`。 ### 2.3 序列化 - **主格式**:JSON(`save_json` / `load_json`),card 自动转 dict / 从 dict 恢复 - **embedding**:可选包含(base64 编码),默认不含 - **断点续跑**:`save_l1_json` / `load_l1_json` 保存单个 L1 子树中间结果 ## 3. VideoTreeBuilder 从 reference 迁移,核心算法保真(ARCHITECTURE.md §6 算法 #1、#2、#3)。 ### 3.1 改造点 | 维度 | reference | TRM5 | |------|-----------|------| | VLM 依赖 | `LLMClient` | `VLMProvider` Protocol | | LLM 依赖 | 同上 | `LLMProvider` Protocol | | 输出格式 | `L3Node.description`(字符串) | `L3Node.card: L3Card`(6 字段) | | VLM Prompt | 返回字符串数组 | 返回结构化 JSON 对象数组 | | 字幕输入 | 无 | 可选 `srt_entries` 参数,注入 VLM prompt | | L2 代表帧 | 独立提取(均匀采样) | 复用 L3 帧:先提帧阶段提取所有 L3 帧,L2 从同 clip 内的 L3 帧中均匀采样 `l2_representative_frames` 个 | | 日志 | `utils/logger_system.py` | loguru | **治理约束**:所有 VLM/LLM 调用必须经过 `GovernedLLMClient`(四层治理栈)+ `TelemetryRecorder` 遥测。VideoTreeBuilder 通过 `VLMProvider` / `LLMProvider` Protocol 接收已治理的客户端实例,不直接接触 adapter 层。调用方(CLI 或测试)负责注入治理实例。 ### 3.2 建树流程 ``` 1. [前置] 字幕完整性检查(可选) 2. [前置] 解析 SRT → list[SRTEntry](可选) 3. 时间切分:视频 → L1 时间区间列表 4. 帧提取:按 l3_fps 提取所有帧到缓存目录(ffmpeg 线程池并发,已存在则跳过) 5. L2 先行:从同 clip 内 L3 帧中均匀采样 l2_representative_frames 个代表帧 + 对应时段字幕 → VLM → L2Card 6. L3 向下:每帧 + L2 描述上下文 + 对应时刻字幕 → VLM → L3Card 7. L1 向上:L2 描述聚合 → LLM → L1Card 8. 字幕分配:Voronoi 中点策略将 SRT 写入 L3Node.subtitle 9. 组装 TreeIndex 10. 原子保存:先写最终 TreeIndex JSON,成功后清理 progress + intermediate 文件 ``` ### 3.3 VLM Prompt 修改策略 **增量修改,不做简化**。在 reference 原始 prompt 基础上追加: - **L3 批量 prompt**:保留原始指令,追加结构化 JSON 输出格式要求 + 字幕上下文(有字幕时) - **L3 单帧 fallback**:同上 - **L2 prompt**:保留原始指令,追加结构化输出 + 字幕 - **L1 prompt**:保留原始指令,追加结构化输出 **结构化 card JSON 解析**:VLM 返回 JSON 对象数组时,逐字段校验类型(str/list/dict)。字段缺失或类型错误时,该批次整体走逐帧 fallback(与算法 #2 一致)。单帧 fallback 解析失败时,使用空字符串/空列表填充缺失字段并记录 warning 日志。 ### 3.4 保真项 | # | 算法 | 保真方式 | |---|------|---------| | 1 | L2 轴心建树策略 | L2 先行 → L3 向下 → L1 向上,asyncio 链式并发 | | 2 | VLM 批量帧描述 + JSON fallback | `_L3_BATCH_SIZE=5`,解析失败逐帧 fallback | | 3 | 断点续跑机制 | `progress.json` + L1 中间 JSON,按段恢复;最终 JSON 成功写入后清理中间文件 | ## 4. 字幕模块 位置:`app/tree/subtitle.py`。从 TRM4 `enhance/merge.py` 和 TRM3 `tools/generate_subtitles.py` 提取。 ### 4.1 接口 | 函数 | 职责 | |------|------| | `parse_srt(path) → list[SRTEntry]` | 解析 SRT 文件,剥离 HTML 标签 | | `check_subtitle_completeness(entries, duration, min_coverage) → SubtitleReport` | 检查覆盖率、最大空白段、条目数 | | `extract_subtitle_for_range(entries, time_range) → str` | 提取时间范围内字幕,供 VLM prompt 注入 | | `assign_subtitles_voronoi(index, entries) → None` | Voronoi 中点策略将字幕分配到 L3 节点 | ### 4.2 SRTEntry ```python @dataclass(frozen=True) class SRTEntry: start: float end: float text: str ``` ### 4.3 SubtitleReport ```python @dataclass(frozen=True) class SubtitleReport: total_entries: int coverage_ratio: float # SRT 覆盖时长 / 视频总时长 max_gap_sec: float # 最大连续无字幕间隔 usable: bool # coverage_ratio >= min_coverage ``` **决策行为**:`usable=True` 时正常注入 VLM prompt;`usable=False` 时记录 warning 日志并以无字幕模式建树(不 raise,不阻断)。解析失败(文件损坏)时 raise,由调用方决定是否降级。 ### 4.4 建树时机 字幕在建树**前**解析完成,作为 `VideoTreeBuilder.build()` 的可选输入。有字幕时注入 VLM prompt 上下文,同时在建树完成后通过 Voronoi 分配写入 `L3Node.subtitle`。 ## 5. 质量校验 位置:`app/tree/verify.py`。建树模式和修复模式共用。 ### 5.1 校验项 | 字段 | 层级 | 逻辑 | |------|------|------| | `visible_text` | L1 | 每条须在下属 L2/L3 的 visible_text 中有出处 | | `visible_text` | L2 | 每条须在下属 L3 的 visible_text 中有出处 | | `key_entities` | L1 | 交叉校验 L2/L3 文本语料 | | `entities` | L2 | 交叉校验 L3 文本语料(visible_text + subtitle + frame_summary) | ### 5.2 匹配算法 模糊子串匹配:忽略大小写、去除标点。不实现编辑距离容忍(简单子串匹配已足够)。 ### 5.3 接口 ```python def verify_tree(index: TreeIndex) -> VerifyStats: """校验树节点卡片,删除不可靠内容,返回统计信息。 Card 为 frozen dataclass,校验时创建新 Card 实例(过滤后) 赋给节点的 card 属性。TreeIndex 和节点本身可变,Card 不可变。 """ ``` `VerifyStats` 记录各字段的保留/删除数量。 ## 6. Embeddings Embedding 实现遵循 Clean Architecture 依赖方向:`app/tree/` 通过 `EmbeddingProvider` Protocol 使用 embedding 能力,具体实现在 `adapters/embedding.py`。 ### 6.1 依赖分层 | 层 | 位置 | 内容 | |---|------|------| | Protocol | `app/ports.py` | `EmbeddingProvider`:`embed(texts) → ndarray [N, D]`、`dim → int` | | 实现 | `adapters/embedding.py` | 双后端(local sentence-transformers / remote OpenAI 兼容 API),L2 归一化 | | 消费 | `app/tree/index.py` | `TreeIndex.embed_all(embed_fn, model_name, dim)` 接受 `embed_fn` 参数 | ### 6.2 从 reference 迁移改造 - reference 的 `EmbeddingModel` 类拆分:接口 → `app/ports.py`,实现 → `adapters/embedding.py` - 日志从 `utils/logger_system.py` 改为 loguru - 配置从 `EmbedConfig` 改为从 `config/default.yaml` 的 `embed:` 段读取 ## 7. TreeEnvironment 位置:`app/tree/environment.py`。从 TRM4 迁移,改造为基于 TreeIndex。 ### 7.1 职责边界 TreeEnvironment 是**纯数据访问层**,不含 LLM 调用。LLM 摘要和 Agent 工具分发属于 `app/search/` 模块(本次竖切不含)。 ### 7.2 接口 | 方法 | 职责 | 依赖 | |------|------|------| | `view_node(node_id, anchor=False)` | 返回节点卡片 + 子节点概览;`anchor=True` 时为卡片字段添加行锚标 `[c1]` `[s1]` 供引用验证 | 纯数据 | | `search_similar(query, top_k, embed_fn)` | 语义搜索 + 祖先去重 | `embed_fn` 参数 | | `get_subtitle(node_id)` | 返回节点字幕 | 纯数据 | | `resolve_frame_paths(node_ids)` | node_id → 帧文件路径 | 纯数据 | ### 7.3 算法 #12 保真:语义搜索 ARCHITECTURE.md §6 算法 #12 要求保真"分块 embedding、祖先去重、锚定验证"。TRM5 的实现方式: | 原算法要素 | TRM4 实现 | TRM5 实现 | 变更理由 | |-----------|----------|----------|---------| | 分块 embedding | 卡片全文按 4000 字符分块,每块独立 embedding | 每节点一个 embedding(基于 description property) | TreeIndex 已有 per-node embedding,分块是 flat-dict 时代的替代方案;per-node embedding 语义更准确 | | 祖先去重 | 搜索结果中,若某节点的祖先已在结果中则去重 | **保持不变** | — | | 锚定验证 | `view_node(anchor=True)` 为卡片行添加 `[c1]` `[s1]` 等锚标 | **保持不变**,在 `view_node` 中实现 | — | **分块→单节点 embedding 的变更属于核心算法修改**,需在实现 PR 的 commit message 中标注"算法 #12 变更"并说明理由。 ### 7.4 与 TRM4 的其他差异 - 底层从 flat dict 改为 TreeIndex 嵌套结构 - `view_node()` 不调 LLM——纯数据返回卡片内容(LLM 摘要移至 `app/search/`) - 通过 `id → (l1_idx, l2_idx, l3_idx)` 索引映射实现 O(1) 节点查找,映射在 TreeEnvironment 构造时一次性构建 ## 8. 修复模式 位置:`app/tree/repair/`,独立可拆卸(`rm -rf app/tree/repair/` + 删除调用入口 = 零残留)。 ### 8.1 文件布局 ``` app/tree/repair/ ├── __init__.py ├── detector.py # 检测缺失/低质量节点 ├── regenerator.py # VLM 重新生成 + 向上级联 └── supplement.py # Q&A 反向补全 ``` ### 8.2 修复流程(底向上) ``` 1. 检测:扫描所有节点,标记 NodeIssue 2. L3 修复:VLM 重新描述帧(复用现有 L2 描述作上下文 + 字幕) 3. L2 重生成:受影响 L2 从全部 L3 children 聚合(LLM) 4. L1 重生成:受影响 L1 从全部 L2 children 聚合(LLM) 5. verify_tree() 6. supplement(Q&A 反向补全,仅修复模式) ``` **治理约束**:修复模式的所有 VLM/LLM 调用同样必须经过 `GovernedLLMClient` + `TelemetryRecorder`,通过 Protocol 参数注入。 ### 8.3 与建树模式的顺序差异 | | 建树模式 | 修复模式 | |---|---------|---------| | 前提 | 从零开始 | 已有树,局部损坏 | | 顺序 | L2 先行 → L3 向下 → L1 向上 | L3 修复 → L2 重生成 → L1 重生成 | | L2 上下文 | 必须先建 L2 才有上下文 | 复用现有 L2 描述 | | 级联 | 向下再向上 | 仅向上 | ### 8.4 检测项(detector.py) | 检查项 | 层级 | 判定条件 | |--------|------|---------| | 必填字段为空 | L3 | card 中 frame_summary / visible_entities 等为空 | | 帧文件缺失 | L3 | frame_path 指向的文件不存在 | | 无子节点 | L2/L1 | children 列表为空 | | 时间空洞 | L2 | 相邻 L2 clips 时间范围不连续 | ### 8.5 Q&A 反向补全(supplement.py) 从 TRM4 `enhance/supplement.py` 迁移,仅修复模式使用(建树模式不含此步骤)。 | 特性 | 说明 | |------|------| | 注入类别白名单 | `person_name`、`location`、`score_number`、`object_name` | | 禁止注入 | 情感、因果、时序推理 | | LLM 调用 | 每题一次,分析缺失事实 + 搜索已有 + 注入缺失 | | 注入前去重 | 大小写归一后去除重复条目 | ## 9. 迁移 一次性操作,完成后归档迁移工具。 ### 9.1 迁移资产清单 | 资产 | TRM4 来源 | TRM5 目标 | |------|----------|----------| | 原始树 JSON | TRM4.zip → `store/videos//tree.json` | `store/videos//tree.json`(转换为 TreeIndex JSON) | | 未压缩帧(1280×720) | TRM4.zip → `store/videos//frames/` | `store/videos//frames/` | | SRT 字幕 | `data/Video-MME/subtitle/*.srt` | `data/Video-MME/subtitle/` | | 原始视频压缩包 | `data/Video-MME/original_data/*.zip` | `data/Video-MME/original_data/` | | 原始视频 MP4 | `data/Video-MME/videos/`(如果存在) | `data/Video-MME/videos/` | | Benchmark 问题 | `store/questions/benchmarks/Video-MME/*.json` | `store/questions/benchmarks/Video-MME/` | ### 9.2 迁移步骤 ``` 1. 解压 TRM4.zip 到临时目录 2. 拷贝原始资产(帧、SRT、视频、问题) 3. 格式转换:flat tree.json → TreeIndex JSON(一次性 Python 脚本) 4. 验证(见 §9.3) 5. 清理临时目录 6. 归档转换脚本到 tools/archived/ ``` 格式转换脚本 `tools/convert_flat_to_treeindex.py` 是一次性工具,仅 CLI 调用,`app/`/`core/`/`adapters/` 不 import 该脚本。迁移完成后移至 `tools/archived/`。 ### 9.3 迁移验收 | 验收项 | 条件 | |--------|------| | 视频数 | 300 个视频目录均存在 | | tree.json | 每个视频目录有 tree.json,可正常反序列化为 TreeIndex | | frames | 每个 L3 节点的 frame_path 对应文件存在,JPEG 可读 | | SRT | `data/Video-MME/subtitle/` 下 SRT 文件数 ≥ 290(部分视频无字幕为已知情况) | | 问题 | 每个视频有对应 question JSON | **失败处理**:迁移脚本输出缺失资产报告(视频 ID + 缺失项),非零缺失时以 exit code 1 退出但不回滚已迁移的资产(允许手动补充后重跑验证)。 ## 10. 文件布局与依赖 ### 10.1 文件结构 ``` app/tree/ ├── __init__.py ├── index.py # TreeIndex, L1/L2/L3Node, L1/L2/L3Card, IndexMeta ├── video_builder.py # VideoTreeBuilder(asyncio, VLMProvider) ├── subtitle.py # SRT 解析 + 完整性检查 + Voronoi 分配 ├── verify.py # 质量校验(建树/修复共用) ├── environment.py # TreeEnvironment(运行时数据访问) └── repair/ # 修复模式(独立可拆卸) ├── __init__.py ├── detector.py # 检测缺失/低质量节点 ├── regenerator.py # VLM 重新生成 + 向上级联 └── supplement.py # Q&A 反向补全 app/ports.py # EmbeddingProvider Protocol(新增) adapters/ ├── embedding.py # EmbeddingProvider 实现(local/remote 双后端) └── vlm.py # VLMProvider 实现(最小可用版本) tools/ ├── migrate_from_trm4.sh # 迁移主脚本 └── convert_flat_to_treeindex.py # 格式转换(迁移后归档至 tools/archived/) ``` ### 10.2 依赖方向 ``` app/tree/ → core/protocols.py (VLMProvider, LLMProvider, TelemetryRecorder) app/tree/ → app/ports.py (EmbeddingProvider) app/tree/ ✗ adapters/(只通过 Protocol) app/tree/repair/ → app/tree/index.py, verify.py, subtitle.py(内部依赖) adapters/ → core/protocols.py, app/ports.py(实现 Protocol) ``` ### 10.3 竖切边界 本次竖切包含 `adapters/embedding.py` 和 `adapters/vlm.py` 的**最小可用实现**,确保竖切可端到端运行(CLAUDE.md §4.4:"开始实现就必须完成到可运行状态")。完整的治理集成(GovernedVLMClient 等)在需要时增量添加。 | 模块 | 状态 | |------|------| | `app/tree/` 全部文件 | 本次实现 | | `adapters/embedding.py` | 本次实现(最小可用) | | `adapters/vlm.py` | 本次实现(最小可用) | | `text_builder.py` | 后续实现 | | `app/search/` tool dispatch + LLM 摘要 | Agent 层,下个竖切 | ## 11. 被拒方案 | 方案 | 拒绝理由 | |------|---------| | 双格式共存(TreeIndex + flat JSON) | 数据重复、同步风险、维护两套序列化 | | 使用增强 10 字段卡片 | 数据分析显示增强字段价值有限:people 空率 42%、emotion_cues 空率 64% | | 从当前 TRM4 store 迁移增强树 | 增强过程引入了格式偏差,原始树更干净 | | 富卡片建树代码复用 | 代码已丢失(远端服务器仅存与 reference 相同的简单版本) | | EmbeddingModel 放在 app/tree/ | 违反 Clean Architecture 依赖方向,外部 SDK 实现应在 adapters/ |