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

19 KiB
Raw Blame History

type, id, title, created, status
type id title created status
design tree-module-vertical-slice 建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移 2026-07-07 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_pathmodalityembed_modelembed_dimcreated_at

2.3 序列化

  • 主格式JSONsave_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: L3Card6 字段)
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

@dataclass(frozen=True)
class SRTEntry:
    start: float
    end: float
    text: str

4.3 SubtitleReport

@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 promptusable=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 接口

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 EmbeddingProviderembed(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.yamlembed: 段读取

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_namelocationscore_numberobject_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.pyadapters/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/