fix(writer): 无锚点子章归并父章生成 + 注入后裸重复子节去重

- template_mapper: 按 design §6.5 仅 Heading level<=1 起章,H2/H3 归入
  父章 sub_headings(此前每个 heading 独立成章,无 {{section:id}} 锚点的
  6 个子章生成后被静默丢弃)
- models: ChapterSpec.sub_headings 字段;to_vars 暴露 sub_headings 变量
- writer_agent: prompt 新增【小节约束】(有子节时按小节顺序以 level=2
  heading 组织,不得遗漏或新增)
- docx_injector: 注入后删除同章内与已生成标题同名且完全无内容的模板裸
  H2/H3(保守策略:带内容的模板子节保留)
- 真实试运行验证:7 章 / 无静默丢弃告警 / 14 个 H2 无重复
This commit is contained in:
lhl
2026-08-24 12:23:35 +08:00
parent 80ccc324fc
commit 0a498e4f7a
10 changed files with 235 additions and 2 deletions
+66
View File
@@ -117,3 +117,69 @@ def test_block_accepts_table_list_note():
assert Block(kind="table", text="t", rows=[["x"]]).kind == "table"
assert Block(kind="list", text="a\nb").kind == "list"
assert Block(kind="note", text="n").kind == "note"
# ---------- 裸子节去重(design §6.5:H2 归并生成后,模板原有空子节标题删除) ----------
def _make_subheading_template(tmp_path) -> str:
"""模板:H1 章 → 锚点 → 模板自带两个空 H2 子节(无任何内容)。"""
doc = Document()
doc.add_paragraph("{{doc_title}}")
doc.add_paragraph("2. 機能一覧", style="Heading 1")
doc.add_paragraph("{{section:function_list}}")
doc.add_paragraph("2.1 機能一覧表", style="Heading 2") # 裸模板子节
doc.add_paragraph("2.2 機能詳細", style="Heading 2") # 裸模板子节
path = tmp_path / "tpl_sub.docx"
doc.save(str(path))
return str(path)
def _subheading_blocks() -> list[Block]:
return [
Block(kind="heading", text="2.1 機能一覧表", level=2),
Block(kind="paragraph", text="機能一覧の内容"),
Block(kind="heading", text="2.2 機能詳細", level=2),
Block(kind="paragraph", text="機能詳細の内容"),
]
def test_bare_duplicate_template_subheadings_removed(tmp_path):
tpl = _make_subheading_template(tmp_path)
out = DocxInjector(tpl).inject(
{"function_list": _subheading_blocks()}, {"doc_title": "T"}
)
h2_texts = [p.text.strip() for p in out.paragraphs if p.style.name == "Heading 2"]
# 生成的同名 H2 已带内容,模板原有的空 H2 应被删除(不重复)
assert h2_texts.count("2.1 機能一覧表") == 1
assert h2_texts.count("2.2 機能詳細") == 1
joined = "\n".join(p.text for p in out.paragraphs)
assert "機能一覧の内容" in joined and "機能詳細の内容" in joined
def test_non_duplicate_bare_subheading_kept(tmp_path):
"""模板独有的裸子节(本章未生成同名标题)→ 保留,不误删。"""
doc = Document()
doc.add_paragraph("{{doc_title}}")
doc.add_paragraph("{{section:x}}")
doc.add_paragraph("付録注記", style="Heading 2") # 模板独有裸子节
path = tmp_path / "tpl_keep.docx"
doc.save(str(path))
blocks = [Block(kind="paragraph", text="章内容")]
out = DocxInjector(str(path)).inject({"x": blocks}, {"doc_title": "T"})
assert any(p.text.strip() == "付録注記" for p in out.paragraphs)
def test_duplicate_subheading_with_content_not_removed(tmp_path):
"""后出现的重复子节若带内容(非裸)→ 保留内容,只删纯重复标题场景之外不动。"""
doc = Document()
doc.add_paragraph("{{doc_title}}")
doc.add_paragraph("{{section:x}}")
doc.add_paragraph("2.1 表", style="Heading 2")
doc.add_paragraph("テーブル定義は別紙参照。") # 模板子节下有实质内容 → 非裸
path = tmp_path / "tpl_content.docx"
doc.save(str(path))
blocks = [Block(kind="heading", text="2.1 表", level=2), Block(kind="paragraph", text="生成内容")]
out = DocxInjector(str(path)).inject({"x": blocks}, {"doc_title": "T"})
texts = [p.text for p in out.paragraphs]
# 模板子节有内容 → 不删(保守策略:仅删除完全空的重复标题)
assert "テーブル定義は別紙参照。" in texts