refactor(tree): subtitle 迁入 L3Card/L2Card + 建树管线修正

- L3Card/L2Card 新增 subtitle: str 字段(L1Card 不加)
- L3Node 移除 subtitle 字段(数据迁入 Card)
- assign_subtitles_voronoi 改写 Card.subtitle + L2 聚合
- _collect_card_strings 增加 skip_fields 排除 subtitle
- _node_full_text/_node_anchored_text 保持 字幕:/[sN] 语义
- get_subtitle 读 Card.subtitle(L2/L3)
- verify.py/synthesizer.py: l3.subtitle → l3.card.subtitle
- 迁移脚本 tools/migrate_subtitle_to_card.py(幂等,300 棵树已迁移)
- 9→6 个测试文件适配(3 个无需改动)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 11:57:41 -04:00
parent c72b55508a
commit d3be9b1322
13 changed files with 253 additions and 46 deletions
+37 -15
View File
@@ -64,26 +64,41 @@ def _node_description(node: AnyNode) -> str:
return node.card.frame_summary
def _collect_card_strings(node: AnyNode) -> list[str]:
def _collect_card_strings(
node: AnyNode,
skip_fields: frozenset[str] = frozenset(),
) -> list[str]:
"""从节点 card 中递归收集所有非空字符串字段。
参数:
node: 树节点实例。
skip_fields: 需要跳过的 dataclass 字段名集合(如 subtitle
因为它需要单独添加"字幕:"标签和 [sN] 锚标)。
返回:
字符串列表(每个非空字段值一项,含内嵌换行的按行拆分)。
"""
result: list[str] = []
_collect_from_obj(node.card, result)
_collect_from_obj(node.card, result, skip_fields=skip_fields)
return result
def _collect_from_obj(obj: object, out: list[str]) -> None:
# subtitle 字段在 _node_full_text / _node_anchored_text 中单独处理
_SUBTITLE_SKIP: frozenset[str] = frozenset({"subtitle"})
def _collect_from_obj(
obj: object,
out: list[str],
*,
skip_fields: frozenset[str] = frozenset(),
) -> None:
"""递归收集任意嵌套结构中的非空字符串。
参数:
obj: dict / list / str / 其他。
out: 收集结果列表(原地修改)。
skip_fields: 需要跳过的 dataclass 字段名集合。
"""
if isinstance(obj, str):
stripped = obj.strip()
@@ -91,14 +106,16 @@ def _collect_from_obj(obj: object, out: list[str]) -> None:
out.append(stripped)
elif isinstance(obj, dict):
for v in obj.values():
_collect_from_obj(v, out)
_collect_from_obj(v, out, skip_fields=skip_fields)
elif isinstance(obj, (list, tuple)):
for item in obj:
_collect_from_obj(item, out)
_collect_from_obj(item, out, skip_fields=skip_fields)
elif hasattr(obj, "__dataclass_fields__"):
# frozen dataclassCard 类型)
for field_name in obj.__dataclass_fields__:
_collect_from_obj(getattr(obj, field_name), out)
if field_name in skip_fields:
continue
_collect_from_obj(getattr(obj, field_name), out, skip_fields=skip_fields)
class TreeEnvironment:
@@ -367,17 +384,19 @@ class TreeEnvironment:
def get_subtitle(self, node_id: str) -> str:
"""返回节点字幕文本。
L2/L3 节点从 card.subtitle 读取,L1 节点不含字幕。
参数:
node_id: 节点 ID。
返回:
字幕文本;无字幕或节点不存在时返回空字符串。
字幕文本;无字幕、L1 节点或节点不存在时返回空字符串。
"""
node = self._id_to_node.get(node_id)
if node is None:
return ""
if isinstance(node, L3Node):
return node.subtitle or ""
if isinstance(node, (L2Node, L3Node)):
return node.card.subtitle or ""
return ""
def resolve_frame_paths(self, node_ids: list[str]) -> list[Path]:
@@ -448,22 +467,25 @@ class TreeEnvironment:
def _node_full_text(self, node: AnyNode) -> str:
"""获取节点完整文本(card 所有字段 + subtitle)。
subtitle 从 card.subtitle 读取,仅 L2/L3 节点附加"字幕:"标签。
参数:
node: 树节点。
返回:
拼接后的全文本。
"""
card_strings = _collect_card_strings(node)
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
text = "\n".join(card_strings)
if isinstance(node, L3Node) and node.subtitle:
text += f"\n字幕: {node.subtitle}"
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
text += f"\n字幕: {node.card.subtitle}"
return text
def _node_anchored_text(self, node: AnyNode) -> str:
"""获取带行号锚的节点文本。
card 字符串逐行编 [c1]..[cN],字幕逐行编 [s1]..[sM]。
字幕从 card.subtitle 读取,仅 L2/L3 节点产生 [sN] 锚标。
参数:
node: 树节点。
@@ -471,15 +493,15 @@ class TreeEnvironment:
返回:
带锚文本。
"""
card_strings = _collect_card_strings(node)
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, L3Node) and node.subtitle:
sub_lines = [ln for ln in node.subtitle.splitlines() if ln.strip()]
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):
+10 -4
View File
@@ -82,6 +82,7 @@ class L3Card:
visible_text: 画面中可见的文字列表。
spatial_layout: 空间布局描述。
visual_attributes: 视觉属性字典(如光照、色调等)。
subtitle: 字幕文本(Voronoi 分配后填充,默认空)。
"""
frame_summary: str
@@ -90,6 +91,7 @@ class L3Card:
visible_text: list[str]
spatial_layout: str
visual_attributes: dict[str, Any]
subtitle: str = ""
@dataclass(frozen=True)
@@ -106,6 +108,7 @@ class L2Card:
visible_text: 片段中可见的文字列表。
spatial_relations: 空间关系描述。
state_changes: 状态变化描述(可选)。
subtitle: 子 L3 字幕聚合文本(Voronoi 分配后填充,默认空)。
"""
event_description: str
@@ -115,6 +118,7 @@ class L2Card:
visible_text: list[str]
spatial_relations: str
state_changes: str | None
subtitle: str = ""
@dataclass(frozen=True)
@@ -183,7 +187,6 @@ class L3Node:
embedding: 文本嵌入向量,形状 [D]float32。
timestamp: 对应的时间戳(秒,可选)。
frame_path: 关联的帧图像路径(可选,仅视频模态)。
subtitle: 该帧对应的字幕文本(可选)。
"""
id: str
@@ -191,7 +194,6 @@ class L3Node:
embedding: np.ndarray | None = None
timestamp: float | None = None
frame_path: str | None = None
subtitle: str | None = None
@property
def description(self) -> str:
@@ -274,10 +276,10 @@ class L1Node:
"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,
"subtitle": n.subtitle,
}
if include_embedding:
d["embedding"] = _embed_to_str(n.embedding)
@@ -294,6 +296,7 @@ class L1Node:
"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],
@@ -334,6 +337,8 @@ class L1Node:
for l2d in d.get("children", []):
l3_nodes: list[L3Node] = []
for l3d in l2d.get("children", []):
# 向后兼容:旧格式 subtitle 在节点级,新格式在 card 内
l3_subtitle = l3d["card"].get("subtitle", "") or l3d.get("subtitle", "") or ""
l3_card = L3Card(
frame_summary=l3d["card"]["frame_summary"],
visible_entities=l3d["card"]["visible_entities"],
@@ -341,6 +346,7 @@ class L1Node:
visible_text=l3d["card"]["visible_text"],
spatial_layout=l3d["card"]["spatial_layout"],
visual_attributes=l3d["card"]["visual_attributes"],
subtitle=l3_subtitle,
)
l3_nodes.append(
L3Node(
@@ -349,7 +355,6 @@ class L1Node:
embedding=_embed_from_str(l3d.get("embedding")),
timestamp=l3d.get("timestamp"),
frame_path=l3d.get("frame_path"),
subtitle=l3d.get("subtitle"),
)
)
l2_card = L2Card(
@@ -360,6 +365,7 @@ class L1Node:
visible_text=l2d["card"]["visible_text"],
spatial_relations=l2d["card"]["spatial_relations"],
state_changes=l2d["card"]["state_changes"],
subtitle=l2d["card"].get("subtitle", ""),
)
tr2 = l2d.get("time_range")
l2_nodes.append(
+14 -2
View File
@@ -13,6 +13,7 @@
from __future__ import annotations
import dataclasses
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -254,7 +255,8 @@ def assign_subtitles_voronoi(
entries: 已解析的 SRTEntry 列表。
副作用:
直接修改每个 L3Node.subtitle 字段。
通过 dataclasses.replace 替换 L3Node.card 和 L2Node.card
将字幕写入 card.subtitle 字段。
迁移来源:
TRM3 tools/generate_subtitles.py compute_effective_ranges + assign_subtitles
@@ -299,7 +301,17 @@ def assign_subtitles_voronoi(
right = (ts + next_ts) / 2.0
subtitle_text = extract_subtitle_for_range(entries, (left, right))
l3.subtitle = subtitle_text if subtitle_text else None
l3.card = dataclasses.replace(
l3.card,
subtitle=subtitle_text or "",
)
# L2 字幕聚合:拼接所有 L3 子节点的字幕
l3_subtitles = [l3.card.subtitle for l3 in l2.children if l3.card.subtitle]
l2.card = dataclasses.replace(
l2.card,
subtitle="\n".join(l3_subtitles),
)
logger.debug(
"Voronoi 字幕分配完成: {} 个 L1 节点, {} 条字幕条目",
+5 -4
View File
@@ -97,8 +97,8 @@ def _collect_l3_text(l2_node: L2Node) -> str:
for l3 in l2_node.children:
parts.append(l3.card.frame_summary)
parts.extend(l3.card.visible_text)
if l3.subtitle:
parts.append(l3.subtitle)
if l3.card.subtitle:
parts.append(l3.card.subtitle)
return "\n".join(parts)
@@ -139,8 +139,8 @@ def _collect_descendant_text_corpus(l1_node: L1Node) -> str:
for l3 in l2.children:
parts.append(l3.card.frame_summary)
parts.extend(l3.card.visible_text)
if l3.subtitle:
parts.append(l3.subtitle)
if l3.card.subtitle:
parts.append(l3.card.subtitle)
return "\n".join(parts)
@@ -221,6 +221,7 @@ def _verify_l2(l2: L2Node, stats: VerifyStats) -> None:
visible_text=kept_vt,
spatial_relations=old_card.spatial_relations,
state_changes=old_card.state_changes,
subtitle=old_card.subtitle,
)
+1 -1
View File
@@ -480,7 +480,7 @@ class VideoTreeBuilder:
)
index = TreeIndex(metadata=metadata, roots=l1_nodes)
# Phase 7: 字幕 Voronoi 分配(可选)
# Phase 7: 字幕 Voronoi 分配到 L3/L2 Card.subtitle(可选)
if srt_entries:
assign_subtitles_voronoi(index, srt_entries)
logger.info("字幕 Voronoi 分配完成", n_entries=len(srt_entries))