19 KiB
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_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
@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 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 接口
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/ |