Files
Video-Tree-TRM5/research-wiki/designs/2026-07-07-tree-module-design.md
T

441 lines
19 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
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. supplementQ&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 # VideoTreeBuilderasyncio, 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/ |