From 5091125fbc056bb68a2472c457135e261a638f15 Mon Sep 17 00:00:00 2001 From: lhl Date: Mon, 24 Aug 2026 13:19:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(writer):=20=E6=97=A0=E5=AD=90=E8=8A=82?= =?UTF-8?q?=E7=AB=A0=E5=89=94=E9=99=A4=20LLM=20=E8=87=AA=E9=80=A0=20headin?= =?UTF-8?q?g=20=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - writer_agent: prompt【小节约束】明确空结构时不得输出 heading 块 - writer_agent: generate_chapter 程序化强制——template_marker.sub_headings 为空的章,剔除生成结果中全部 type=heading 的内容块(LLM 遵循度不完美, 以模板结构为准做确定性兜底) - 真实试运行验证:自造与章同名 H2 清零、H2 总数 14→6(仅模板定义子节)、 内容引用不受影响 --- _AI_USAGE_LOG.md | 1 + src/genesis/writer/writer_agent.py | 7 ++++- tests/test_phase5_writer_agent.py | 48 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 3ffe598..e470898 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -116,3 +116,4 @@ | 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) | +| 2026-08-24 13:18 | Agent 实现 | 修复无子节章 LLM 自造标题:prompt【小节约束】禁止空结构时输出 heading 块(尽力约束)+ WriterAgent.generate_chapter 程序化强制(sub_headings 为空的章剔除生成结果中的 heading 块,模板结构为准)。真实试运行验证:自造同名 H2 清零、H2 总数 14→6(仅模板定义的子节)、无子节章正文全为段落 | src/genesis/writer/writer_agent.py, tests/test_phase5_writer_agent.py | x-preview-f-free (opencode) | diff --git a/src/genesis/writer/writer_agent.py b/src/genesis/writer/writer_agent.py index 9a7e33f..5287229 100644 --- a/src/genesis/writer/writer_agent.py +++ b/src/genesis/writer/writer_agent.py @@ -27,7 +27,8 @@ WRITER_PROMPT_TEMPLATE = ( "请输出符合 schema 的章节内容 JSON。\n" "【小节约束】若上方「本章小节结构」非空,必须按该小节顺序组织内容," "每个小节以 type=heading、level=2 的内容块开头(标题使用小节原文),随后为该小节的内容块;" - "不得遗漏或新增小节。为空时按章节主题自行组织。\n" + "不得遗漏或新增小节。若「本章小节结构」为空,则不得输出任何 type=heading 的内容块" + "(章节标题已由模板提供),仅以 paragraph/table/list/note 块组织内容。\n" "【主题约束】本章必须且仅围绕标题「{{title}}」所对应的主题撰写," "严格以「参考资料(本章对应数据)」中的要件定义数据和「影响调查上下文」为核心依据;" "禁止输出与本章无关的系统整体架构、通用设计说明等内容,禁止套用其他章节的主题。" @@ -143,6 +144,10 @@ class WriterAgent: except (KeyError, TypeError, ValueError) as e: last_err = e continue + # 模板结构为准(design §6.5):无子节结构的章,剔除 LLM 自造的 + # heading 块(prompt 约束为尽力而为,此处程序化强制) + if not (getattr(context.template_marker, "sub_headings", None) or []): + content.blocks = [b for b in content.blocks if b.type != "heading"] self.state.record_success(content) return content raise WriterGenerationError(f"章节 {context.chapter_id} 重试耗尽: {last_err}") diff --git a/tests/test_phase5_writer_agent.py b/tests/test_phase5_writer_agent.py index 1b03dbc..615075e 100644 --- a/tests/test_phase5_writer_agent.py +++ b/tests/test_phase5_writer_agent.py @@ -81,6 +81,54 @@ def test_prompt_template_has_sub_headings_var(): assert "小节" in WRITER_PROMPT_TEMPLATE +def test_prompt_template_forbids_heading_blocks_when_no_sub_headings(): + # 无子节的章:LLM 不得自造 heading 块(真实试运行暴露:帳票一覧/バッチ一覧 + # 章内出现与章同名的自造 H2) + assert "不得输出任何 type=heading 的内容块" in WRITER_PROMPT_TEMPLATE + + +class HeadingEngine(FakeEngine): + """返回含 heading 块的输出(模拟 LLM 自造标题行为)。""" + + def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2): + return SimpleNamespace( + data={ + "title": variables["title"], + "blocks": [ + {"type": "heading", "level": 2, "text": variables["title"]}, + {"type": "paragraph", "text": "正文内容"}, + ], + }, + status="ok", + ) + + +def test_generate_chapter_drops_headings_when_no_sub_headings(): + """无子节结构的章:LLM 自造的 heading 块被程序化剔除(模板结构为准)。""" + agent = WriterAgent( + session_id="s", engine=HeadingEngine(), + prompt_registry=FakePromptRegistry(), + state=WriterState(["batch_list"]), + ) + ctx = _ctx("batch_list", "7. バッチ一覧") + content = agent.generate_chapter(ctx) + assert all(b.type != "heading" for b in content.blocks) + assert [b.text for b in content.blocks] == ["正文内容"] + + +def test_generate_chapter_keeps_headings_when_sub_headings_exist(): + """有子节结构的章:heading 块保留(按小节组织的内容)。""" + agent = WriterAgent( + session_id="s", engine=HeadingEngine(), + prompt_registry=FakePromptRegistry(), + state=WriterState(["function_list"]), + ) + ctx = _ctx("function_list", "2. 機能一覧") + ctx.template_marker.sub_headings = ["2.1 機能一覧表"] + content = agent.generate_chapter(ctx) + assert any(b.type == "heading" for b in content.blocks) + + class RecordingEngine(FakeEngine): """记录 variables 以断言 prompt 渲染输入。"""