1263 lines
41 KiB
Markdown
1263 lines
41 KiB
Markdown
# 建树模块竖切实现计划
|
||
|
||
> **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 模块)。
|