---
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/<vid>/tree.json` | `store/videos/<vid>/tree.json`（转换为 TreeIndex JSON） |
| 未压缩帧（1280×720） | TRM4.zip → `store/videos/<vid>/frames/` | `store/videos/<vid>/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/ |
