fix(plan): Task 0 Codex 审查修复 — 锚标保持/双写消除/幂等迁移/遗漏文件

- [C4] 加入 verify.py/synthesizer.py 到变更清单
- [I3a] L2 subtitle 统一由 Voronoi+聚合处理,移除 _build_l2 双写
- [I3b] File Structure 修正为'保留 Phase 7'
- [I5] 迁移脚本幂等性修复
- [I6] 保持 [cN]/[sN] 双锚语义,_collect_card_strings 跳过 subtitle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 11:43:33 -04:00
parent 160fb3bc7c
commit c72b55508a
@@ -16,11 +16,23 @@
| 操作 | 文件 | 职责 | | 操作 | 文件 | 职责 |
|------|------|------| |------|------|------|
| **Task 0: subtitle 迁入 Card** | | |
| Modify | `app/tree/index.py:73-194` | L3Card/L2Card 加 subtitle 字段;L3Node 去 subtitle 字段 |
| Modify | `app/tree/index.py:267-390` | to_dict/from_dict 序列化适配 |
| Modify | `app/tree/subtitle.py:239-308` | Voronoi 写入 Card.subtitle 而非 Node.subtitle |
| Modify | `app/tree/environment.py:448-489` | _node_full_text/_node_anchored_text 简化 |
| Modify | `app/tree/environment.py:367-381` | get_subtitle 读 Card.subtitle |
| Modify | `app/tree/video_builder.py:475-486` | Phase 7 保留但改写 Card.subtitle |
| Modify | `app/tree/verify.py` | `l3.subtitle``l3.card.subtitle` |
| Modify | `app/question_gen/synthesizer.py` | `l3.subtitle` / `l2.subtitle``card.subtitle` |
| Create | `tools/migrate_subtitle_to_card.py` | 迁移脚本:300 棵树 subtitle 从 Node 移入 Card |
| Modify | 9 个测试文件 | Card 构造加 subtitle 参数 |
| **Task 1-8: 原有任务** | | |
| Create | `app/harness/deps_router.py` | 按 video_id 懒加载 InferenceDeps 并路由 dispatch/prompt_builder | | Create | `app/harness/deps_router.py` | 按 video_id 懒加载 InferenceDeps 并路由 dispatch/prompt_builder |
| Modify | `app/ports.py:76` | 新增 4 个 ProtocolToolDispatchFn, ToolDispatchFactory, PromptBuilderFn, PromptBuilderFactory | | Modify | `app/ports.py` (文件末尾追加) | 新增 4 个 Protocol |
| Modify | `app/harness/runner.py:454-469` | __init__ 增加 2 个 factory 参数 + fail-fast 校验 | | Modify | `app/harness/runner.py:454-469` | __init__ 增加 2 个 factory 参数 + fail-fast 校验 |
| Modify | `app/harness/runner.py:2064-2082` | _make_* 方法优先用注入值 | | Modify | `app/harness/runner.py:2064-2082` | _make_* 方法优先用注入值 |
| Create | `main.py` | Composition Rootargparse + InfraSettings + 适配器构建 + Runner 组装 | | Create | `main.py` | Composition Root |
| Move | `store/prompts/*.md``store/prompts/v1/` | 版本化目录重组 | | Move | `store/prompts/*.md``store/prompts/v1/` | 版本化目录重组 |
| Create | `store/skills/v1/` (13 files) | 从 TRM4 v1 精简 + 注入 TRM5 card 字段 | | Create | `store/skills/v1/` (13 files) | 从 TRM4 v1 精简 + 注入 TRM5 card 字段 |
| Modify | `config/default.yaml:29,31` | concurrency=24, max_steps=40 | | Modify | `config/default.yaml:29,31` | concurrency=24, max_steps=40 |
@@ -29,6 +41,426 @@
--- ---
### Task 0: subtitle 迁入 Card + 建树管线修正 + 300 棵树迁移
**Files:**
- Modify: `app/tree/index.py` (L3Card, L2Card, L3Node, to_dict, from_dict)
- Modify: `app/tree/subtitle.py:239-308` (assign_subtitles_voronoi)
- Modify: `app/tree/environment.py:367-381,448-489` (_node_full_text, get_subtitle)
- Modify: `app/tree/video_builder.py:475-486,902-996` (builder pipeline)
- Create: `tools/migrate_subtitle_to_card.py`
- Modify: 9 个测试文件 (Card 构造适配)
**设计决策:**
- L3Card/L2Card 加 `subtitle: str = ""` 字段(放在字段列表末尾,`_collect_card_strings` 自动收集)
- L1Card 不加 subtitle(用户确认)
- L3Node 移除 `subtitle: str | None` 字段(数据迁入 Card
- Card 保持 `frozen=True`,建树时在 Card 创建前计算好 subtitle 传入构造函数;迁移脚本用 `dataclasses.replace()` 创建新 Card
- 建树管线中 L2/L3 直接在 Card 构造时注入字幕,移除 Phase 7 Voronoi 后处理
- L2 subtitle = `extract_subtitle_for_range(srt_entries, l2_time_range)``_build_l2_video_async` 中计算
- L3 subtitle 仍用 Voronoi 逻辑(精确分配帧级字幕),但写入 Card 而非 Node
- [ ] **Step 1: 修改 Card dataclass**
`app/tree/index.py` 中:
**L3Card** (第 73-92 行) — 末尾加 `subtitle: str = ""`
```python
@dataclass(frozen=True)
class L3Card:
"""L3 帧级语义卡片(不可变)。"""
frame_summary: str
visible_entities: list[str]
ongoing_actions: list[str]
visible_text: list[str]
spatial_layout: str
visual_attributes: dict[str, Any]
subtitle: str = ""
```
**L2Card** (第 96-117 行) — 末尾加 `subtitle: str = ""`
```python
@dataclass(frozen=True)
class L2Card:
"""L2 事件级语义卡片(不可变)。"""
event_description: str
entities: list[str]
actions: list[str]
action_subjects: list[str]
visible_text: list[str]
spatial_relations: str
state_changes: str | None
subtitle: str = ""
```
**L3Node** (第 176-194 行) — 移除 `subtitle` 字段:
```python
@dataclass
class L3Node:
"""L3 帧级语义节点(叶层)。"""
id: str
card: L3Card
embedding: np.ndarray | None = None
timestamp: float | None = None
frame_path: str | None = None
# subtitle 已迁入 L3Card,此处不再保留
```
- [ ] **Step 2: 修改序列化/反序列化**
**to_dict** — L3 `l3_to_dict` (第 267-284 行): subtitle 从 card 输出,移除节点级 subtitle
```python
def l3_to_dict(n: L3Node) -> dict[str, Any]:
d: dict[str, Any] = {
"id": n.id,
"card": {
"frame_summary": n.card.frame_summary,
"visible_entities": n.card.visible_entities,
"ongoing_actions": n.card.ongoing_actions,
"visible_text": n.card.visible_text,
"spatial_layout": n.card.spatial_layout,
"visual_attributes": n.card.visual_attributes,
"subtitle": n.card.subtitle,
},
"timestamp": n.timestamp,
"frame_path": n.frame_path,
}
if include_embedding:
d["embedding"] = _embed_to_str(n.embedding)
return d
```
**to_dict** — L2 `l2_to_dict` (第 286-303 行): card 字典加 subtitle
```python
def l2_to_dict(n: L2Node) -> dict[str, Any]:
d: dict[str, Any] = {
"id": n.id,
"card": {
"event_description": n.card.event_description,
"entities": n.card.entities,
"actions": n.card.actions,
"action_subjects": n.card.action_subjects,
"visible_text": n.card.visible_text,
"spatial_relations": n.card.spatial_relations,
"state_changes": n.card.state_changes,
"subtitle": n.card.subtitle,
},
"time_range": list(n.time_range) if n.time_range else None,
"children": [l3_to_dict(c) for c in n.children],
}
if include_embedding:
d["embedding"] = _embed_to_str(n.embedding)
return d
```
**from_dict** — L3 反序列化 (第 337-354 行): subtitle 从 card 读取,兼容旧格式从节点级读取:
```python
l3_card = L3Card(
frame_summary=l3d["card"]["frame_summary"],
visible_entities=l3d["card"]["visible_entities"],
ongoing_actions=l3d["card"]["ongoing_actions"],
visible_text=l3d["card"]["visible_text"],
spatial_layout=l3d["card"]["spatial_layout"],
visual_attributes=l3d["card"]["visual_attributes"],
subtitle=l3d["card"].get("subtitle", "") or l3d.get("subtitle", "") or "",
)
l3_nodes.append(
L3Node(
id=l3d["id"],
card=l3_card,
embedding=_embed_from_str(l3d.get("embedding")),
timestamp=l3d.get("timestamp"),
frame_path=l3d.get("frame_path"),
)
)
```
**from_dict** — L2 反序列化 (第 355-363 行): 加 subtitle
```python
l2_card = L2Card(
event_description=l2d["card"]["event_description"],
entities=l2d["card"]["entities"],
actions=l2d["card"]["actions"],
action_subjects=l2d["card"]["action_subjects"],
visible_text=l2d["card"]["visible_text"],
spatial_relations=l2d["card"]["spatial_relations"],
state_changes=l2d["card"]["state_changes"],
subtitle=l2d["card"].get("subtitle", ""),
)
```
- [ ] **Step 3: 修改 environment.py**
**`get_subtitle`** (第 367-381 行) — 读 Card.subtitleL1/L2/L3 均支持:
```python
def get_subtitle(self, node_id: str) -> str:
"""返回节点字幕文本。L2/L3 从 Card 读取,L1 返回空串。"""
node = self._id_to_node.get(node_id)
if node is None:
return ""
if isinstance(node, (L2Node, L3Node)):
return node.card.subtitle or ""
return ""
```
**`_collect_card_strings`** (第 67-78 行) — 新增 `skip_fields` 参数,排除 subtitlesubtitle 需单独标签/锚标处理):
```python
def _collect_card_strings(node: AnyNode, *, skip_fields: frozenset[str] = frozenset()) -> list[str]:
"""从节点 card 中递归收集所有非空字符串字段,可排除指定字段。"""
result: list[str] = []
_collect_from_obj(node.card, result, skip_fields=skip_fields)
return result
```
`_collect_from_obj` (第 81-101 行) — 在 dataclass 分支中跳过 `skip_fields`
```python
def _collect_from_obj(obj: object, out: list[str], *, skip_fields: frozenset[str] = frozenset()) -> None:
if isinstance(obj, str):
stripped = obj.strip()
if stripped:
out.append(stripped)
elif isinstance(obj, dict):
for v in obj.values():
_collect_from_obj(v, out, skip_fields=skip_fields)
elif isinstance(obj, (list, tuple)):
for item in obj:
_collect_from_obj(item, out, skip_fields=skip_fields)
elif hasattr(obj, "__dataclass_fields__"):
for field_name in obj.__dataclass_fields__:
if field_name in skip_fields:
continue
_collect_from_obj(getattr(obj, field_name), out, skip_fields=skip_fields)
```
**`_node_full_text`** (第 448-461 行) — 保持"字幕:"标签,改为读 `card.subtitle`
```python
_SUBTITLE_SKIP = frozenset({"subtitle"})
def _node_full_text(self, node: AnyNode) -> str:
"""获取节点完整文本(card 非 subtitle 字段 + 带标签的字幕)。"""
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
text = "\n".join(card_strings)
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
text += f"\n字幕: {node.card.subtitle}"
return text
```
**`_node_anchored_text`** (第 463-489 行) — 保持 [cN]/[sN] 双锚语义,改为读 `card.subtitle`
```python
def _node_anchored_text(self, node: AnyNode) -> str:
"""获取带行号锚的节点文本。card 字段 [cN],字幕 [sN]。"""
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
card_lines: list[str] = []
for s in card_strings:
card_lines.extend(ln for ln in s.splitlines() if ln.strip())
sub_lines: list[str] = []
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
sub_lines = [ln for ln in node.card.subtitle.splitlines() if ln.strip()]
anchored: list[str] = []
for i, line in enumerate(card_lines, 1):
anchored.append(f"[c{i}] {line}")
for i, line in enumerate(sub_lines, 1):
anchored.append(f"[s{i}] {line}")
return "\n".join(anchored)
```
- [ ] **Step 4: 修改 subtitle.py — Voronoi 写入 Card**
`assign_subtitles_voronoi` (第 239-308 行) — 用 `dataclasses.replace()` 写入 Card.subtitle
在函数顶部加 `import dataclasses`,然后将第 302 行:
```python
# 原: l3.subtitle = subtitle_text if subtitle_text else None
# 改:
l3.card = dataclasses.replace(l3.card, subtitle=subtitle_text or "")
```
同时在函数末尾为每个 L2 聚合子节点字幕:
```python
# L2 字幕聚合(在 L3 分配完成后)
for l1 in index.roots:
for l2 in l1.children:
l2_sub_parts = [c.card.subtitle for c in l2.children if c.card.subtitle]
if l2_sub_parts:
l2.card = dataclasses.replace(l2.card, subtitle="\n".join(l2_sub_parts))
```
- [ ] **Step 5: 修改 video_builder.py — 建树管线**
Phase 7 保留 `assign_subtitles_voronoi`(它现在写 Card.subtitle + L2 聚合),不在 `_build_l2_video_async` 中注入字幕(避免双写)。仅更新注释:
```python
# Phase 7: 字幕注入 CardL3 Voronoi 分配 + L2 聚合)
if srt_entries:
assign_subtitles_voronoi(index, srt_entries)
logger.info("字幕已注入 Card: L3 Voronoi + L2 聚合", n_entries=len(srt_entries))
```
`_build_l2_video_async``_build_l3_video_async` 不做字幕注入改动。字幕注入统一由 Phase 7 `assign_subtitles_voronoi` 负责。
- [ ] **Step 5.5: 修改 verify.py 和 synthesizer.py 中的 subtitle 访问**
搜索并替换所有 `l3.subtitle``l3.card.subtitle``l2.subtitle``l2.card.subtitle`(若存在):
```bash
grep -rn "\.subtitle" app/tree/verify.py app/question_gen/synthesizer.py
```
对每处命中做替换,例如:
- `app/tree/verify.py`: `node.subtitle``node.card.subtitle`(仅 L3/L2 节点)
- `app/question_gen/synthesizer.py`: `l3.subtitle``l3.card.subtitle`
- [ ] **Step 6: 迁移脚本**
```python
# tools/migrate_subtitle_to_card.py
"""迁移 300 棵树:L3 subtitle 从 Node 级移入 Card 级,L2 聚合子节点字幕。"""
import dataclasses
import json
import sys
from pathlib import Path
from loguru import logger
def migrate_tree(tree_path: Path) -> bool:
"""迁移单棵树,返回是否有变更。幂等:已迁移的树不会被修改。"""
data = json.loads(tree_path.read_text(encoding="utf-8"))
changed = False
for l1 in data.get("roots", []):
for l2 in l1.get("children", []):
l2_sub_parts: list[str] = []
for l3 in l2.get("children", []):
# L3: node 级 subtitle 迁入 card(幂等:已有 card subtitle 则跳过)
card_sub = l3.get("card", {}).get("subtitle", "")
node_sub = l3.get("subtitle") # None = 从未有字幕
if node_sub is not None and "subtitle" in l3:
# 旧格式:有 node 级 subtitle
final_sub = card_sub or (node_sub if node_sub else "")
l3["card"]["subtitle"] = final_sub
del l3["subtitle"]
changed = True
elif "subtitle" not in l3.get("card", {}):
# 无任何 subtitle 数据
l3.setdefault("card", {})["subtitle"] = ""
changed = True
# 收集 L3 字幕用于 L2 聚合
effective_sub = l3.get("card", {}).get("subtitle", "")
if effective_sub:
l2_sub_parts.append(effective_sub)
# L2: 聚合 L3 字幕(幂等:已有 card subtitle 则跳过)
l2_card = l2.setdefault("card", {})
if "subtitle" not in l2_card:
l2_card["subtitle"] = "\n".join(l2_sub_parts) if l2_sub_parts else ""
changed = True
if changed:
tree_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return changed
def main() -> None:
"""迁移 store/videos/ 下所有 tree.json。"""
videos_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("store/videos")
tree_files = sorted(videos_dir.glob("*/tree.json"))
logger.info("发现 {} 棵树待迁移", len(tree_files))
migrated = 0
for tf in tree_files:
if migrate_tree(tf):
migrated += 1
logger.info("迁移完成: {}/{} 棵树有变更", migrated, len(tree_files))
if __name__ == "__main__":
main()
```
Run: `conda run -n Video-Tree-TRM python tools/migrate_subtitle_to_card.py store/videos`
验证:
```bash
python3 -c "
import json; d=json.loads(open('store/videos/068rdc75mHM/tree.json').read())
l1=d['roots'][0]; l2=l1['children'][0]; l3=l2['children'][0]
print('L3 card subtitle:', bool(l3['card'].get('subtitle')))
print('L3 node subtitle:', 'subtitle' in l3 and l3.get('subtitle') is not None)
print('L2 card subtitle:', bool(l2['card'].get('subtitle')))
"
```
Expected: L3 card subtitle: True, L3 node subtitle: False, L2 card subtitle: True
- [ ] **Step 7: 更新测试文件**
需要更新的 9 个文件中所有 `L3Card(...)``L2Card(...)` 构造调用。由于 `subtitle` 有默认值 `""`,大多数测试不需要改动(默认空串即可)。但 `L3Node(...)` 构造中如果传了 `subtitle=` 参数需要移除。
需要检查并修改的具体文件:
```
tests/integration/test_tree_build_e2e.py
tests/unit/test_repair_detector.py
tests/unit/test_repair_regenerator.py
tests/unit/test_search_tools.py
tests/unit/test_subtitle.py
tests/unit/test_tree_environment.py
tests/unit/test_tree_index.py
tests/unit/test_verify.py
tests/unit/test_video_builder.py
```
对每个文件:
1. `L3Node(..., subtitle=xxx)` → 移除 `subtitle=` 参数,改为在 L3Card 构造时传入 `subtitle=xxx`
2. `L3Card(a, b, c, d, e, f)` 位置参数调用 → 不影响(subtitle 有默认值)
3. 确认 `L2Card(...)` 构造不受影响(subtitle 有默认值)
- [ ] **Step 8: 运行全量测试**
Run: `conda run -n Video-Tree-TRM pytest tests/ -x -q`
Expected: ALL PASSED
- [ ] **Step 9: Commit**
```bash
git add app/tree/index.py app/tree/subtitle.py app/tree/environment.py app/tree/video_builder.py tools/migrate_subtitle_to_card.py tests/
git commit -m "refactor(tree): subtitle 迁入 L3Card/L2Card + 建树管线修正 + 300 棵树迁移
- L3Card/L2Card 新增 subtitle: str 字段
- L3Node 移除 subtitle 字段(数据迁入 Card
- assign_subtitles_voronoi 改写 Card.subtitle
- _node_full_text 简化(Card 已含 subtitle
- 建树管线 L2 直接注入字幕到 Card
- 迁移脚本处理 300 棵现有树"
```
---
### Task 1: store/ 目录重组 ### Task 1: store/ 目录重组
**Files:** **Files:**
@@ -56,11 +488,18 @@ git mv store/prompts/view_node_children_verify.md store/prompts/v1/
mkdir -p store/skills/v1 mkdir -p store/skills/v1
``` ```
- [ ] **Step 3: 验证目录结构** - [ ] **Step 3: 修正 system.md — L1 去掉 subtitle 描述**
编辑 `store/prompts/v1/system.md`,在 L1 字段表中删除 `| subtitle | 完整字幕(较长) |` 行。在信任层级段落末尾将"三个层级都包含 visible_text 和 subtitle 字段"改为"三个层级都包含 visible_text 字段,L2 和 L3 额外包含 subtitle 字段"。
- [ ] **Step 4: 验证目录结构**
Run: `ls store/prompts/v1/ && ls store/skills/v1/` Run: `ls store/prompts/v1/ && ls store/skills/v1/`
Expected: 9 个 .md 文件在 prompts/v1/ 下,skills/v1/ 为空目录。 Expected: 9 个 .md 文件在 prompts/v1/ 下,skills/v1/ 为空目录。
Run: `grep subtitle store/prompts/v1/system.md`
Expected: 仅 L2 和 L3 部分出现 subtitle。
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
@@ -121,8 +560,8 @@ git commit -m "refactor(store): prompts 版本化目录重组 + skills/v1 骨架
| L3 | ongoing_actions | 正在发生的动作 | | L3 | ongoing_actions | 正在发生的动作 |
| L3 | spatial_layout | 精确空间位置 | | L3 | spatial_layout | 精确空间位置 |
| L3 | visual_attributes | 光照、色调、机位 | | L3 | visual_attributes | 光照、色调、机位 |
| L2/L3 | subtitle | 字幕转写(L1 无此字段) |
| 全层 | visible_text | 画面文字(OCR | | 全层 | visible_text | 画面文字(OCR |
| 全层 | subtitle | 字幕转写 |
``` ```
**TRM4 v1 源文件路径:** `/home/iomgaa/Projects/Video-Tree-TRM4/store/skills/v1/` **TRM4 v1 源文件路径:** `/home/iomgaa/Projects/Video-Tree-TRM4/store/skills/v1/`
@@ -1307,10 +1746,11 @@ git commit -m "test: 冒烟测试通过,900 题推理管线就绪"
## 核心算法保真校验 ## 核心算法保真校验
本计划不涉及核心算法迁移。所有 13 项核心算法(L2 轴心建树、CE-Gate e-process、Agent Loop 等)在 TRM5 中已有完整实现。本次工作仅涉及 Task 0 涉及 Voronoi 字幕分配(算法清单中非独立项,属于建树模块的后处理步骤)。改动范围
- 依赖注入的架构改进(Runner factory 注入)
- 新增基础设施模块(InferenceDepsRouter、main.py
- 内容准备(skills/v1、prompts/v1 目录重组)
- 配置变更
保真校验不适用。 | 算法 | 改动性质 | 保真判定 |
|------|---------|---------|
| Voronoi 字幕分配 (`subtitle.py`) | 写入目标从 `L3Node.subtitle` 改为 `L3Card.subtitle` + 新增 L2 聚合 | 核心逻辑(中点计算、范围扩展)不变,仅写入目标变更。**保真**。 |
| L2 建树 (`video_builder.py`) | Card 构造后注入 subtitle | 新增逻辑(原来不存在 L2 subtitle),不涉及算法简化。**不适用**。 |
其余 Task 1-8 不涉及核心算法迁移。