@@ -0,0 +1,880 @@
# 建树批量并行入口(Spec-2) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal: ** 新增多视频批量并行建树入口(视频级 16 并发 + 全局共享 API 信号量 16),单视频建树行为零变化。
**Architecture: ** `VideoTreeBuilder` 公开 `build_async()` (消除事件循环嵌套)并支持外部注入 API Semaphore; `tools/build_trees.py` 复刻 `repair_trees.py` 的编排惯例(视频级 Semaphore + gather + progress.json 断点续跑 + 熔断阈值缩放);`scripts/build_trees.sh` 自包含实验入口。设计文档:`research-wiki/designs/2026-07-11-batch-tree-build-design.md` 。
**Tech Stack: ** Python 3.11 asyncio / pytest + pytest-asyncio / loguru / PyYAML。
**验证策略说明 ** :仓库 data/ 下当前无视频文件(`find data -name "*.mp4"` 为空),设计 §6 的"3-4 短视频小批量集成"改为**桩编排集成测试**( monkeypatch VideoTreeBuilder,验证并发上限、进度跳过、信号量共享、中断恢复),真实视频烟测流程写入 sh 脚本头部注释,首次实际建树时执行。
---
### Task 1: VideoTreeBuilder 异步入口公开 + API Semaphore 注入
**Files: **
- Modify: `app/tree/video_builder.py` ( `__init__` 172-192 行;`build()` 284-299 行;`_build_async` 改名 `build_async` 305 行起;`vlm_sem = asyncio.Semaphore(...)` 357 行)
- Test: `tests/unit/test_video_builder.py` (追加测试类)
- [ ] **Step 1: 写失败测试 **
`tests/unit/test_video_builder.py` 末尾追加(fixture `mock_vlm` /`mock_llm` /`tree_config` 已存在于该文件 159-191 行):
``` python
# ── Semaphore 注入与异步入口(Spec-2)─────────────────────────
class TestApiSemaphoreInjection :
""" API Semaphore 注入与 build_async 公开入口。 """
def test_injected_semaphore_stored (
self ,
mock_vlm : MockVLMProvider ,
mock_llm : MockLLMProvider ,
tree_config : TreeConfig ,
) - > None :
""" 构造器注入的 Semaphore 应被保存供 build_async 使用。 """
sem = asyncio . Semaphore ( 3 )
builder = VideoTreeBuilder (
vlm = mock_vlm , llm = mock_llm , config = tree_config , api_semaphore = sem
)
assert builder . _api_semaphore is sem
def test_default_no_injection (
self ,
mock_vlm : MockVLMProvider ,
mock_llm : MockLLMProvider ,
tree_config : TreeConfig ,
) - > None :
""" 未注入时属性为 None(build_async 内部自建,单视频行为零变化)。 """
builder = VideoTreeBuilder ( vlm = mock_vlm , llm = mock_llm , config = tree_config )
assert builder . _api_semaphore is None
def test_build_async_is_public (
self ,
mock_vlm : MockVLMProvider ,
mock_llm : MockLLMProvider ,
tree_config : TreeConfig ,
) - > None :
""" build_async 必须是公开协程方法(供批量编排在事件循环内调用)。 """
builder = VideoTreeBuilder ( vlm = mock_vlm , llm = mock_llm , config = tree_config )
assert hasattr ( builder , " build_async " )
assert asyncio . iscoroutinefunction ( builder . build_async )
```
(文件顶部如无 `import asyncio` 则补上。)
- [ ] **Step 2: 运行测试确认失败 **
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_video_builder.py::TestApiSemaphoreInjection -v`
Expected: FAIL——`api_semaphore` 是未知构造参数(TypeError),`build_async` 不存在。
- [ ] **Step 3: 实现 **
三处修改:
其一,`__init__` 增加 keyword-only 可选参数(docstring 同步补充):
``` python
def __init__ (
self ,
vlm : VLMProvider ,
llm : LLMProvider ,
config : TreeConfig ,
* ,
api_semaphore : asyncio . Semaphore | None = None ,
) - > None :
```
赋值区追加:
``` python
self . _api_semaphore = api_semaphore
```
docstring 参数段追加一行:`api_semaphore: 外部注入的全局 VLM/LLM 并发信号量(批量建树时跨视频共享);None 时 build_async 内部按 config.concurrency 自建,单视频行为零变化。`
其二,`_build_async` 改名为公开 `build_async` (方法体不动,docstring 首行相应更新),`build()` 同步壳改为:
``` python
return asyncio . run ( self . build_async ( video_path , srt_entries ) )
```
同时更新模块 docstring( 16-21 行)与类 docstring( 158-163 行)中的 `_build_async` 字样为 `build_async` 。
其三,`build_async` 内 357 行的信号量创建改为:
``` python
# 创建 VLM/LLM 并发控制信号量(外部注入时跨视频全局共享,Spec-2)
vlm_sem = (
self . _api_semaphore
if self . _api_semaphore is not None
else asyncio . Semaphore ( self . _config . concurrency )
)
```
- [ ] **Step 4: 运行测试确认通过 **
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_video_builder.py -v`
Expected: 全部 PASS(既有测试 + 新增 3 个,确认改名无回归——若有既有测试直接引用 `_build_async` ,同步改为 `build_async` )。
- [ ] **Step 5: Commit **
``` bash
git add app/tree/video_builder.py tests/unit/test_video_builder.py
git commit -m "feat(tree): expose build_async and accept injected API semaphore"
```
( commit body 标注:核心算法 #1/ #2/ #3 不变——仅入口封装与信号量来源切换,构建逻辑零改动。)
---
### Task 2: tools/build_trees.py 辅助纯函数
**Files: **
- Create: `tools/build_trees.py` (本任务只写模块骨架 + 纯函数;编排在 Task 3)
- Test: `tests/unit/test_build_trees.py` (新建)
- [ ] **Step 1: 写失败测试 **
新建 `tests/unit/test_build_trees.py` :
``` python
""" tools/build_trees.py 单元测试。
覆盖纯函数(完整性校验、待建清单发现、SRT 查找)与编排集成(Task 3 追加)。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path ( __file__ ) . resolve ( ) . parent . parent . parent
if str ( PROJECT_ROOT ) not in sys . path :
sys . path . insert ( 0 , str ( PROJECT_ROOT ) )
from app . tree . index import IndexMeta , L1Card , L1Node , TreeIndex
from tools . build_trees import _discover_pending , _find_srt_entries , _tree_is_complete
def _write_valid_tree ( tree_path : Path ) - > None :
""" 写入一棵最小合法树。 """
l1 = L1Node (
id = " vid_L1_000 " ,
card = L1Card ( " 场景 " , " 室内 " , [ " 实体 " ] , [ " 动作 " ] , [ " 关键词 " ] , [ ] , " 线性 " ) ,
time_range = ( 0.0 , 10.0 ) ,
children = [ ] ,
)
index = TreeIndex ( metadata = IndexMeta ( " /v.mp4 " , " video " ) , roots = [ l1 ] )
tree_path . parent . mkdir ( parents = True , exist_ok = True )
index . save_json ( str ( tree_path ) )
class TestTreeIsComplete :
""" _tree_is_complete 测试。 """
def test_valid_tree ( self , tmp_path : Path ) - > None :
""" 合法 tree.json 判定完整。 """
tree_path = tmp_path / " vid " / " tree.json "
_write_valid_tree ( tree_path )
assert _tree_is_complete ( tree_path ) is True
def test_missing_file ( self , tmp_path : Path ) - > None :
""" 文件不存在判定不完整。 """
assert _tree_is_complete ( tmp_path / " nope " / " tree.json " ) is False
def test_corrupt_json ( self , tmp_path : Path ) - > None :
""" 损坏 JSON 判定不完整(不抛异常)。 """
p = tmp_path / " vid " / " tree.json "
p . parent . mkdir ( parents = True )
p . write_text ( " { broken " , encoding = " utf-8 " )
assert _tree_is_complete ( p ) is False
class TestDiscoverPending :
""" _discover_pending 测试。 """
def _touch_videos ( self , videos_dir : Path , names : list [ str ] ) - > None :
videos_dir . mkdir ( parents = True , exist_ok = True )
for n in names :
( videos_dir / n ) . write_bytes ( b " " )
def test_all_pending_when_fresh ( self , tmp_path : Path ) - > None :
""" 无进度无产物时全部待建,按名排序。 """
videos = tmp_path / " videos "
self . _touch_videos ( videos , [ " b.mp4 " , " a.mkv " , " c.txt " ] )
pending = _discover_pending ( videos , tmp_path / " out " , set ( ) )
assert [ p . name for p in pending ] == [ " a.mkv " , " b.mp4 " ] # 非视频扩展名被忽略
def test_skips_finished_and_complete ( self , tmp_path : Path ) - > None :
""" progress 已记录或 tree.json 完整的视频被跳过。 """
videos = tmp_path / " videos "
out = tmp_path / " out "
self . _touch_videos ( videos , [ " a.mp4 " , " b.mp4 " , " c.mp4 " ] )
_write_valid_tree ( out / " b " / " tree.json " ) # b 已有完整树
pending = _discover_pending ( videos , out , { " a " } ) # a 在 progress 中
assert [ p . name for p in pending ] == [ " c.mp4 " ]
def test_incomplete_tree_not_skipped ( self , tmp_path : Path ) - > None :
""" tree.json 损坏的视频仍待建(重建覆盖)。 """
videos = tmp_path / " videos "
out = tmp_path / " out "
self . _touch_videos ( videos , [ " a.mp4 " ] )
( out / " a " ) . mkdir ( parents = True )
( out / " a " / " tree.json " ) . write_text ( " { broken " , encoding = " utf-8 " )
pending = _discover_pending ( videos , out , set ( ) )
assert [ p . name for p in pending ] == [ " a.mp4 " ]
class TestFindSrtEntries :
""" _find_srt_entries 测试。 """
def test_found ( self , tmp_path : Path ) - > None :
""" 同名 .srt 存在时解析返回条目。 """
srt = tmp_path / " vid.srt "
srt . write_text (
" 1 \n 00:00:01,000 --> 00:00:03,000 \n hello world \n \n " ,
encoding = " utf-8 " ,
)
entries = _find_srt_entries ( tmp_path / " vid.mp4 " , tmp_path )
assert entries is not None
assert len ( entries ) == 1
def test_missing_returns_none ( self , tmp_path : Path ) - > None :
""" 无同名 .srt 返回 None。 """
assert _find_srt_entries ( tmp_path / " vid.mp4 " , tmp_path ) is None
```
- [ ] **Step 2: 运行确认失败 **
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_build_trees.py -v`
Expected: FAIL( ModuleNotFoundError: tools.build_trees)。
- [ ] **Step 3: 实现模块骨架 + 纯函数 **
新建 `tools/build_trees.py` :
``` python
#!/usr/bin/env python3
""" 批量并行建树入口:多视频并发构建三层 TreeIndex。
并发模型(Spec-2) :
视频级 Semaphore(video_concurrency) + gather —— 复刻 repair_trees.py 惯例;
全局共享一个 API Semaphore(api_concurrency) 注入所有 VideoTreeBuilder,
端点压力与单视频建树完全一致,吞吐提升来自非 API 阶段跨视频重叠。
用法:
conda activate Video-Tree-TRM
python tools/build_trees.py --videos-dir <dir> [--out-dir store/videos]
[--srt-dir <dir>] [--video-concurrency 16] [--limit 0]
api_concurrency 为工程配置,从 .env 读取 TREE_BUILD_API_CONCURRENCY(默认 16)。
app/core/adapters 不 import 此脚本。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path ( __file__ ) . resolve ( ) . parent . parent
sys . path . insert ( 0 , str ( PROJECT_ROOT ) )
from dotenv import load_dotenv
from loguru import logger
load_dotenv ( PROJECT_ROOT / " .env " )
import yaml
from app . tree . config import TreeConfig
from app . tree . index import TreeIndex
from app . tree . subtitle import parse_srt
from app . tree . video_builder import VideoTreeBuilder
# ---------------------------------------------------------------------------
# 日志配置:不缓存,立即输出
# ---------------------------------------------------------------------------
logger . remove ( )
logger . add (
sys . stderr ,
format = " { time:HH:mm:ss} | {level:<7} | {message} " ,
level = " DEBUG " ,
colorize = True ,
)
logger . add (
PROJECT_ROOT / " logs " / " build_trees.log " ,
format = " { time:YYYY-MM-DD HH:mm:ss} | {level:<7} | {message} " ,
level = " DEBUG " ,
rotation = " 50 MB " ,
)
# ---------------------------------------------------------------------------
# 断点续跑 — progress 文件管理(复刻 repair_trees.py 惯例)
# ---------------------------------------------------------------------------
PROGRESS_FILE = " build_progress.json "
_VIDEO_SUFFIXES = frozenset ( { " .mp4 " , " .mkv " , " .avi " , " .webm " } )
def load_progress ( path : Path ) - > set [ str ] :
""" 读取 progress 文件,返回已完成视频 ID 集合。
参数:
path: progress JSON 文件路径。
返回:
已完成视频 ID 集合。文件不存在或损坏时返回空集。
"""
if not path . exists ( ) :
return set ( )
try :
data = json . loads ( path . read_text ( encoding = " utf-8 " ) )
return set ( data . get ( " finished_video_ids " , [ ] ) )
except ( json . JSONDecodeError , KeyError , TypeError , AttributeError ) :
logger . warning ( " progress 文件损坏,忽略: {} " , path )
return set ( )
async def save_progress ( path : Path , lock : asyncio . Lock , vid : str ) - > None :
""" 原子追加一个视频 ID 到 progress 文件。
参数:
path: progress JSON 文件路径。
lock: asyncio.Lock,防并发读改写丢更新。
vid: 要追加的视频 ID。
"""
async with lock :
finished = load_progress ( path )
finished . add ( vid )
tmp = path . with_suffix ( " .tmp " )
tmp . write_text (
json . dumps ( { " finished_video_ids " : sorted ( finished ) } , ensure_ascii = False , indent = 2 ) ,
encoding = " utf-8 " ,
)
os . replace ( str ( tmp ) , str ( path ) )
# ---------------------------------------------------------------------------
# 待建发现与完整性校验
# ---------------------------------------------------------------------------
def _tree_is_complete ( tree_path : Path ) - > bool :
""" 判断 tree.json 是否存在且可加载为非空树。
参数:
tree_path: tree.json 路径。
返回:
True 表示完整(跳过重建);文件缺失/损坏/空树返回 False。
"""
if not tree_path . exists ( ) :
return False
try :
index = TreeIndex . load_json ( str ( tree_path ) )
except ( json . JSONDecodeError , KeyError , TypeError , ValueError , AssertionError ) as exc :
logger . warning ( " tree.json 无法加载,视为不完整: {} ( {} ) " , tree_path , exc )
return False
return len ( index . roots ) > 0
def _discover_pending (
videos_dir : Path ,
out_dir : Path ,
finished : set [ str ] ,
) - > list [ Path ] :
""" 扫描视频目录,返回待建视频文件列表(按文件名排序)。
跳过条件:video_id 在 progress 中,或 out_dir/<video_id>/tree.json 完整。
参数:
videos_dir: 视频文件目录。
out_dir: 树输出根目录。
finished: progress 中已完成的视频 ID 集合。
返回:
待建视频文件路径列表。
"""
pending : list [ Path ] = [ ]
for f in sorted ( videos_dir . iterdir ( ) ) :
if not f . is_file ( ) or f . suffix . lower ( ) not in _VIDEO_SUFFIXES :
continue
vid = f . stem
if vid in finished :
continue
if _tree_is_complete ( out_dir / vid / " tree.json " ) :
continue
pending . append ( f )
return pending
def _find_srt_entries ( video_path : Path , srt_dir : Path ) :
""" 按视频同名规则查找并解析 SRT 字幕。
参数:
video_path: 视频文件路径。
srt_dir: SRT 目录。
返回:
SRTEntry 列表;无同名 .srt 时返回 None。
"""
srt_path = srt_dir / f " { video_path . stem } .srt "
if not srt_path . exists ( ) :
return None
return parse_srt ( str ( srt_path ) )
```
(编排函数与 main 在 Task 3 追加;本步模块以纯函数收尾即可运行测试。)
- [ ] **Step 4: 运行确认通过 **
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_build_trees.py -v`
Expected: 全部 PASS。
- [ ] **Step 5: Commit **
``` bash
git add tools/build_trees.py tests/unit/test_build_trees.py
git commit -m "feat(tools): add build_trees skeleton with discovery helpers"
```
---
### Task 3: 编排 main_async + CLI + 桩编排集成测试
**Files: **
- Modify: `tools/build_trees.py` (追加客户端构建、编排、CLI)
- Test: `tests/unit/test_build_trees.py` (追加编排测试)
- [ ] **Step 1: 写失败测试(桩 builder 编排集成) **
`tests/unit/test_build_trees.py` 追加:
``` python
# ── 编排集成(桩 builder,无真实视频/LLM)──────────────────────
import asyncio
from tools . build_trees import main_async
class _StubBuilder :
""" 记录并发与注入信号量的桩 builder。 """
instances : list [ " _StubBuilder " ] = [ ]
inflight = 0
max_inflight = 0
def __init__ ( self , vlm , llm , config , * , api_semaphore = None ) - > None :
self . api_semaphore = api_semaphore
_StubBuilder . instances . append ( self )
async def build_async ( self , video_path : str , srt_entries = None ) :
_StubBuilder . inflight + = 1
_StubBuilder . max_inflight = max ( _StubBuilder . max_inflight , _StubBuilder . inflight )
await asyncio . sleep ( 0.02 )
_StubBuilder . inflight - = 1
from app . tree . index import IndexMeta , L1Card , L1Node , TreeIndex
l1 = L1Node (
id = " x_L1_000 " ,
card = L1Card ( " s " , " 室内 " , [ ] , [ ] , [ ] , [ ] , " 线性 " ) ,
time_range = ( 0.0 , 1.0 ) ,
children = [ ] ,
)
return TreeIndex ( metadata = IndexMeta ( video_path , " video " ) , roots = [ l1 ] )
@pytest.fixture ( )
def batch_env ( tmp_path : Path , monkeypatch : pytest . MonkeyPatch ) :
""" 5 个假视频 + 桩 builder + 隔离的 progress 路径。 """
import tools . build_trees as bt
_StubBuilder . instances = [ ]
_StubBuilder . inflight = 0
_StubBuilder . max_inflight = 0
monkeypatch . setattr ( bt , " VideoTreeBuilder " , _StubBuilder )
monkeypatch . setattr ( bt , " _build_clients " , lambda api_concurrency : ( None , None ) )
videos = tmp_path / " videos "
videos . mkdir ( )
for i in range ( 5 ) :
( videos / f " v { i } .mp4 " ) . write_bytes ( b " " )
return {
" videos " : videos ,
" out " : tmp_path / " out " ,
" progress " : tmp_path / " build_progress.json " ,
}
def _make_args ( env : dict , video_concurrency : int = 2 , limit : int = 0 ) - > " argparse.Namespace " :
import argparse
return argparse . Namespace (
videos_dir = str ( env [ " videos " ] ) ,
out_dir = str ( env [ " out " ] ) ,
srt_dir = str ( env [ " videos " ] ) ,
video_concurrency = video_concurrency ,
limit = limit ,
progress_path = str ( env [ " progress " ] ) ,
)
class TestOrchestration :
""" main_async 编排行为(桩 builder)。 """
@pytest.mark.asyncio
async def test_video_concurrency_capped ( self , batch_env : dict ) - > None :
""" 同时在建视频数不得超过 video_concurrency。 """
await main_async ( _make_args ( batch_env , video_concurrency = 2 ) )
assert _StubBuilder . max_inflight < = 2
assert len ( _StubBuilder . instances ) == 5
@pytest.mark.asyncio
async def test_shared_api_semaphore ( self , batch_env : dict ) - > None :
""" 全部 builder 实例共享同一个 API Semaphore 对象。 """
await main_async ( _make_args ( batch_env ) )
sems = { id ( b . api_semaphore ) for b in _StubBuilder . instances }
assert len ( sems ) == 1
assert _StubBuilder . instances [ 0 ] . api_semaphore is not None
@pytest.mark.asyncio
async def test_trees_saved_and_progress_recorded ( self , batch_env : dict ) - > None :
""" 每个视频产出 tree.json 且 progress 记录全部完成。 """
await main_async ( _make_args ( batch_env ) )
for i in range ( 5 ) :
assert ( batch_env [ " out " ] / f " v { i } " / " tree.json " ) . exists ( )
from tools . build_trees import load_progress
assert load_progress ( batch_env [ " progress " ] ) == { f " v { i } " for i in range ( 5 ) }
@pytest.mark.asyncio
async def test_resume_skips_finished ( self , batch_env : dict ) - > None :
""" 第二次运行跳过全部已完成视频。 """
await main_async ( _make_args ( batch_env ) )
n_first = len ( _StubBuilder . instances )
await main_async ( _make_args ( batch_env ) )
assert len ( _StubBuilder . instances ) == n_first # 无新建
@pytest.mark.asyncio
async def test_partial_completion_resume ( self , batch_env : dict ) - > None :
""" 部分完成后重跑只建剩余视频(模拟中断后恢复)。 """
batch_env [ " progress " ] . write_text (
json . dumps ( { " finished_video_ids " : [ " v0 " , " v1 " ] } ) ,
encoding = " utf-8 " ,
)
await main_async ( _make_args ( batch_env ) )
assert len ( _StubBuilder . instances ) == 3 # 仅 v2/v3/v4
@pytest.mark.asyncio
async def test_limit ( self , batch_env : dict ) - > None :
""" --limit 2 只建前两个(烟测入口)。 """
await main_async ( _make_args ( batch_env , limit = 2 ) )
assert len ( _StubBuilder . instances ) == 2
```
- [ ] **Step 2: 运行确认失败 **
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_build_trees.py::TestOrchestration -v`
Expected: FAIL( ImportError: main_async 不存在)。
- [ ] **Step 3: 实现客户端构建 + 编排 + CLI **
`tools/build_trees.py` 追加:
``` python
# ---------------------------------------------------------------------------
# LLM/VLM 客户端构建(复刻 repair_trees.py 惯例)
# ---------------------------------------------------------------------------
def _build_clients ( api_concurrency : int ) :
""" 构建 GovernedLLMClient( LLM + VLM),熔断阈值随 API 并发缩放。
参数:
api_concurrency: 全局 API 并发上限(熔断阈值取 max(cfg, api_concurrency*2))。
返回:
(llm_client, vlm_client) 元组。
"""
from adapters . breaker import CircuitBreaker
from adapters . llm import GovernedLLMClient
from adapters . telemetry import SQLiteTelemetryRecorder
from adapters . vlm import GovernedVLMClient
( PROJECT_ROOT / " logs " ) . mkdir ( exist_ok = True )
telemetry = SQLiteTelemetryRecorder ( str ( PROJECT_ROOT / " logs " / " build_trees_telemetry.db " ) )
breaker_threshold = int ( os . getenv ( " LLM_CIRCUIT_BREAKER_THRESHOLD " , " 5 " ) )
breaker_threshold = max ( breaker_threshold , api_concurrency * 2 )
breaker_cooldown = int ( os . getenv ( " LLM_CIRCUIT_BREAKER_COOLDOWN " , " 60 " ) )
timeout_s = float ( os . getenv ( " LLM_TIMEOUT " , " 120 " ) )
max_retries = int ( os . getenv ( " LLM_MAX_RETRIES " , " 3 " ) )
base_delay = float ( os . getenv ( " LLM_RETRY_BASE_DELAY " , " 2.0 " ) )
max_delay = float ( os . getenv ( " LLM_RETRY_MAX_DELAY " , " 30.0 " ) )
ttft = float ( os . getenv ( " LLM_TTFT_TIMEOUT " , " 30 " ) )
inter_token = float ( os . getenv ( " LLM_INTER_TOKEN_TIMEOUT " , " 15 " ) )
llm = GovernedLLMClient (
model = os . environ [ " SEARCH_LLM_MODEL " ] ,
base_url = os . environ [ " SEARCH_LLM_BASE_URL " ] ,
api_key = os . environ [ " SEARCH_LLM_API_KEY " ] ,
provider = " deepseek " ,
thinking = False ,
breaker = CircuitBreaker ( fail_threshold = breaker_threshold , cooldown_s = breaker_cooldown ) ,
cache = None ,
telemetry = telemetry ,
timeout_s = timeout_s ,
ttft_timeout_s = ttft ,
inter_token_timeout_s = inter_token ,
max_retries = max_retries ,
retry_base_delay_s = base_delay ,
retry_max_delay_s = max_delay ,
)
vlm_base = GovernedLLMClient (
model = os . environ [ " VL_LLM_MODEL " ] ,
base_url = os . environ [ " VL_LLM_BASE_URL " ] ,
api_key = os . environ [ " VL_LLM_API_KEY " ] ,
provider = " qwen " ,
thinking = False ,
breaker = CircuitBreaker ( fail_threshold = breaker_threshold , cooldown_s = breaker_cooldown ) ,
cache = None ,
telemetry = telemetry ,
timeout_s = timeout_s ,
ttft_timeout_s = ttft ,
inter_token_timeout_s = inter_token ,
max_retries = max_retries ,
retry_base_delay_s = base_delay ,
retry_max_delay_s = max_delay ,
)
return llm , GovernedVLMClient ( vlm_base )
# ---------------------------------------------------------------------------
# 主编排
# ---------------------------------------------------------------------------
async def main_async ( args : argparse . Namespace ) - > None :
""" 异步主流程:视频级并发建树 + 全局共享 API 信号量。
参数:
args: CLI 参数(videos_dir/out_dir/srt_dir/video_concurrency/limit/progress_path)。
"""
videos_dir = Path ( args . videos_dir )
out_dir = Path ( args . out_dir )
srt_dir = Path ( args . srt_dir )
assert videos_dir . is_dir ( ) , f " 视频目录不存在: { videos_dir } "
api_concurrency = int ( os . getenv ( " TREE_BUILD_API_CONCURRENCY " , " 16 " ) )
# Phase 1: 待建发现(progress + 完整性双重跳过)
progress_path = Path ( args . progress_path )
finished = load_progress ( progress_path )
if finished :
logger . info ( " progress 已记录 {} 个完成视频 " , len ( finished ) )
pending = _discover_pending ( videos_dir , out_dir , finished )
if args . limit > 0 :
pending = pending [ : args . limit ]
logger . info (
" 待建 {} 个视频, video_concurrency= {} , api_concurrency= {} " ,
len ( pending ) , args . video_concurrency , api_concurrency ,
)
if not pending :
return
# Phase 2: 客户端与共享信号量
llm , vlm = _build_clients ( api_concurrency )
with open ( PROJECT_ROOT / " config " / " default.yaml " , encoding = " utf-8 " ) as f :
tree_cfg = TreeConfig . from_dict ( yaml . safe_load ( f ) [ " tree " ] )
api_sem = asyncio . Semaphore ( api_concurrency )
video_sem = asyncio . Semaphore ( args . video_concurrency )
progress_lock = asyncio . Lock ( )
start_time = time . time ( )
completed = 0
failed : list [ str ] = [ ]
# Phase 3: 视频级并发编排(复刻 repair_trees 模式)
async def _build_one ( video_path : Path ) - > None :
nonlocal completed
async with video_sem :
vid = video_path . stem
logger . info ( " 开始建树 {} " , vid )
builder = VideoTreeBuilder ( vlm = vlm , llm = llm , config = tree_cfg , api_semaphore = api_sem )
srt_entries = _find_srt_entries ( video_path , srt_dir )
try :
index = await builder . build_async ( str ( video_path ) , srt_entries )
except Exception as exc :
logger . error ( " 建树失败 {} ( {} ): {} " , vid , type ( exc ) . __name__ , exc )
failed . append ( vid )
return
tree_path = out_dir / vid / " tree.json "
tree_path . parent . mkdir ( parents = True , exist_ok = True )
index . save_json ( str ( tree_path ) )
await save_progress ( progress_path , progress_lock , vid )
completed + = 1
if completed % 5 == 0 :
elapsed = time . time ( ) - start_time
rate = completed / elapsed * 60 if elapsed > 0 else 0
logger . info (
" 进度: {} / {} , 已用 {:.0f} s, 速率 {:.2f} 视频/分钟 " ,
completed , len ( pending ) , elapsed , rate ,
)
await asyncio . gather ( * [ asyncio . create_task ( _build_one ( p ) ) for p in pending ] )
# Phase 4: 汇总
elapsed = time . time ( ) - start_time
logger . info (
" 批量建树完成: 成功 {} , 失败 {} , 总耗时 {:.0f} s {} " ,
completed , len ( failed ) , elapsed ,
f " , 失败清单: { failed } " if failed else " " ,
)
def main ( ) - > None :
""" 同步入口。 """
parser = argparse . ArgumentParser ( description = " 批量并行建树 " )
parser . add_argument ( " --videos-dir " , type = str , required = True , help = " 视频文件目录 " )
parser . add_argument ( " --out-dir " , type = str , default = " store/videos " , help = " 树输出根目录 " )
parser . add_argument ( " --srt-dir " , type = str , default = " " , help = " SRT 目录(默认同 videos-dir) " )
parser . add_argument ( " --video-concurrency " , type = int , default = 16 , help = " 同时在建视频数 " )
parser . add_argument ( " --limit " , type = int , default = 0 , help = " 只建前 N 个(0=全部,烟测用) " )
parser . add_argument (
" --progress-path " ,
type = str ,
default = str ( PROJECT_ROOT / " logs " / PROGRESS_FILE ) ,
dest = " progress_path " ,
help = " progress 文件路径 " ,
)
args = parser . parse_args ( )
if not args . srt_dir :
args . srt_dir = args . videos_dir
asyncio . run ( main_async ( args ) )
if __name__ == " __main__ " :
main ( )
```
注意:`_build_one` 中 `except Exception` 用于**单视频错误隔离**(一个视频失败不拖垮整批,失败清单汇总上报,vid 不进 progress 可重跑)——这是编排层的错误隔离语义而非吞错,日志含异常类型与内容。
- [ ] **Step 4: 运行确认通过 **
Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_build_trees.py -v`
Expected: 全部 PASS(纯函数 8 个 + 编排 6 个,共 14 个)。
- [ ] **Step 5: Commit **
``` bash
git add tools/build_trees.py tests/unit/test_build_trees.py
git commit -m "feat(tools): batch tree build orchestration with shared API semaphore"
```
---
### Task 4: scripts/build_trees.sh + 工程配置 + 回归收尾
**Files: **
- Create: `scripts/build_trees.sh`
- Modify: `.env.example` (追加 TREE_BUILD_API_CONCURRENCY)
- [ ] **Step 1: 写 sh 脚本 **
新建 `scripts/build_trees.sh` :
``` bash
#!/usr/bin/env bash
# 批量并行建树(Spec-2)
# 职责:对目录下所有视频并发构建三层 TreeIndex,断点续跑。
#
# 用法:
# bash scripts/build_trees.sh --videos-dir <dir> # 全量
# bash scripts/build_trees.sh --videos-dir <dir> --limit 2 # 真实视频烟测
# VIDEO_CONCURRENCY=8 bash scripts/build_trees.sh --videos-dir <dir>
#
# 真实视频烟测流程(首次使用时执行):
# 1. --limit 2 建两个视频,观察日志速率与 API 并发
# 2. 中途 Ctrl+C 后重跑,确认已完成视频被跳过、未完成视频续跑
# 3. 检查 store/videos/<vid>/tree.json 可被 TreeIndex.load_json 加载
#
# 日志输出:
# stderr → 终端实时显示
# logs/build_trees.log → 完整日志(自动 rotation 50MB)
# logs/build_trees_telemetry.db → LLM/VLM 调用遥测
# logs/build_progress.json → 断点续跑进度
#
# api_concurrency 走 .env 的 TREE_BUILD_API_CONCURRENCY(工程配置,默认 16)
set -euo pipefail
cd " $( dirname " $0 " ) /.. "
VIDEO_CONCURRENCY = " ${ VIDEO_CONCURRENCY :- 16 } "
export PYTHONUNBUFFERED = 1
# shellcheck source=/dev/null
source " $( conda info --base) /etc/profile.d/conda.sh "
conda activate Video-Tree-TRM
python tools/build_trees.py \
--out-dir store/videos \
--video-concurrency " $VIDEO_CONCURRENCY " \
" $@ "
```
``` bash
chmod +x scripts/build_trees.sh
```
- [ ] **Step 2: 补 .env.example **
`.env.example` 追加(若文件不存在则跳过并在 NOTES 说明):
```
# 建树批量并行:全局 VLM/LLM 在途调用上限(Spec-2 工程配置)
TREE_BUILD_API_CONCURRENCY=16
```
- [ ] **Step 3: 全量回归 + lint **
Run: `conda run -n Video-Tree-TRM ruff format --check tools/build_trees.py tests/unit/test_build_trees.py app/tree/video_builder.py`
Run: `conda run -n Video-Tree-TRM ruff check tools/build_trees.py tests/unit/test_build_trees.py app/tree/video_builder.py`
Run: `conda run -n Video-Tree-TRM radon cc tools/build_trees.py app/tree/video_builder.py -n C -s` (新增函数不得出现 C 级;video_builder 既有 C 级为存量)
Run: `conda run -n Video-Tree-TRM pytest tests/ -q`
Expected: 全绿。
- [ ] **Step 4: Commit **
``` bash
git add scripts/build_trees.sh .env.example
git commit -m "feat(scripts): add batch tree build entry with smoke-test guide"
```
---
## Self-Review 记录
1. **Spec 覆盖 ** :设计 §3 全部落地——build_async 公开 + Semaphore 注入(Task 1)、两层并发参数与全局共享信号量(Task 3)、熔断阈值缩放(Task 3 `_build_clients` )、progress 断点续跑与完整性跳过(Task 2/3)、sh 入口与 D7 配置归属(Task 4);§4 速率日志(Task 3 Phase 3);§6 验证改为桩编排测试(头部"验证策略说明"已声明理由:仓库无视频文件),自动化覆盖"已完成跳过"(`test_resume_skips_finished` )与"部分完成后恢复"(`test_partial_completion_resume` );**段级断点续跑**由 builder 既有 `_load_resume_state` 机制承担(算法 #3 ,本计划零改动)+ 真实视频烟测流程(sh 头注释步骤 2)在首次实际建树时验证。
2. **占位符扫描 ** :无 TBD/TODO;所有代码步骤含完整代码。
3. **类型一致性 ** : `_discover_pending(videos_dir, out_dir, finished)` 、`_find_srt_entries(video_path, srt_dir)` 、`main_async(args)` 、`_build_clients(api_concurrency)` 在测试与实现间签名一致;`_make_args` 构造的 Namespace 字段与 `main_async` 读取字段一一对应(含 progress_path)。
## 核心算法保真校验
本计划涉及**算法 #1 ( L2 轴心建树)/#2 ( VLM 批量帧描述)/#3 (断点续跑)**所在文件 `app/tree/video_builder.py` :改动仅为 (a) `_build_async` 改名公开(方法体零改动)、(b) 信号量来源三元切换(默认路径与现状逐字等价)、(c) 构造器加可选参数。三项算法的核心逻辑(L2→L3 链式触发、批量 VLM+fallback、progress 段级恢复)均未触碰。执行时质量门第 7 项应 diff 确认 `build_async` 方法体与原 `_build_async` 逐行一致。其余算法不涉及。