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):