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
+70
View File
@@ -49,6 +49,7 @@ class DocxInjector:
def inject(self, sections: dict[str, list[Block]], meta: dict[str, str]) -> DocxDocument:
doc = Document(self._template_path)
self._inject_sections(doc, sections)
self._dedupe_bare_subheadings(doc)
self._inject_inline(doc, meta)
# 残留检查(design §6.6 规范约束)
@@ -57,6 +58,75 @@ class DocxInjector:
raise DocxInjectError(f"占位符残留未替换:{residue}")
return doc
# ---------- 裸子节去重(design §6.5H2/H3 归并进父章生成后) ----------
@staticmethod
def _heading_level(style_name: str | None) -> int | None:
"""样式名 → Heading 层级;非 Heading 样式返回 None。"""
if not style_name or not style_name.startswith("Heading"):
return None
tail = style_name[len("Heading"):].strip()
try:
return int(tail)
except ValueError:
return None
def _iter_body_items(self, doc: DocxDocument):
"""按文档顺序产出 (element, kind, text, style)。kind: "p" | "tbl""""
for child in doc.element.body.iterchildren():
if child.tag == qn("w:p"):
para = Paragraph(child, doc)
style = para.style.name if para.style is not None else ""
yield child, "p", para.text.strip(), style
elif child.tag == qn("w:tbl"):
yield child, "tbl", "", ""
def _dedupe_bare_subheadings(self, doc: DocxDocument) -> None:
"""删除「裸重复子节标题」:同一 H1 章内,与更早的同级同名标题重复、
且其后到下一个标题/表格之间无任何实质内容的模板自带 H2/H3。
背景:§6.5 将 H2/H3 归并进父章生成(生成内容含小节标题),模板原有
空 H2/H3 会与之重复。仅删完全空的重复标题(保守:模板子节下有内容则保留)。
"""
items = list(self._iter_body_items(doc))
to_remove: set = set()
last_h1_idx = -1
for i, (_, kind, text, style) in enumerate(items):
if kind != "p":
continue
level = self._heading_level(style)
if level is None:
continue
if level <= 1:
last_h1_idx = i
continue
# 裸判定:直到下一个标题/表格前,只有空段落
bare = True
for j in range(i + 1, len(items)):
_el2, kind2, text2, style2 = items[j]
if kind2 == "tbl":
bare = False
break
if self._heading_level(style2) is not None:
break
if text2:
bare = False
break
if not bare or text == "":
continue
# 同章内存在更早的同级同名标题(即注入生成的那份,带内容)
dup = any(
items[k][1] == "p"
and self._heading_level(items[k][3]) == level
and items[k][2] == text
and last_h1_idx < k < i
for k in range(last_h1_idx + 1, i)
)
if dup:
to_remove.add(items[i][0])
for el in to_remove:
el.getparent().remove(el)
# ---------- 内部 ----------
def _inject_sections(self, doc: DocxDocument, sections: dict[str, list[Block]]) -> None:
+4
View File
@@ -95,6 +95,7 @@ class ChapterSpec:
chapter_id: str
title: str
section_placeholder: str | None = None # 如 "{{section:db_design}}",无则 None
sub_headings: list[str] = field(default_factory=list) # 本章 H2/H3 子节标题(§6.5 归并)
@dataclass
@@ -120,6 +121,9 @@ class GenerationContext:
"write_rules": "\n".join(self.write_rules),
"design_rules": "\n".join(self.design_rules),
"template_styles": ", ".join(sorted(self.template_styles)),
"sub_headings": "\n".join(
f"- {h}" for h in (getattr(tm, "sub_headings", None) or [])
),
"prior_state": str(self.prior_state) if self.prior_state is not None else "",
"data": _format_chapter_data(
self.structured_source,
+9
View File
@@ -3,6 +3,10 @@
真实 ParsedTemplate.sections 为 ChapterMarker 列表;章节由 type=="heading" 起,
紧随其后的 type=="placeholder" 且形如 `section:<id>` 的标记归属该章,
用于确定 chapter_id 与 section_placeholder(语言无关、按文档顺序)。
design.md §6.5 映射规则:仅 Heading level<=1 起章(1 章 = 1 次生成循环);
level>=2 的节/小节归入当前章 sub_headings,随本章一并生成——避免无
{{section:id}} 锚点的子章「生成后静默丢弃」。
"""
from __future__ import annotations
@@ -21,6 +25,11 @@ def map_template(parsed: ParsedTemplate) -> list[ChapterSpec]:
for ch in getattr(parsed, "sections", []):
t = getattr(ch, "type", None)
if t == "heading":
level = int(getattr(ch, "level", 1) or 1)
if level > 1 and current is not None:
# §6.5:节/小节归入父章,不独立成章
current.sub_headings.append(getattr(ch, "name", ""))
continue
idx += 1
current = ChapterSpec(
chapter_id=f"chapter_{idx}", title=getattr(ch, "name", ""), section_placeholder=None
+4
View File
@@ -18,12 +18,16 @@ from genesis.writer.exceptions import WriterGenerationError
WRITER_PROMPT_TEMPLATE = (
"你是概要设计书撰写专家。\n"
"章节: {{chapter_id}} {{title}}\n"
"本章小节结构:\n{{sub_headings}}\n"
"写入规则:\n{{write_rules}}\n"
"设计规则:\n{{design_rules}}\n"
"模板样式:\n{{template_styles}}\n"
"影响调查上下文:\n{{impact}}\n"
"参考资料(本章对应数据):\n{{data}}\n"
"请输出符合 schema 的章节内容 JSON。\n"
"【小节约束】若上方「本章小节结构」非空,必须按该小节顺序组织内容,"
"每个小节以 type=heading、level=2 的内容块开头(标题使用小节原文),随后为该小节的内容块;"
"不得遗漏或新增小节。为空时按章节主题自行组织。\n"
"【主题约束】本章必须且仅围绕标题「{{title}}」所对应的主题撰写,"
"严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;"
"禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。"