From 2be2569ed85b0ee9fbdf2a8edea404052778bdf6 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Tue, 7 Jul 2026 01:27:36 -0400 Subject: [PATCH] docs(tree): add tree module vertical slice design and implementation plan --- .../designs/2026-07-07-tree-module-design.md | 440 ++++++ .../designs/tree-module-vertical-slice.md | 9 + research-wiki/graph/edges.json | 17 + research-wiki/index.md | 10 +- research-wiki/log.md | 4 + .../2026-07-07-tree-module-vertical-slice.md | 1262 +++++++++++++++++ .../plans/tree-module-vertical-slice.md | 9 + 7 files changed, 1748 insertions(+), 3 deletions(-) create mode 100644 research-wiki/designs/2026-07-07-tree-module-design.md create mode 100644 research-wiki/designs/tree-module-vertical-slice.md create mode 100644 research-wiki/plans/2026-07-07-tree-module-vertical-slice.md create mode 100644 research-wiki/plans/tree-module-vertical-slice.md diff --git a/research-wiki/designs/2026-07-07-tree-module-design.md b/research-wiki/designs/2026-07-07-tree-module-design.md new file mode 100644 index 0000000..02d3de7 --- /dev/null +++ b/research-wiki/designs/2026-07-07-tree-module-design.md @@ -0,0 +1,440 @@ +--- +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/ | diff --git a/research-wiki/designs/tree-module-vertical-slice.md b/research-wiki/designs/tree-module-vertical-slice.md new file mode 100644 index 0000000..1c6deb2 --- /dev/null +++ b/research-wiki/designs/tree-module-vertical-slice.md @@ -0,0 +1,9 @@ +--- +type: design +node_id: design:tree-module-vertical-slice +title: "建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移" +date: 2026-07-07 +--- + +# 建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移 + diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index b01d9e6..497384d 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -15,6 +15,16 @@ "id": "plan:core-agent-adapters-llm", "label": "core/agent/ + adapters/llm 基础设施实现计划", "type": "plan" + }, + { + "id": "design:tree-module-vertical-slice", + "label": "建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移", + "type": "design" + }, + { + "id": "plan:tree-module-vertical-slice", + "label": "建树模块竖切实现计划", + "type": "plan" } ], "links": [ @@ -24,6 +34,13 @@ "relation": "implements", "evidence": "计划实现设计文档中定义的全异步 AgentLoop + 四层治理栈", "added": "2026-07-07T02:25:32.349931+00:00" + }, + { + "source": "plan:tree-module-vertical-slice", + "target": "design:tree-module-vertical-slice", + "relation": "implements", + "evidence": "计划逐 Task 实现设计文档中的 11 个模块", + "added": "2026-07-07T05:27:02.953166+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index 4cbf94a..9209e92 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,11 +1,15 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-07-07 02:25 UTC +> 自动生成,更新时间:2026-07-07 05:27 UTC -## design (1) +## design (3) - [2026-07-06-core-agent-adapters-llm-design](designs/2026-07-06-core-agent-adapters-llm-design.md) `design:2026-07-06-core-agent-adapters-llm-design` +- [建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移](designs/2026-07-07-tree-module-design.md) `design:2026-07-07-tree-module-design` +- [建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移](designs/tree-module-vertical-slice.md) `design:tree-module-vertical-slice` -## plan (3) +## plan (5) - [2026-07-06-core-agent-adapters-llm](plans/2026-07-06-core-agent-adapters-llm.md) `plan:2026-07-06-core-agent-adapters-llm` +- [2026-07-07-tree-module-vertical-slice](plans/2026-07-07-tree-module-vertical-slice.md) `plan:2026-07-07-tree-module-vertical-slice` - [core/agent/ + adapters/llm 基础设施实现计划](plans/core-agent-adapters-llm.md) `plan:core-agent-adapters-llm` +- [建树模块竖切实现计划](plans/tree-module-vertical-slice.md) `plan:tree-module-vertical-slice` - [项目基础设施初始化计划](plans/infrastructure-setup.md) `plan:infrastructure-setup` diff --git a/research-wiki/log.md b/research-wiki/log.md index 2f6567f..9c0ddf0 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -8,3 +8,7 @@ - [2026-07-07 02:25 UTC] 新增 plan: core/agent/ + adapters/llm 基础设施实现计划 (plan:core-agent-adapters-llm) - [2026-07-07 02:25 UTC] 新增边: plan:core-agent-adapters-llm --implements--> design:core-agent-adapters-llm - [2026-07-07 02:25 UTC] 重建索引: 4 篇页面 +- [2026-07-07 05:26 UTC] 新增 design: 建树模块竖切设计:数据结构 + 建树 + 修复 + 迁移 (design:tree-module-vertical-slice) +- [2026-07-07 05:26 UTC] 新增 plan: 建树模块竖切实现计划 (plan:tree-module-vertical-slice) +- [2026-07-07 05:27 UTC] 新增边: plan:tree-module-vertical-slice --implements--> design:tree-module-vertical-slice +- [2026-07-07 05:27 UTC] 重建索引: 8 篇页面 diff --git a/research-wiki/plans/2026-07-07-tree-module-vertical-slice.md b/research-wiki/plans/2026-07-07-tree-module-vertical-slice.md new file mode 100644 index 0000000..08055d6 --- /dev/null +++ b/research-wiki/plans/2026-07-07-tree-module-vertical-slice.md @@ -0,0 +1,1262 @@ +# 建树模块竖切实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 TRM5 中实现完整的建树模块竖切——数据结构、VideoTreeBuilder、字幕、校验、运行时环境、修复模式、适配器、迁移。 + +**Architecture:** 扩展 reference 的 TreeIndex 为统一数据结构(Card 体系 + embedding),VideoTreeBuilder 通过 VLMProvider/LLMProvider Protocol 调用已治理的 LLM 客户端。Clean Architecture 四层分层:`core/protocols.py` → `app/tree/` → `adapters/`。 + +**Tech Stack:** Python 3.11, asyncio, loguru, numpy, sentence-transformers, httpx, ffmpeg, pytest + +**设计文档:** `research-wiki/designs/2026-07-07-tree-module-design.md` + +**核心算法保真:** 本计划涉及算法 #1(L2 轴心建树)、#2(VLM 批量帧描述 + JSON fallback)、#3(断点续跑)、#12(树环境语义搜索,分块→单节点 embedding 变更)。每个涉及保真的 Task 标注了 `[保真]` 标记和校验检查点。 + +--- + +## 文件结构总览 + +| 操作 | 文件路径 | 职责 | +|------|----------|------| +| Create | `app/tree/index.py` | TreeIndex + L1/L2/L3 Node/Card + 序列化 | +| Create | `app/tree/subtitle.py` | SRT 解析 + 完整性检查 + Voronoi 分配 | +| Create | `app/tree/verify.py` | 质量校验 | +| Create | `app/tree/video_builder.py` | VideoTreeBuilder(asyncio, VLM) | +| Create | `app/tree/environment.py` | TreeEnvironment 运行时 | +| Create | `app/tree/repair/__init__.py` | 修复模式包 | +| Create | `app/tree/repair/detector.py` | 缺失/低质量节点检测 | +| Create | `app/tree/repair/regenerator.py` | VLM 重生成 + 向上级联 | +| Create | `app/tree/repair/supplement.py` | Q&A 反向补全 | +| Modify | `app/ports.py` | 新增 EmbeddingProvider Protocol | +| Create | `adapters/embedding.py` | EmbeddingProvider 实现 | +| Create | `adapters/vlm.py` | VLMProvider 最小可用实现 | +| Create | `tools/migrate_from_trm4.sh` | 迁移主脚本 | +| Create | `tools/convert_flat_to_treeindex.py` | 格式转换(迁移后归档) | +| Create | `tests/unit/test_tree_index.py` | TreeIndex 单元测试 | +| Create | `tests/unit/test_subtitle.py` | 字幕模块单元测试 | +| Create | `tests/unit/test_verify.py` | 校验模块单元测试 | +| Create | `tests/unit/test_video_builder.py` | VideoTreeBuilder 单元测试 | +| Create | `tests/unit/test_tree_environment.py` | TreeEnvironment 单元测试 | +| Create | `tests/unit/test_embedding_adapter.py` | Embedding 适配器测试 | +| Create | `tests/unit/test_vlm_adapter.py` | VLM 适配器测试 | +| Create | `tests/unit/test_repair_detector.py` | 修复检测器测试 | +| Create | `tests/unit/test_repair_regenerator.py` | 修复重生成器测试 | +| Create | `tests/unit/test_repair_supplement.py` | Q&A 补全测试 | +| Create | `tests/integration/test_tree_build_e2e.py` | 建树端到端集成测试 | + +--- + +### Task 1: TreeIndex 数据结构 + +**Files:** +- Create: `app/tree/index.py` +- Test: `tests/unit/test_tree_index.py` + +**说明:** 三级 Card frozen dataclass + 三级 Node dataclass + TreeIndex 容器 + JSON 序列化/反序列化 + embedding 矩阵提取。这是整个竖切的基础,后续所有 Task 依赖此文件。 + +- [ ] **Step 1: 编写 Card + Node + TreeIndex 的失败测试** + +```python +# tests/unit/test_tree_index.py +"""TreeIndex 数据结构单元测试。""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +from app.tree.index import ( + IndexMeta, + L1Card, + L1Node, + L2Card, + L2Node, + L3Card, + L3Node, + TreeIndex, +) + + +# ── fixtures ── + +def _make_l3(idx: int = 0) -> L3Node: + return L3Node( + id=f"l1_0_l2_0_l3_{idx}", + card=L3Card( + frame_summary=f"帧{idx}描述", + visible_entities=["实体A"], + ongoing_actions=["动作A"], + visible_text=["文字A"], + spatial_layout="居中构图", + visual_attributes={"lighting": "明亮"}, + ), + timestamp=idx * 2.0, + frame_path=f"frames/l1_0_l2_0_l3_{idx}.jpg", + ) + + +def _make_l2(n_l3: int = 2) -> L2Node: + return L2Node( + id="l1_0_l2_0", + card=L2Card( + event_description="事件描述", + entities=["实体B"], + actions=["动作B"], + action_subjects=["主体B"], + visible_text=["文字B"], + spatial_relations="左右排列", + state_changes=None, + ), + time_range=(0.0, 60.0), + children=[_make_l3(i) for i in range(n_l3)], + ) + + +def _make_l1(n_l2: int = 1, n_l3: int = 2) -> L1Node: + return L1Node( + id="l1_0", + card=L1Card( + scene_summary="场景摘要", + main_setting="室内", + key_entities=["实体C"], + main_actions=["动作C"], + topic_keywords=["关键词"], + visible_text=["文字C"], + temporal_flow="从左到右", + ), + time_range=(0.0, 600.0), + children=[_make_l2(n_l3) for _ in range(n_l2)], + ) + + +def _make_index(n_l1: int = 1) -> TreeIndex: + meta = IndexMeta(source_path="/test/video.mp4", modality="video") + return TreeIndex(metadata=meta, roots=[_make_l1() for _ in range(n_l1)]) + + +# ── Card 测试 ── + +class TestCards: + def test_l3_card_frozen(self): + card = L3Card( + frame_summary="desc", visible_entities=[], ongoing_actions=[], + visible_text=[], spatial_layout="", visual_attributes={}, + ) + with pytest.raises(AttributeError): + card.frame_summary = "changed" + + def test_l2_card_fields(self): + card = L2Card( + event_description="evt", entities=[], actions=[], + action_subjects=[], visible_text=[], spatial_relations="", + state_changes=None, + ) + assert card.event_description == "evt" + assert card.state_changes is None + + def test_l1_card_fields(self): + card = L1Card( + scene_summary="scene", main_setting="outdoor", + key_entities=["e"], main_actions=["a"], + topic_keywords=["k"], visible_text=["t"], + temporal_flow="flow", + ) + assert card.scene_summary == "scene" + + +# ── Node 测试 ── + +class TestNodes: + def test_l3_description_property(self): + node = _make_l3() + assert node.description == node.card.frame_summary + + def test_l2_description_property(self): + node = _make_l2() + assert node.description == node.card.event_description + + def test_l1_summary_property(self): + node = _make_l1() + assert node.summary == node.card.scene_summary + + def test_l3_default_embedding_none(self): + node = _make_l3() + assert node.embedding is None + + def test_l3_subtitle_default_none(self): + node = _make_l3() + assert node.subtitle is None + + +# ── TreeIndex 测试 ── + +class TestTreeIndex: + def test_is_embedded_false_by_default(self): + index = _make_index() + assert not index.is_embedded + + def test_embed_all(self): + index = _make_index() + def fake_embed(texts): + if isinstance(texts, str): + texts = [texts] + return np.random.randn(len(texts), 4).astype(np.float32) + + index.embed_all(fake_embed, "test-model", 4) + assert index.is_embedded + assert index.metadata.embed_model == "test-model" + assert index.metadata.embed_dim == 4 + + def test_l1_embeddings_shape(self): + index = _make_index(n_l1=2) + def fake_embed(texts): + if isinstance(texts, str): + texts = [texts] + return np.random.randn(len(texts), 4).astype(np.float32) + + index.embed_all(fake_embed, "test-model", 4) + m = index.l1_embeddings() + assert m.shape == (2, 4) + + def test_get_node(self): + index = _make_index() + node = index.get_node(0, 0, 1) + assert node.id == "l1_0_l2_0_l3_1" + + def test_get_node_out_of_bounds(self): + index = _make_index() + with pytest.raises(IndexError): + index.get_node(99, 0, 0) + + +# ── 序列化测试 ── + +class TestSerialization: + def test_json_roundtrip(self, tmp_path): + index = _make_index() + path = tmp_path / "tree.json" + index.save_json(str(path)) + + loaded = TreeIndex.load_json(str(path)) + assert len(loaded.roots) == 1 + assert loaded.roots[0].id == "l1_0" + assert loaded.roots[0].card.scene_summary == "场景摘要" + assert loaded.roots[0].children[0].children[0].card.frame_summary == "帧0描述" + + def test_json_roundtrip_with_embedding(self, tmp_path): + index = _make_index() + def fake_embed(texts): + if isinstance(texts, str): + texts = [texts] + return np.random.randn(len(texts), 4).astype(np.float32) + + index.embed_all(fake_embed, "test-model", 4) + path = tmp_path / "tree_emb.json" + index.save_json(str(path), include_embedding=True) + + loaded = TreeIndex.load_json(str(path)) + assert loaded.is_embedded + np.testing.assert_array_almost_equal( + loaded.roots[0].embedding, index.roots[0].embedding, decimal=5 + ) + + def test_l1_json_roundtrip(self, tmp_path): + from app.tree.index import save_l1_json, load_l1_json + l1 = _make_l1() + path = tmp_path / "l1_0.json" + save_l1_json(str(path), l1) + loaded = load_l1_json(str(path)) + assert loaded.id == "l1_0" + assert len(loaded.children) == 1 + assert len(loaded.children[0].children) == 2 + + def test_id_uniqueness_validation(self, tmp_path): + """重复 ID 在反序列化时应报错。""" + index = _make_index() + d = index.to_dict() + # 人为制造重复 ID + d["roots"].append(d["roots"][0]) + path = tmp_path / "dup.json" + with open(path, "w") as f: + json.dump(d, f) + with pytest.raises(ValueError, match="重复"): + TreeIndex.load_json(str(path)) +``` + +- [ ] **Step 2: 运行测试,确认全部 FAIL** + +```bash +conda activate Video-Tree-TRM & pytest tests/unit/test_tree_index.py -v 2>&1 | tail -20 +``` + +预期:所有测试 FAIL(`ModuleNotFoundError: No module named 'app.tree.index'`) + +- [ ] **Step 3: 实现 `app/tree/index.py`** + +从 `reference/video_tree_trm/tree_index.py` 迁移,关键改造: +- 新增 `L3Card`、`L2Card`、`L1Card` frozen dataclass +- `L3Node.description` / `L2Node.description` / `L1Node.summary` 改为 property(从 card 派生) +- 节点增加 `card` 字段(替代原来的 `description` 直接字段) +- `L3Node` 新增 `subtitle: str | None` 字段 +- `to_dict()` / `from_dict()` 适配 card dict 序列化 +- `load_json()` 反序列化时校验 ID 唯一性 +- 删除 pickle 序列化(不需要) +- 日志用 loguru 替代 `utils/logger_system` +- 保留 `embed_all()`, `l1_embeddings()`, `l2_embeddings_of()`, `l3_embeddings_of()`, `get_node()`, `save_l1_json()`, `load_l1_json()` 的全部逻辑 + +逐行参考 `reference/video_tree_trm/tree_index.py` 确保不遗漏: +- `_embed_to_str()` / `_embed_from_str()`: 保持不变 +- `IndexMeta`: 保持不变 +- `TreeIndex.is_embedded`: 保持不变 +- `TreeIndex.embed_all()`: 保持不变(L3 按 L2 分组批量 embed) +- `TreeIndex.l1_embeddings()` / `l2_embeddings_of()` / `l3_embeddings_of()`: 保持不变 + +- [ ] **Step 4: 运行测试,确认全部 PASS** + +```bash +conda activate Video-Tree-TRM & pytest tests/unit/test_tree_index.py -v +``` + +预期:全部 PASS + +- [ ] **Step 5: lint 检查** + +```bash +conda activate Video-Tree-TRM & ruff check app/tree/index.py --fix && ruff format app/tree/index.py +``` + +- [ ] **Step 6: 提交** + +```bash +git add app/tree/index.py tests/unit/test_tree_index.py +git commit -m "feat(tree): TreeIndex 数据结构 — Card 体系 + 节点 + 序列化" +``` + +--- + +### Task 1.5: TreeConfig 数据类 + +**Files:** +- Create: `app/tree/config.py` + +**说明:** 定义 `TreeConfig` frozen dataclass,字段对齐 `config/default.yaml` 的 `tree:` 段。提供 `from_dict()` 工厂方法。 + +- [ ] **Step 1: 创建 `app/tree/config.py`** + +```python +"""建树模块配置。""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TreeConfig: + """建树配置参数,字段对齐 config/default.yaml 的 tree: 段。""" + + l1_segment_duration: float = 600.0 + l2_clip_duration: float = 60.0 + l3_fps: float = 0.5 + l2_representative_frames: int = 6 + cache_dir: str = "cache/trees" + concurrency: int = 16 + subtitle_inject: bool = True + srt_window_sec: float = 5.0 + + @classmethod + def from_dict(cls, d: dict) -> TreeConfig: + """从 YAML 解析后的 dict 构造。""" + return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/tree/config.py +git commit -m "feat(tree): TreeConfig 配置 dataclass" +``` + +--- + +### Task 2: EmbeddingProvider Protocol + 适配器 + +**Files:** +- Modify: `app/ports.py` +- Create: `adapters/embedding.py` +- Test: `tests/unit/test_embedding_adapter.py` + +**说明:** 定义 `EmbeddingProvider` Protocol,实现 local/remote 双后端适配器。从 `reference/video_tree_trm/embeddings.py` 迁移,拆分为 Protocol + 实现。 + +- [ ] **Step 1: 编写失败测试** + +```python +# tests/unit/test_embedding_adapter.py +"""EmbeddingProvider 适配器单元测试。""" + +from __future__ import annotations + +import numpy as np +import pytest + +from app.ports import EmbeddingProvider + + +class TestEmbeddingProviderProtocol: + def test_protocol_shape(self): + """确认 Protocol 定义了 embed() 和 dim 属性。""" + assert hasattr(EmbeddingProvider, "embed") + assert hasattr(EmbeddingProvider, "dim") + + +class TestMockEmbeddingProvider: + """用 mock 测试 Protocol 契约。""" + + def test_embed_single_text(self): + from adapters.embedding import LocalEmbeddingProvider + + # 仅测试接口——实际初始化需要模型,这里先跳过 + # 真实测试需在 integration 中做 + pass + + def test_embed_returns_correct_shape(self): + """用手工 mock 验证契约。""" + + class FakeEmbed: + @property + def dim(self) -> int: + return 4 + + def embed(self, texts): + if isinstance(texts, str): + texts = [texts] + return np.random.randn(len(texts), 4).astype(np.float32) + + provider = FakeEmbed() + result = provider.embed(["你好", "世界"]) + assert result.shape == (2, 4) + assert isinstance(provider, EmbeddingProvider) +``` + +- [ ] **Step 2: 运行测试确认 FAIL** + +```bash +conda activate Video-Tree-TRM & pytest tests/unit/test_embedding_adapter.py -v 2>&1 | tail -10 +``` + +- [ ] **Step 3: 实现 `app/ports.py` 新增 EmbeddingProvider** + +```python +# app/ports.py — 完整重写(原文件仅一行 docstring) +"""应用层 Protocol 端口定义。""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +import numpy as np + + +@runtime_checkable +class EmbeddingProvider(Protocol): + """文本嵌入端口。""" + + @property + def dim(self) -> int: ... + + def embed(self, texts: str | list[str]) -> np.ndarray: ... +``` + +- [ ] **Step 4: 实现 `adapters/embedding.py`** + +从 `reference/video_tree_trm/embeddings.py` 迁移全部逻辑(191 行),改造: +- 类名改为 `LocalEmbeddingProvider` / `RemoteEmbeddingProvider` +- 日志用 loguru +- 配置参数通过构造函数传入(不读 config 文件) +- 保留 `embed()` 和 `embed_tensor()` 接口 +- 保留 L2 归一化逻辑 + +- [ ] **Step 5: 运行测试确认 PASS** + +```bash +conda activate Video-Tree-TRM & pytest tests/unit/test_embedding_adapter.py -v +``` + +- [ ] **Step 6: 提交** + +```bash +git add app/ports.py adapters/embedding.py tests/unit/test_embedding_adapter.py +git commit -m "feat(adapters): EmbeddingProvider Protocol + local/remote 双后端实现" +``` + +--- + +### Task 3: VLMProvider 最小可用适配器 + +**Files:** +- Create: `adapters/vlm.py` +- Test: `tests/unit/test_vlm_adapter.py` + +**说明:** 基于 `GovernedLLMClient` 的 VLM 包装器,将图片编码为 base64 嵌入 messages 中,通过已有的 `GovernedLLMClient.chat()` 发送。最小可用实现,满足 `VLMProvider` Protocol。 + +- [ ] **Step 1: 编写失败测试** + +测试 VLMProvider 的 Protocol 契约和 base64 图片编码逻辑。 + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `adapters/vlm.py`** + +关键实现: +- `GovernedVLMClient.__init__(governed_llm: GovernedLLMClient)` — 复用已有治理栈 +- `chat_with_images(messages, images)` — 将图片路径编码为 base64,构造 OpenAI vision API 格式的 messages,委托给 `governed_llm.chat()` +- 实现 `VLMProvider` Protocol + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add adapters/vlm.py tests/unit/test_vlm_adapter.py +git commit -m "feat(adapters): GovernedVLMClient — VLMProvider 最小可用实现" +``` + +--- + +### Task 4: 字幕模块 + +**Files:** +- Create: `app/tree/subtitle.py` +- Test: `tests/unit/test_subtitle.py` + +**说明:** SRT 解析 + 完整性检查 + 时间范围提取 + Voronoi 分配。从 TRM4 `enhance/merge.py` 的 `parse_srt()` + TRM3 `tools/generate_subtitles.py` 的 Voronoi 逻辑迁移。 + +- [ ] **Step 1: 编写失败测试** + +```python +# tests/unit/test_subtitle.py +"""字幕模块单元测试。""" + +from __future__ import annotations + +import pytest + +from app.tree.subtitle import ( + SRTEntry, + SubtitleReport, + parse_srt, + check_subtitle_completeness, + extract_subtitle_for_range, + assign_subtitles_voronoi, +) + + +_SAMPLE_SRT = """\ +1 +00:00:01,000 --> 00:00:03,500 +Hello world. + +2 +00:00:05,000 --> 00:00:08,000 +This is italic text. + +3 +00:00:10,000 --> 00:00:12,000 +Final line. +""" + + +class TestParseSrt: + def test_basic_parse(self, tmp_path): + srt_file = tmp_path / "test.srt" + srt_file.write_text(_SAMPLE_SRT, encoding="utf-8") + + entries = parse_srt(str(srt_file)) + assert len(entries) == 3 + assert entries[0] == SRTEntry(start=1.0, end=3.5, text="Hello world.") + assert entries[1].text == "This is italic text." # HTML 标签已剥离 + + def test_empty_srt(self, tmp_path): + srt_file = tmp_path / "empty.srt" + srt_file.write_text("", encoding="utf-8") + entries = parse_srt(str(srt_file)) + assert entries == [] + + def test_malformed_srt_skips_bad_blocks(self, tmp_path): + """格式损坏的 block 被跳过,不影响正常 block。""" + bad_srt = "garbage\n\n1\n00:00:01,000 --> 00:00:02,000\nGood line.\n" + srt_file = tmp_path / "bad.srt" + srt_file.write_text(bad_srt, encoding="utf-8") + entries = parse_srt(str(srt_file)) + assert len(entries) == 1 + assert entries[0].text == "Good line." + + +class TestCompletenessCheck: + def test_good_coverage(self): + entries = [ + SRTEntry(0.0, 5.0, "a"), + SRTEntry(5.0, 10.0, "b"), + ] + report = check_subtitle_completeness(entries, duration=10.0, min_coverage=0.5) + assert report.usable is True + assert report.coverage_ratio >= 0.5 + + def test_poor_coverage(self): + entries = [SRTEntry(0.0, 1.0, "short")] + report = check_subtitle_completeness(entries, duration=100.0, min_coverage=0.3) + assert report.usable is False + + def test_max_gap(self): + entries = [ + SRTEntry(0.0, 1.0, "a"), + SRTEntry(50.0, 51.0, "b"), + ] + report = check_subtitle_completeness(entries, duration=60.0) + assert report.max_gap_sec >= 49.0 + + +class TestExtractForRange: + def test_overlap(self): + entries = [ + SRTEntry(0.0, 5.0, "first"), + SRTEntry(4.0, 8.0, "second"), + SRTEntry(10.0, 12.0, "third"), + ] + text = extract_subtitle_for_range(entries, (3.0, 9.0)) + assert "first" in text + assert "second" in text + assert "third" not in text + + +class TestVoronoiAssign: + def test_assigns_to_l3_nodes(self): + from app.tree.index import ( + IndexMeta, TreeIndex, L1Node, L1Card, + L2Node, L2Card, L3Node, L3Card, + ) + + l3_0 = L3Node( + id="l1_0_l2_0_l3_0", + card=L3Card("desc0", [], [], [], "", {}), + timestamp=2.0, + ) + l3_1 = L3Node( + id="l1_0_l2_0_l3_1", + card=L3Card("desc1", [], [], [], "", {}), + timestamp=6.0, + ) + l2 = L2Node( + id="l1_0_l2_0", + card=L2Card("evt", [], [], [], [], "", None), + time_range=(0.0, 10.0), + children=[l3_0, l3_1], + ) + l1 = L1Node( + id="l1_0", + card=L1Card("scene", "", [], [], [], [], ""), + time_range=(0.0, 10.0), + children=[l2], + ) + index = TreeIndex( + metadata=IndexMeta("/test.mp4", "video"), + roots=[l1], + ) + + entries = [ + SRTEntry(1.0, 3.0, "hello"), + SRTEntry(5.0, 7.0, "world"), + ] + assign_subtitles_voronoi(index, entries) + + assert l3_0.subtitle is not None + assert "hello" in l3_0.subtitle + assert l3_1.subtitle is not None + assert "world" in l3_1.subtitle +``` + +- [ ] **Step 2: 运行测试确认 FAIL** + +```bash +conda activate Video-Tree-TRM & pytest tests/unit/test_subtitle.py -v 2>&1 | tail -10 +``` + +- [ ] **Step 3: 实现 `app/tree/subtitle.py`** + +从 TRM4 `core/tree/enhance/merge.py:31-84`(`parse_srt`, `extract_subtitle_window`)和 TRM3 `tools/generate_subtitles.py:439-547`(`compute_effective_ranges`, `assign_subtitles`)迁移。改造: +- `parse_srt()` → 返回 `list[SRTEntry]`(frozen dataclass) +- 新增 `check_subtitle_completeness()` → 返回 `SubtitleReport` +- 新增 `extract_subtitle_for_range()` — 按时间重叠提取 +- `assign_subtitles_voronoi()` — 适配 TreeIndex 嵌套结构(遍历 L1→L2→L3),使用 Voronoi 中点策略 + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add app/tree/subtitle.py tests/unit/test_subtitle.py +git commit -m "feat(tree): 字幕模块 — SRT 解析 + 完整性检查 + Voronoi 分配" +``` + +--- + +### Task 5: 质量校验 + +**Files:** +- Create: `app/tree/verify.py` +- Test: `tests/unit/test_verify.py` + +**说明:** 从 TRM4 `core/tree/enhance/verify.py` 迁移交叉校验逻辑,适配 TreeIndex + Card 体系。 + +- [ ] **Step 1: 编写失败测试** + +覆盖: +- `_normalize()` 归一化 +- `fuzzy_match()` 模糊子串匹配 +- `verify_tree()` L2 `entities` 校验(有出处保留、无出处删除) +- `verify_tree()` L2 `visible_text` 校验(每条须在 L3 visible_text 中有出处) +- `verify_tree()` L1 `visible_text` 校验 +- `verify_tree()` L1 `key_entities` 校验(交叉验证 L2/L3 文本语料) +- `verify_tree()` frozen Card 替换(创建新 Card 实例) +- `VerifyStats` 统计(各字段保留/删除数量) + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `app/tree/verify.py`** + +从 TRM4 `core/tree/enhance/verify.py` 迁移: +- `_normalize()`, `fuzzy_match()` — 保持不变 +- `_collect_l3_text()` — 改为从 `L2Node.children` 遍历 `L3Node` +- `verify_tree(index: TreeIndex) -> VerifyStats` — 遍历 TreeIndex,校验 L2.card.entities、L2.card.visible_text、L1.card.visible_text、L1.card.key_entities +- 校验时创建新 Card 实例替换(因为 Card 是 frozen) +- 返回 `VerifyStats` dataclass + +**注意**:TRM4 verify 还处理 `named_entities`、`quantitative_facts`、`causal_links`——这些字段在 6 字段 Card 中不存在,跳过。 + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add app/tree/verify.py tests/unit/test_verify.py +git commit -m "feat(tree): 质量校验 — 交叉验证 entities/visible_text" +``` + +--- + +### Task 6: VideoTreeBuilder [保真 #1 #2 #3] + +**Files:** +- Create: `app/tree/video_builder.py` +- Test: `tests/unit/test_video_builder.py` + +**说明:** 从 `reference/video_tree_trm/video_tree_builder.py`(994 行)迁移。核心算法保真:L2 轴心、VLM 批量 + JSON fallback、断点续跑。 + +**保真校验检查点:** +- [ ] 比对 `_build_async()` 的 L2→L3 链式并发结构(算法 #1) +- [ ] 比对 `_call_vlm_batch_async()` 的批量调用 + fallback 逻辑(算法 #2) +- [ ] 比对 `_save_progress()` / `_load_progress()` / `_cleanup_intermediate_and_progress()` 的断点机制(算法 #3) + +- [ ] **Step 1: 编写失败测试** + +用 mock VLMProvider/LLMProvider 测试: +- `_segment_video()` 时间切分 +- `_get_l2_clips()` L2 clip 划分 +- `_parse_json_descriptions()` JSON 解析 + fallback +- `build()` 完整流程(mock VLM 返回固定 JSON) +- 断点续跑(模拟中断 + 恢复) + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `app/tree/video_builder.py`** + +逐行参考 `reference/video_tree_trm/video_tree_builder.py` 迁移,关键改造: + +1. **依赖注入**:`__init__(vlm: VLMProvider, llm: LLMProvider, config: TreeConfig)` — 不再直接使用 `LLMClient` +2. **VLM 调用**:`await self.vlm.chat_with_images(messages, images)` → 提取 `.content` +3. **LLM 调用**:`await self.llm.chat(messages)` → 提取 `.content` +4. **输出结构化 Card**:VLM prompt 返回 JSON 对象数组,解析为 `L3Card`;解析失败走逐帧 fallback +5. **L2 代表帧复用**:先提取所有 L3 帧,L2 从中采样 +6. **字幕注入**:`build(video_path, srt_entries=None)` 可选参数 +7. **断点续跑**:保持 reference 的 `progress.json` + L1 中间 JSON 机制 +8. **清理**:`_cleanup_intermediate_and_progress()` 在最终 JSON 成功后调用 + +**VLM Prompt 增量修改**(不简化原有内容): + +L3 批量 prompt 在 reference 原文基础上追加结构化输出格式: +```python +_L3_VIDEO_PROMPT = ( + '该片段的整体内容: "{l2_description}"\n' + "以下是该片段中连续的 {n} 帧画面。\n" + "对每帧用一到两句话描述其具体画面内容。\n" + "重点关注: 动作、物体变化、文字信息、人物表情。\n" + "不要重复片段整体描述,聚焦每帧的区分性信息。\n" + "{subtitle_block}" + "对每帧返回一个 JSON 对象,包含以下字段:\n" + "- frame_summary: 1-2句画面描述\n" + "- visible_entities: 可见实体列表\n" + "- ongoing_actions: 正在进行的动作列表\n" + "- visible_text: 画面中可见文字列表\n" + "- spatial_layout: 画面空间布局\n" + '- visual_attributes: {{"lighting": "...", "dominant_colors": [...], "camera_angle": "..."}}\n' + '只返回 JSON 数组,格式: [{{...}}, {{...}}, ...],不要其他内容。' +) +``` + +类似地修改 L2、L1 prompt,追加结构化输出要求。 + +- [ ] **Step 4: 运行测试确认 PASS** + +```bash +conda activate Video-Tree-TRM & pytest tests/unit/test_video_builder.py -v +``` + +- [ ] **Step 5: 保真校验 — 逐一比对参考代码** + +对照 reference 检查三项算法核心逻辑未被简化: +1. `_build_async()` 中 `asyncio.gather(*[_chain(j, clip) for j, clip in enumerate(clips)])` 的链式并发 +2. `_call_vlm_batch_async()` 中 `_L3_BATCH_SIZE=5` 分批 + `_parse_json_descriptions()` 校验 + 逐帧 fallback +3. `_save_progress()` / `_load_progress()` / `_has_l1_intermediate()` / `_cleanup_intermediate_and_progress()` 的完整断点机制 + +- [ ] **Step 6: 提交** + +```bash +git add app/tree/video_builder.py tests/unit/test_video_builder.py +git commit -m "feat(tree): VideoTreeBuilder — L2轴心建树(算法#1) + VLM批量+fallback(算法#2) + 断点续跑(算法#3)" +``` + +--- + +### Task 7: TreeEnvironment [保真 #12 变更] + +**Files:** +- Create: `app/tree/environment.py` +- Test: `tests/unit/test_tree_environment.py` + +**说明:** 从 TRM4 `core/tree/environment.py`(451 行)迁移,改为基于 TreeIndex。算法 #12 变更:分块 embedding → 单节点 embedding。保留祖先去重 + 锚定验证。 + +- [ ] **Step 1: 编写失败测试** + +覆盖: +- `view_node()` 返回卡片内容 + 子节点概览 +- `view_node(anchor=True)` 锚定标记 +- `search_similar()` 余弦相似度 + 祖先去重 +- `get_subtitle()` 字幕查询 +- `resolve_frame_paths()` 帧路径解析 +- ID 索引映射(O(1) 查找) + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `app/tree/environment.py`** + +关键逻辑: + +1. **构造函数**:接收 `TreeIndex`,构建 `_id_to_node: dict[str, L1Node | L2Node | L3Node]` 节点引用映射(O(1) 查找任意层级节点) +2. **`view_node(node_id, anchor=False)`**: + - 通过 `_id_to_path` O(1) 定位节点 + - 格式化卡片字段为文本 + - `anchor=True` 时为每个卡片字段行添加 `[c1]`、`[s1]` 锚标(从 TRM4 `_node_anchored_text()` 迁移) + - 列出子节点概览(ID + 时间范围 + 主描述前 120 字符) +3. **`search_similar(query, top_k, embed_fn)`**: + - 用 `embed_fn(query)` 获取 query embedding + - 与所有节点 embedding 计算余弦相似度 + - 祖先去重(从 TRM4 迁移:`any(s.startswith(nid + "_") for s in seen_prefixes)`) + - 返回 top_k 结果列表 +4. **`get_subtitle(node_id)`** / **`resolve_frame_paths(node_ids)`**:从 TRM4 迁移,适配 TreeIndex + +**算法 #12 变更记录**:分块 embedding(4000 字符分块,每块独立 embedding)改为 per-node embedding(基于各节点 embedding 文本源:L3.description、L2.description、L1.summary)。理由:TreeIndex 已有 per-node embedding,分块是 flat-dict 时代的替代方案。Commit message 需标注"算法 #12 变更"。 + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add app/tree/environment.py tests/unit/test_tree_environment.py +git commit -m "feat(tree): TreeEnvironment — 运行时数据访问 + 语义搜索(算法#12变更:分块→单节点embedding)" +``` + +--- + +### Task 8: 修复模式 — 检测器 + +**Files:** +- Create: `app/tree/repair/__init__.py` +- Create: `app/tree/repair/detector.py` +- Test: `tests/unit/test_repair_detector.py` + +- [ ] **Step 1: 编写失败测试** + +覆盖: +- L3 必填字段为空检测 +- L3 帧文件缺失检测 +- L2 无子节点检测 +- L2 时间空洞检测 +- `NodeIssue` dataclass 结构 + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `app/tree/repair/detector.py`** + +```python +@dataclass(frozen=True) +class NodeIssue: + node_id: str + level: int + issue_type: str # "empty_field" | "missing_frame" | "no_children" | "time_gap" + details: str + +def detect_issues(index: TreeIndex, frames_dir: Path | None = None) -> list[NodeIssue]: + """扫描树,返回所有问题节点列表。""" +``` + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add app/tree/repair/ tests/unit/test_repair_detector.py +git commit -m "feat(tree/repair): 缺失/低质量节点检测器" +``` + +--- + +### Task 9: 修复模式 — 重生成器 + +**Files:** +- Create: `app/tree/repair/regenerator.py` +- Test: `tests/unit/test_repair_regenerator.py` + +- [ ] **Step 1: 编写失败测试** + +用 mock VLM/LLM 测试: +- L3 节点修复(VLM 重新描述帧) +- L2 向上级联(LLM 从 L3 聚合) +- L1 向上级联(LLM 从 L2 聚合) +- `RepairStats` 统计 + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `app/tree/repair/regenerator.py`** + +```python +@dataclass(frozen=True) +class RepairStats: + l3_repaired: int + l2_regenerated: int + l1_regenerated: int + +async def repair_tree( + index: TreeIndex, + issues: list[NodeIssue], + vlm: VLMProvider, + llm: LLMProvider, + frames_dir: Path, + srt_entries: list[SRTEntry] | None = None, +) -> RepairStats: + """修复有问题的节点,底向上级联。""" +``` + +底向上级联逻辑: +1. 收集需修复的 L3 节点 → VLM 重新描述(复用现有 L2 描述作上下文) +2. 收集受影响的 L2 节点(其 L3 children 被修复的)→ LLM 从 L3 聚合 +3. 收集受影响的 L1 节点 → LLM 从 L2 聚合 + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add app/tree/repair/regenerator.py tests/unit/test_repair_regenerator.py +git commit -m "feat(tree/repair): VLM 重生成 + 底向上级联" +``` + +--- + +### Task 10: 修复模式 — Q&A 反向补全 + +**Files:** +- Create: `app/tree/repair/supplement.py` +- Test: `tests/unit/test_repair_supplement.py` + +**说明:** 从 TRM4 `core/tree/enhance/supplement.py`(401 行)迁移,适配 TreeIndex + Card。 + +- [ ] **Step 1: 编写失败测试** + +覆盖: +- `deduplicate_field()` 去重 +- `_inject_one()` 单字段注入 +- `apply_injections()` 批量注入 +- 类别白名单过滤 + +- [ ] **Step 2: 运行测试确认 FAIL** + +- [ ] **Step 3: 实现 `app/tree/repair/supplement.py`** + +从 TRM4 迁移: +- `_ALLOWED_CATEGORIES` 白名单 +- `deduplicate_field()` — 保持不变 +- `_inject_one()` — 适配 Card frozen dataclass(创建新 Card 实例) +- `apply_injections()` — 适配 TreeIndex +- `analyze_question()` — LLM 调用分析缺失事实 +- `supplement_tree()` — 主入口,遍历 questions,收集注入指令,执行注入 + +- [ ] **Step 4: 运行测试确认 PASS** + +- [ ] **Step 5: 提交** + +```bash +git add app/tree/repair/supplement.py tests/unit/test_repair_supplement.py +git commit -m "feat(tree/repair): Q&A 反向补全 — 从 TRM4 supplement 迁移" +``` + +--- + +### Task 11: 迁移工具 + +**Files:** +- Create: `tools/migrate_from_trm4.sh` +- Create: `tools/convert_flat_to_treeindex.py` + +**说明:** 一次性迁移脚本。从 TRM4.zip 解压原始树 → 格式转换 → 拷贝资产 → 验收。 + +- [ ] **Step 1: 实现 `tools/convert_flat_to_treeindex.py`** + +```python +"""一次性格式转换:TRM4 flat tree.json → TreeIndex JSON。 + +用法: python tools/convert_flat_to_treeindex.py + +app/core/adapters 不 import 此脚本。迁移完成后归档至 tools/archived/。 +""" +``` + +核心逻辑: +- 读取 flat tree.json(`{nodes: {id: {level, card, ...}}}`) +- 按 level 分组:L1 → L2 → L3 +- 按 parent_id/children_ids 重建嵌套关系 +- card dict → L1Card/L2Card/L3Card dataclass +- 组装 TreeIndex,调用 `save_json()` + +- [ ] **Step 2: 实现 `tools/migrate_from_trm4.sh`** + +```bash +#!/usr/bin/env bash +# 从 TRM4.zip 迁移资产到 TRM5 +# 用法: bash tools/migrate_from_trm4.sh /path/to/Video-Tree-TRM4.zip + +set -euo pipefail +# 1. 解压到临时目录 +# 2. 拷贝帧文件(rsync --ignore-existing) +# 3. 拷贝 SRT 字幕 +# 4. 拷贝视频压缩包 +# 5. 拷贝问题 JSON +# 6. 运行格式转换 +# 7. 验收:检查 300 视频 + tree.json + frames 完整性 +# 8. 输出报告 +``` + +验收逻辑(§9.3): +- 检查 300 个视频目录 +- 每个 tree.json 可反序列化为 TreeIndex +- 每个 L3 的 frame_path 对应文件存在 +- SRT 文件数 ≥ 290 +- 每个视频有 question JSON +- 缺失资产报告 + 非零缺失 exit code 1 + +- [ ] **Step 3: 手动测试迁移(在少量视频上验证)** + +```bash +# 仅解压一个视频测试转换 +unzip -o -j /home/iomgaa/Projects/Video-Tree-TRM4.zip \ + "Video-Tree-TRM4/store/videos/wNpA02SNgUg/*" \ + -d /tmp/trm4_test/store/videos/wNpA02SNgUg/ + +conda activate Video-Tree-TRM & python tools/convert_flat_to_treeindex.py \ + /tmp/trm4_test/store/videos/ store/videos/ --dry-run +``` + +- [ ] **Step 4: 提交** + +```bash +git add tools/migrate_from_trm4.sh tools/convert_flat_to_treeindex.py +git commit -m "feat(tools): TRM4→TRM5 迁移工具 — 格式转换 + 资产拷贝 + 验收" +``` + +--- + +### Task 12: 集成测试 + +**Files:** +- Create: `tests/integration/test_tree_build_e2e.py` + +**说明:** 端到端测试:mock VLM/LLM → VideoTreeBuilder 建树 → verify → subtitle 注入 → TreeEnvironment 查询 → 序列化 roundtrip。 + +- [ ] **Step 1: 编写集成测试** + +```python +# tests/integration/test_tree_build_e2e.py +"""建树模块端到端集成测试。 + +使用 mock VLM/LLM 测试完整建树流程: +build → verify → subtitle → environment → serialize +""" + +import asyncio +import json +import pytest +import numpy as np + +from app.tree.index import TreeIndex +from app.tree.verify import verify_tree +from app.tree.subtitle import SRTEntry, assign_subtitles_voronoi +from app.tree.environment import TreeEnvironment + + +class MockVLM: + """返回固定结构化 JSON 的 mock VLM。""" + async def chat_with_images(self, messages, images, **kwargs): + from core.types import LLMResponse + n = len(images) + cards = [ + { + "frame_summary": f"帧{i}描述", + "visible_entities": [f"实体{i}"], + "ongoing_actions": [f"动作{i}"], + "visible_text": [], + "spatial_layout": "居中", + "visual_attributes": {"lighting": "明亮"}, + } + for i in range(n) + ] + return LLMResponse( + content=json.dumps(cards, ensure_ascii=False), + thinking="", model="mock", provider="mock", + prompt_tokens=0, completion_tokens=0, + latency_ms=0, ttft_ms=None, max_inter_token_ms=None, + cache_hit=False, call_id="mock", + ) + + +class MockLLM: + """返回固定文本的 mock LLM。""" + async def chat(self, messages, **kwargs): + from core.types import LLMResponse + return LLMResponse( + content='{"event_description":"事件","entities":[],"actions":[],"action_subjects":[],"visible_text":[],"spatial_relations":"","state_changes":null}', + thinking="", model="mock", provider="mock", + prompt_tokens=0, completion_tokens=0, + latency_ms=0, ttft_ms=None, max_inter_token_ms=None, + cache_hit=False, call_id="mock", + ) + + +class TestTreeBuildE2E: + def test_verify_then_environment(self, tmp_path): + """构造最小树 → verify → subtitle → environment 查询。""" + from app.tree.index import ( + IndexMeta, TreeIndex, L1Node, L1Card, + L2Node, L2Card, L3Node, L3Card, + ) + from app.tree.verify import verify_tree + from app.tree.subtitle import SRTEntry, assign_subtitles_voronoi + from app.tree.environment import TreeEnvironment + + l3 = L3Node( + id="l1_0_l2_0_l3_0", + card=L3Card("帧描述", ["真实实体"], ["动作"], ["文字"], "居中", {}), + timestamp=2.0, + frame_path="frames/l1_0_l2_0_l3_0.jpg", + ) + l2 = L2Node( + id="l1_0_l2_0", + card=L2Card("事件", ["真实实体", "幻觉实体"], [], [], ["文字"], "", None), + time_range=(0.0, 10.0), + children=[l3], + ) + l1 = L1Node( + id="l1_0", + card=L1Card("场景", "", ["真实实体"], [], [], ["文字"], ""), + time_range=(0.0, 10.0), + children=[l2], + ) + index = TreeIndex(metadata=IndexMeta("/test.mp4", "video"), roots=[l1]) + + # verify 应删除 L2 中无 L3 出处的"幻觉实体" + stats = verify_tree(index) + assert "幻觉实体" not in index.roots[0].children[0].card.entities + + # subtitle 注入 + entries = [SRTEntry(1.0, 3.0, "hello")] + assign_subtitles_voronoi(index, entries) + assert l3.subtitle is not None + + # environment 查询 + env = TreeEnvironment(index) + result = env.view_node("l1_0_l2_0_l3_0") + assert "帧描述" in result + + # 序列化 roundtrip + path = tmp_path / "tree.json" + index.save_json(str(path)) + loaded = TreeIndex.load_json(str(path)) + assert len(loaded.roots) == 1 + assert loaded.roots[0].children[0].children[0].subtitle is not None +``` + +- [ ] **Step 2: 运行集成测试** + +```bash +conda activate Video-Tree-TRM & pytest tests/integration/test_tree_build_e2e.py -v +``` + +- [ ] **Step 3: 提交** + +```bash +git add tests/integration/test_tree_build_e2e.py +git commit -m "test(tree): 建树模块端到端集成测试" +``` + +--- + +### Task 13: 最终 lint + 覆盖率 + +- [ ] **Step 1: 全量 lint** + +```bash +conda activate Video-Tree-TRM & ruff check app/tree/ adapters/embedding.py adapters/vlm.py --fix +conda activate Video-Tree-TRM & ruff format app/tree/ adapters/embedding.py adapters/vlm.py +``` + +- [ ] **Step 2: 运行全部测试 + 覆盖率** + +```bash +conda activate Video-Tree-TRM & pytest tests/ --cov=app/tree --cov=adapters/embedding --cov=adapters/vlm --cov-report=term-missing -v +``` + +预期:覆盖率 ≥ 80% + +- [ ] **Step 3: 提交** + +```bash +git add -A +git commit -m "chore: lint + 覆盖率达标" +``` + +--- + +## 核心算法保真校验结果 + +本计划涉及 4 项核心算法: + +| # | 算法 | Task | 保真状态 | +|---|------|------|---------| +| 1 | L2 轴心建树策略 | Task 6 | 保真——逐行迁移 `_build_async()` 链式并发结构 | +| 2 | VLM 批量帧描述 + JSON fallback | Task 6 | 保真——`_L3_BATCH_SIZE=5`、`_parse_json_descriptions()` + 逐帧 fallback | +| 3 | 断点续跑机制 | Task 6 | 保真——`progress.json` + L1 中间 JSON + cleanup | +| 12 | 树环境语义搜索 | Task 7 | **变更**——分块 embedding → 单节点 embedding;祖先去重 + 锚定验证保留 | + +算法 #4-#11、#13 不在本计划范围内(属于 harness / evolution / retriever 模块)。 diff --git a/research-wiki/plans/tree-module-vertical-slice.md b/research-wiki/plans/tree-module-vertical-slice.md new file mode 100644 index 0000000..7f7f2ed --- /dev/null +++ b/research-wiki/plans/tree-module-vertical-slice.md @@ -0,0 +1,9 @@ +--- +type: plan +node_id: plan:tree-module-vertical-slice +title: 建树模块竖切实现计划 +date: 2026-07-07 +--- + +# 建树模块竖切实现计划 +