From 0a498e4f7a2c29efca6bd836dfd38c1c6b02fbb6 Mon Sep 17 00:00:00 2001 From: lhl Date: Mon, 24 Aug 2026 12:23:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(writer):=20=E6=97=A0=E9=94=9A=E7=82=B9?= =?UTF-8?q?=E5=AD=90=E7=AB=A0=E5=BD=92=E5=B9=B6=E7=88=B6=E7=AB=A0=E7=94=9F?= =?UTF-8?q?=E6=88=90=20+=20=E6=B3=A8=E5=85=A5=E5=90=8E=E8=A3=B8=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=AD=90=E8=8A=82=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 无重复 --- _AI_USAGE_LOG.md | 1 + src/genesis/writer/docx_injector.py | 70 +++++++++++++++++++++++++++ src/genesis/writer/models.py | 4 ++ src/genesis/writer/template_mapper.py | 9 ++++ src/genesis/writer/writer_agent.py | 4 ++ tests/test_docx_injector.py | 66 +++++++++++++++++++++++++ tests/test_phase5_models.py | 29 +++++++++++ tests/test_phase5_template_mapper.py | 43 ++++++++++++++++ tests/test_phase5_writer_agent.py | 6 +++ tests/test_run_trial.py | 5 +- 10 files changed, 235 insertions(+), 2 deletions(-) diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 83abf43..3ffe598 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -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 RED(ImportError)→ GREEN(test_code_parser 17 passed + test_source_aggregator 19 passed)→ 全量 362 passed / 99.28%(基线 351/99.27%,+11 新测试);门禁(fake 模式)复跑 PASS:summary 与 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 key;SourceParser→WriteOrchestrator 门控自动影响调查→output/output.docx + impact-report.json;暴露 main(argv) 供测试);新增 tests/test_run_trial.py(fake 模式断言 13 章 + summary 16/5/8/3/50/0 + 文件产出 + docx 注入);.gitignore 新增 output/(试运行产物不入库);新建 README.md(安装/配置 .env/离线与真实试运行命令/输出/测试);TDD RED(ModuleNotFoundError)→ GREEN(2 passed)→ 全量 364 passed / 99.28%;fake 试运行 PASS(13 章、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_headings;to_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) | diff --git a/src/genesis/writer/docx_injector.py b/src/genesis/writer/docx_injector.py index 693583b..fae6ea9 100644 --- a/src/genesis/writer/docx_injector.py +++ b/src/genesis/writer/docx_injector.py @@ -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.5:H2/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: diff --git a/src/genesis/writer/models.py b/src/genesis/writer/models.py index af1cbb0..6342161 100644 --- a/src/genesis/writer/models.py +++ b/src/genesis/writer/models.py @@ -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, diff --git a/src/genesis/writer/template_mapper.py b/src/genesis/writer/template_mapper.py index 6d8c10e..31891e8 100644 --- a/src/genesis/writer/template_mapper.py +++ b/src/genesis/writer/template_mapper.py @@ -3,6 +3,10 @@ 真实 ParsedTemplate.sections 为 ChapterMarker 列表;章节由 type=="heading" 起, 紧随其后的 type=="placeholder" 且形如 `section:` 的标记归属该章, 用于确定 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 diff --git a/src/genesis/writer/writer_agent.py b/src/genesis/writer/writer_agent.py index 20bbf68..9a7e33f 100644 --- a/src/genesis/writer/writer_agent.py +++ b/src/genesis/writer/writer_agent.py @@ -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}}」所对应的主题撰写," "严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;" "禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。" diff --git a/tests/test_docx_injector.py b/tests/test_docx_injector.py index 8f12cd8..4303701 100644 --- a/tests/test_docx_injector.py +++ b/tests/test_docx_injector.py @@ -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 diff --git a/tests/test_phase5_models.py b/tests/test_phase5_models.py index a9ada90..978e175 100644 --- a/tests/test_phase5_models.py +++ b/tests/test_phase5_models.py @@ -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"] == "" diff --git a/tests/test_phase5_template_mapper.py b/tests/test_phase5_template_mapper.py index 7a4e2f4..54735ef 100644 --- a/tests/test_phase5_template_mapper.py +++ b/tests/test_phase5_template_mapper.py @@ -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 == [] diff --git a/tests/test_phase5_writer_agent.py b/tests/test_phase5_writer_agent.py index 92b3a53..1b03dbc 100644 --- a/tests/test_phase5_writer_agent.py +++ b/tests/test_phase5_writer_agent.py @@ -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 渲染输入。""" diff --git a/tests/test_run_trial.py b/tests/test_run_trial.py index 9dccf78..25f9494 100644 --- a/tests/test_run_trial.py +++ b/tests/test_run_trial.py @@ -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,