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:
@@ -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
|
||||
|
||||
@@ -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.5:H2/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"] == ""
|
||||
|
||||
@@ -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.5:H1 起章,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 == []
|
||||
|
||||
@@ -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 渲染输入。"""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user