# 建树模块竖切实现计划

> **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
<i>This is italic</i> 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 <src_dir> <dst_dir>

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 模块）。
