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
+29
View File
@@ -213,3 +213,32 @@ def test_impact_introduction_shows_all_elements():
report = ImpactReport(metadata={}, change_analysis=ca, summary={"new": 2})
impact = _ctx("introduction", "はじめに", source=None, report=report).to_vars()["impact"]
assert "T001" in impact and "F001" in impact
# ---------- 子节结构注入(design.md §6.5H2/H3 归入本章) ----------
def test_chapter_spec_sub_headings_default_empty():
s = ChapterSpec(chapter_id="x", title="X")
assert s.sub_headings == []
def test_to_vars_exposes_sub_headings():
spec = ChapterSpec(
chapter_id="function_list", title="2. 機能一覧",
section_placeholder="section:function_list",
sub_headings=["2.1 機能一覧表", "2.2 機能詳細"],
)
ctx = GenerationContext(
chapter_id=spec.chapter_id, title=spec.title,
template_marker=spec,
structured_source=None, write_rules=[], design_rules=[],
template_styles=set(),
)
v = ctx.to_vars()
assert "2.1 機能一覧表" in v["sub_headings"]
assert "2.2 機能詳細" in v["sub_headings"]
def test_to_vars_sub_headings_empty_when_none():
vars_ = _ctx("db_design", "DB設計").to_vars()
assert vars_["sub_headings"] == ""
+43
View File
@@ -42,3 +42,46 @@ def test_map_template_section_re_case_insensitive():
specs = map_template(parsed)
assert specs[0].chapter_id == "2"
assert specs[0].section_placeholder == "Section:2"
# ---------- design.md §6.5H1 起章,H2/H3 归入本章作为子节(不再独立成章被丢弃) ----------
def test_h2_headings_merge_into_parent_chapter():
sections = [
ChapterMarker(type="heading", name="2. 機能一覧", level=1),
ChapterMarker(type="placeholder", name="section:function_list", level=0),
ChapterMarker(type="heading", name="2.1 機能一覧表", level=2),
ChapterMarker(type="heading", name="2.2 機能詳細", level=2),
]
parsed = ParsedTemplate(file_name="t.docx", sections=sections, placeholders={}, styles={})
specs = map_template(parsed)
assert len(specs) == 1
s = specs[0]
assert s.chapter_id == "function_list"
assert s.title == "2. 機能一覧"
assert s.sub_headings == ["2.1 機能一覧表", "2.2 機能詳細"]
def test_h3_headings_also_merge():
sections = [
ChapterMarker(type="heading", name="5. DB設計", level=1),
ChapterMarker(type="placeholder", name="section:db_design", level=0),
ChapterMarker(type="heading", name="5.1 テーブル一覧", level=2),
ChapterMarker(type="heading", name="5.1.1 補足", level=3),
]
parsed = ParsedTemplate(file_name="t.docx", sections=sections, placeholders={}, styles={})
specs = map_template(parsed)
assert len(specs) == 1
assert specs[0].sub_headings == ["5.1 テーブル一覧", "5.1.1 補足"]
def test_leading_h2_without_parent_starts_chapter():
"""异常模板防御:任何 H1 之前出现 H2 → 回落为独立章(不崩溃、不静默丢弃)。"""
sections = [
ChapterMarker(type="heading", name="孤立节", level=2),
ChapterMarker(type="heading", name="1. はじめに", level=1),
]
parsed = ParsedTemplate(file_name="t.docx", sections=sections, placeholders={}, styles={})
specs = map_template(parsed)
assert [s.title for s in specs] == ["孤立节", "1. はじめに"]
assert specs[0].sub_headings == []
+6
View File
@@ -75,6 +75,12 @@ def test_prompt_template_enforces_topic_and_chapter_data():
assert "{{source}}" not in WRITER_PROMPT_TEMPLATE
def test_prompt_template_has_sub_headings_var():
# 子节结构(design.md §6.5:H2/H3 归入本章):模板必须提供小节变量并指导按小节组织
assert "{{sub_headings}}" in WRITER_PROMPT_TEMPLATE
assert "小节" in WRITER_PROMPT_TEMPLATE
class RecordingEngine(FakeEngine):
"""记录 variables 以断言 prompt 渲染输入。"""
+3 -2
View File
@@ -15,8 +15,9 @@ def test_run_trial_fake_produces_docx_and_report(tmp_path):
"--impact-report", str(report),
])
# 章数:真实概要设计书模板 13
assert result["chapters"] == 13
# 章数:真实概要设计书模板按 §6.5 归并后为 7 个 H1
#(H2/H3 子节归入父章,不再独立成章被丢弃)
assert result["chapters"] == 7
# 影响调查 summary 与 MVP 基线一致
assert result["summary"] == {
"total": 16, "new": 5, "modified": 8, "deleted": 3,