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:
+37
-15
@@ -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 dataclass(Card 类型)
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user