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
+1
View File
@@ -115,3 +115,4 @@
| 2026-08-23 | Agent 实现 | CodeParser 多语言就绪预备性重构(行为不变):code_parser.py 重构为语言适配器注册表——BaseLanguageParser 抽象基类(extensions/source_files/parse_file 契约)+ JavaLanguageParser(迁移全部 Java 正则,输出与重构前逐字节一致:20Ctrl/25Svc/22Ent/113EP/13模块/219类/278imports+ LANGUAGE_PARSERS 注册表 + register_language_parser 扩展点 + CodeParser 分发器(parse(root, language=None) 自动按扩展名探测/显式语言覆盖/多语言合并 language 逗号连接/无源码与不支持语言抛 CodeParseError);source_aggregator 新增 existing_system_language 透传参数;config.py allowed_extensions 补 .py/.ts/.go/.cs;删除自动探测下不可达 continue 死分支;TDD REDImportError)→ GREENtest_code_parser 17 passed + test_source_aggregator 19 passed)→ 全量 362 passed / 99.28%(基线 351/99.27%+11 新测试);门禁(fake 模式)复跑 PASSsummary 与 MVP 基线一致(total=16 new=5 modified=8 deleted=3 unchanged=50 warnings=0),概要设计书 13 章;未实现任何新语言(仅 Java,后续加语言=新增适配器类+register_language_parser,下游零改动) | src/genesis/impact/code_parser.py; src/genesis/parsers/source_aggregator.py; src/genesis/config.py; tests/test_code_parser.py; tests/test_source_aggregator.py; _AI_USAGE_LOG.md | deepseek-v4-flash |
| 2026-08-24 | Agent 实现 | 试运行脚本与运行说明:新增 scripts/run_trial.py(库级端到端驱动——argparse 默认指向 samples/ 股票追加改修+sunOnly+真实模板;--fake 离线 FakeEngine / 默认 build_inference_engine 真实 LLM 读 .env keySourceParser→WriteOrchestrator 门控自动影响调查→output/output.docx + impact-report.json;暴露 main(argv) 供测试);新增 tests/test_run_trial.pyfake 模式断言 13 章 + summary 16/5/8/3/50/0 + 文件产出 + docx 注入);.gitignore 新增 output/(试运行产物不入库);新建 README.md(安装/配置 .env/离线与真实试运行命令/输出/测试);TDD REDModuleNotFoundError)→ GREEN2 passed)→ 全量 364 passed / 99.28%fake 试运行 PASS13 章、summary 与 MVP 基线一致、output.docx 含注入文本);真实 LLM 试运行待用户配置 .env API Key | scripts/run_trial.py; tests/test_run_trial.py; .gitignore; README.md; _AI_USAGE_LOG.md | deepseek-v4-flash |
| 2026-08-24 11:50 | Agent 实现 | Writer 章节级数据注入(design §6.8 ①落地):CHAPTER_SHEET_TYPES/CHAPTER_IMPACT_ELEMENT 映射、_format_chapter_data 按章定向注入 ExcelTable、_format_impact 按要素类型过滤;修复关键缺陷——真实 PromptRegistry.get 返回模板字符串导致引擎从不渲染变量(LLM 收到 {{占位符}} 原文),_resolve_prompt 统一包装为 Prompt 对象;prompt 增加【主题约束】。真实试运行验证:13 章主题全部正确(機能/画面/帳票/DB/IF/バッチ各写其义)、引用 F001-F004/TB001-004 影响数据、语言漂移顺带消除 | src/genesis/writer/models.py, src/genesis/writer/writer_agent.py, tests/test_phase5_models.py, tests/test_phase5_writer_agent.py, tests/test_phase5_context_builder.py | x-preview-f-free (opencode) |
| 2026-08-24 12:23 | Agent 实现 | 遗留项修复——无锚点子章静默丢弃:template_mapper 按 design §6.5 仅 H1 起章、H2/H3 归并父章 sub_headingsto_vars 注入小节结构 + prompt【小节约束】按小节组织内容;DocxInjector 新增裸重复子节标题去重(模板空 H2/H3 与生成同名且无内容时删除)。真实试运行:13→7 章、无静默丢弃告警、14 个 H2 无重复 | src/genesis/writer/template_mapper.py, src/genesis/writer/models.py, src/genesis/writer/writer_agent.py, src/genesis/writer/docx_injector.py, tests/test_phase5_template_mapper.py, tests/test_phase5_models.py, tests/test_phase5_writer_agent.py, tests/test_docx_injector.py, tests/test_run_trial.py | x-preview-f-free (opencode) |
+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}}」所对应的主题撰写,"
"严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;"
"禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。"
+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,