Files
Video-Tree-TRM5/research-wiki/plans/2026-07-07-tree-module-vertical-slice.md
T

41 KiB
Raw Blame History

建树模块竖切实现计划

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.pyapp/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 VideoTreeBuilderasyncio, 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 的失败测试
# 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
conda activate Video-Tree-TRM & pytest tests/unit/test_tree_index.py -v 2>&1 | tail -20

预期:所有测试 FAILModuleNotFoundError: No module named 'app.tree.index'

  • Step 3: 实现 app/tree/index.py

reference/video_tree_trm/tree_index.py 迁移,关键改造:

  • 新增 L3CardL2CardL1Card 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

conda activate Video-Tree-TRM & pytest tests/unit/test_tree_index.py -v

预期:全部 PASS

  • Step 5: lint 检查
conda activate Video-Tree-TRM & ruff check app/tree/index.py --fix && ruff format app/tree/index.py
  • Step 6: 提交
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.yamltree: 段。提供 from_dict() 工厂方法。

  • Step 1: 创建 app/tree/config.py
"""建树模块配置。"""

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: 提交
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: 编写失败测试
# 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
conda activate Video-Tree-TRM & pytest tests/unit/test_embedding_adapter.py -v 2>&1 | tail -10
  • Step 3: 实现 app/ports.py 新增 EmbeddingProvider
# 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

conda activate Video-Tree-TRM & pytest tests/unit/test_embedding_adapter.py -v
  • Step 6: 提交
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: 提交

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.pyparse_srt() + TRM3 tools/generate_subtitles.py 的 Voronoi 逻辑迁移。

  • Step 1: 编写失败测试
# 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
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-84parse_srt, extract_subtitle_window)和 TRM3 tools/generate_subtitles.py:439-547compute_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: 提交

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_entitiesquantitative_factscausal_links——这些字段在 6 字段 Card 中不存在,跳过。

  • Step 4: 运行测试确认 PASS

  • Step 5: 提交

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. 输出结构化 CardVLM 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 原文基础上追加结构化输出格式:

_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
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: 提交
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 变更记录:分块 embedding4000 字符分块,每块独立 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: 提交

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

@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: 提交

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

@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: 提交

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.py401 行)迁移,适配 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: 提交

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
"""一次性格式转换: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

#!/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: 手动测试迁移(在少量视频上验证)

# 仅解压一个视频测试转换
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: 提交
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: 编写集成测试
# 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: 运行集成测试
conda activate Video-Tree-TRM & pytest tests/integration/test_tree_build_e2e.py -v
  • Step 3: 提交
git add tests/integration/test_tree_build_e2e.py
git commit -m "test(tree): 建树模块端到端集成测试"

Task 13: 最终 lint + 覆盖率

  • Step 1: 全量 lint
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: 运行全部测试 + 覆盖率
conda activate Video-Tree-TRM & pytest tests/ --cov=app/tree --cov=adapters/embedding --cov=adapters/vlm --cov-report=term-missing -v

预期:覆盖率 ≥ 80%

  • Step 3: 提交
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 模块)。