From da33df92e193d33eab5f204a57879513f1bec1c6 Mon Sep 17 00:00:00 2001 From: lhl Date: Sun, 23 Aug 2026 14:47:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(writer):=20=E9=98=B2=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E4=B8=A2=E7=AB=A0=E5=91=8A=E8=AD=A6=20+=20=E5=8D=A0=E4=BD=8D?= =?UTF-8?q?=E7=AC=A6=E6=AD=A3=E5=88=99=E5=AE=BD=E5=AE=B9=E5=8C=96=EF=BC=88?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99/=E5=85=A8=E8=A7=92=E5=86=92?= =?UTF-8?q?=E5=8F=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 3 ++- src/genesis/parsers/word_template_parser.py | 12 ++++++---- src/genesis/qa/qa_loop.py | 3 ++- src/genesis/writer/docx_injector.py | 5 ++-- src/genesis/writer/orchestrator.py | 18 ++++++++++++++ src/genesis/writer/template_mapper.py | 2 +- tests/test_docx_injector.py | 22 +++++++++++++++++ tests/test_phase5_template_mapper.py | 12 ++++++++++ tests/test_phase5_writer_orchestrator.py | 25 ++++++++++++++++++++ tests/test_word_template_parser.py | 26 +++++++++++++++++++++ 10 files changed, 119 insertions(+), 9 deletions(-) diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index af69d91..f541eba 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -108,4 +108,5 @@ | 2026-08-13 23:20 | 测试验证 | P5-T10 引擎工厂接线:补 engine=None 委托工厂测试(orchestrator/qa_loop),全量 296 passed / 99.04% | tests/test_phase5_writer_orchestrator.py; tests/test_phase5_qa_loop.py | hy3-free | | 2026-08-13 23:35 | 测试验证 | P5-T10 门禁诊断:修复 WriterGenerationError 吞掉底层 LLM 错误(如 401 详情),补透传测试 | src/genesis/writer/writer_agent.py; tests/test_phase5_writer_agent.py | hy3-free | | 2026-08-13 23:50 | Agent 实现 | 修复 HttpLLMClient 在同步门禁中多次 asyncio.run 复用已关闭事件循环致 Event loop is closed:chat 改为每次调用新建 client(保留 async with 协议)| src/genesis/inference/client.py | hy3-free | -| 2026-08-13 23:58 | Agent 实现 | P5-T10 语言对齐:WRITER_PROMPT_TEMPLATE 增加「正文语言须与章节标题一致」约束(模板日文则输出日文)| src/genesis/writer/writer_agent.py; tests/test_phase5_writer_agent.py | hy3-free | \ No newline at end of file +| 2026-08-13 23:58 | Agent 实现 | P5-T10 语言对齐:WRITER_PROMPT_TEMPLATE 增加「正文语言须与章节标题一致」约束(模板日文则输出日文)| src/genesis/writer/writer_agent.py; tests/test_phase5_writer_agent.py | hy3-free | +| 2026-08-23 | Agent 实现 | 7 章模板只注入 2/3/5 章缺陷修复(用户圈定两项):①正则宽容化——word_template_parser.PLACEHOLDER_RE 键名大小写不敏感+支持全角冒号(解析归一为小写 section:id);template_mapper._SECTION_RE 与 docx_injector._SECTION_RE 加 IGNORECASE/全角冒号容忍;②防静默丢章——orchestrator 新增 _warn_unanchored(ctxs),对无 {{section:id}} 锚点章打 WARNING 列章名,generate 与 qa_loop._build 调用;TDD RED(6 failed)→ GREEN(子集 25 passed)→ 全量 304 passed / 99.19% 覆盖,fail_under=99 达标;端到端冒烟 PASS({{Section:1}}/{{section:2}} 注入 + 无锚点章告警) | src/genesis/parsers/word_template_parser.py; src/genesis/writer/template_mapper.py; src/genesis/writer/docx_injector.py; src/genesis/writer/orchestrator.py; src/genesis/qa/qa_loop.py; tests/test_word_template_parser.py; tests/test_phase5_template_mapper.py; tests/test_docx_injector.py; tests/test_phase5_writer_orchestrator.py; _AI_USAGE_LOG.md | deepseek-v4-flash | \ No newline at end of file diff --git a/src/genesis/parsers/word_template_parser.py b/src/genesis/parsers/word_template_parser.py index 4efc9bb..5ac2742 100644 --- a/src/genesis/parsers/word_template_parser.py +++ b/src/genesis/parsers/word_template_parser.py @@ -9,8 +9,12 @@ from docx.oxml.ns import qn from genesis.data_models import ChapterMarker, ParsedTemplate from genesis.parsers._word_common import heading_level -# 统一占位符正则:{{键名}} 或 {{键名:章节名}}(spec §3.2) -PLACEHOLDER_RE = re.compile(r"\{\{([a-z][a-z0-9_]*)(?::([a-z][a-z0-9_]*))?\}\}") +# 统一占位符正则:{{键名}} 或 {{键名:章节名}}(spec §3.2)。 +# 宽容:键名大小写不敏感、分隔符支持半角(:)/全角(:)冒号;解析时归一为小写键名 + 半角冒号。 +PLACEHOLDER_RE = re.compile( + r"\{\{([A-Za-z][A-Za-z0-9_]*)(?:[::]([^}]+?))?\}\}", + re.IGNORECASE, +) class WordTemplateParser: @@ -37,9 +41,9 @@ class WordTemplateParser: for m in PLACEHOLDER_RE.finditer(text): if m.group(2): - key = f"{m.group(1)}:{m.group(2)}" + key = f"{m.group(1).lower()}:{m.group(2).strip()}" else: - key = m.group(1) + key = m.group(1).lower() placeholders[key] = text sections.append(ChapterMarker(type="placeholder", name=key, level=0)) diff --git a/src/genesis/qa/qa_loop.py b/src/genesis/qa/qa_loop.py index 950a97f..30bec49 100644 --- a/src/genesis/qa/qa_loop.py +++ b/src/genesis/qa/qa_loop.py @@ -11,7 +11,7 @@ from genesis.qa.validator import QAValidator from genesis.writer.context_builder import build_contexts from genesis.writer.docx_injector import Block, DocxInjector from genesis.writer.models import ChapterContent -from genesis.writer.orchestrator import _section_id_of +from genesis.writer.orchestrator import _section_id_of, _warn_unanchored from genesis.writer.renderer import render_chapter_blocks from genesis.writer.writer_agent import WriterAgent from genesis.writer.writer_state import WriterState @@ -23,6 +23,7 @@ class QALoop: def _build(self, structured_source, samples_dir, engine, prompt_registry, template_path, output_path, session_id, only_ids=None, prev=None): ctxs = build_contexts(structured_source, samples_dir) + _warn_unanchored(ctxs) state = WriterState([c.chapter_id for c in ctxs]) agent = WriterAgent(session_id=session_id, engine=engine, prompt_registry=prompt_registry, state=state) contents_map = dict(prev) if prev else {} diff --git a/src/genesis/writer/docx_injector.py b/src/genesis/writer/docx_injector.py index 2d3fa71..693583b 100644 --- a/src/genesis/writer/docx_injector.py +++ b/src/genesis/writer/docx_injector.py @@ -21,7 +21,8 @@ from docx.document import Document as DocxDocument from docx.oxml.ns import qn from docx.text.paragraph import Paragraph -_SECTION_RE = re.compile(r"\{\{section:([^}]+)\}\}") +# 宽容:docx 正文锚点可能写为 {{Section:id}} / {{section:id}}(大小写/全角冒号) +_SECTION_RE = re.compile(r"\{\{\s*section\s*[::]\s*([^}]+?)\s*\}\}", re.IGNORECASE) _INLINE_RE = re.compile(r"\{\{([^}]+)\}\}") @@ -63,7 +64,7 @@ class DocxInjector: m = _SECTION_RE.search(para.text) if not m: continue - section_id = m.group(1) + section_id = m.group(1).strip() blocks = sections.get(section_id) if blocks is None: # 未提供该章节内容 → 保留占位符段落,交由残留检查报错 diff --git a/src/genesis/writer/orchestrator.py b/src/genesis/writer/orchestrator.py index 331b8be..1502158 100644 --- a/src/genesis/writer/orchestrator.py +++ b/src/genesis/writer/orchestrator.py @@ -1,6 +1,7 @@ """Writer 编排:上下文装配 → 逐章生成 → 渲染 → docx 注入(Phase 5 垂直切片)。""" from __future__ import annotations +import logging from pathlib import Path from genesis.data_models import StructuredSource @@ -13,6 +14,8 @@ from genesis.writer.renderer import render_chapter_blocks from genesis.writer.writer_agent import WriterAgent from genesis.writer.writer_state import WriterState +_LOGGER = logging.getLogger(__name__) + def _section_id_of(placeholder: str | None) -> str | None: if not placeholder or not placeholder.startswith("section:"): @@ -20,6 +23,20 @@ def _section_id_of(placeholder: str | None) -> str | None: return placeholder[len("section:"):] +def _warn_unanchored(ctxs) -> None: + """防静默丢章:对缺少 {{section:}} 锚点的章节打显式告警。 + + 管线会对模板中每个 Heading 都生成内容,但只有带锚点的章才会注入 docx; + 无锚点章生成后会被丢弃。此函数将其从「静默丢弃」变为「可见告警」。 + """ + unanchored = [ctx.title for ctx in ctxs if not ctx.template_marker.section_placeholder] + if unanchored: + _LOGGER.warning( + "章节已生成但模板缺少 {{section:}} 锚点,内容未注入(静默丢弃):%s", + ", ".join(unanchored), + ) + + class WriteOrchestrator: def generate( self, @@ -34,6 +51,7 @@ class WriteOrchestrator: engine = engine or build_inference_engine() prompt_registry = prompt_registry or PromptRegistry() ctxs = build_contexts(structured_source, samples_dir) + _warn_unanchored(ctxs) state = WriterState([c.chapter_id for c in ctxs]) agent = WriterAgent(session_id=session_id, engine=engine, prompt_registry=prompt_registry, state=state) diff --git a/src/genesis/writer/template_mapper.py b/src/genesis/writer/template_mapper.py index abee43f..6d8c10e 100644 --- a/src/genesis/writer/template_mapper.py +++ b/src/genesis/writer/template_mapper.py @@ -11,7 +11,7 @@ import re from genesis.data_models import ParsedTemplate from genesis.writer.models import ChapterSpec -_SECTION_RE = re.compile(r"^section:(.+)$") +_SECTION_RE = re.compile(r"^section:(.+)$", re.IGNORECASE) def map_template(parsed: ParsedTemplate) -> list[ChapterSpec]: diff --git a/tests/test_docx_injector.py b/tests/test_docx_injector.py index bc9c748..8f12cd8 100644 --- a/tests/test_docx_injector.py +++ b/tests/test_docx_injector.py @@ -48,6 +48,28 @@ def test_section_placeholder_replaced(tmp_path): assert "以下がDB表定义です。" in full_text +def test_section_placeholder_case_insensitive(tmp_path): + # 宽容:docx 中锚点为 {{Section:id}}(大写键)也能注入 + tpl = _make_template(tmp_path, "{{Section:db_design}}") + inj = DocxInjector(tpl) + out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "概要設計書"}) + + full_text = "\n".join(p.text for p in out.paragraphs) + assert "{{Section:db_design}}" not in full_text + assert "3.1 テーブル一覧" in full_text + + +def test_section_placeholder_fullwidth_colon(tmp_path): + # 宽容:docx 中锚点为 {{section:id}}(全角冒号)也能注入 + tpl = _make_template(tmp_path, "{{section:db_design}}") + inj = DocxInjector(tpl) + out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "概要設計書"}) + + full_text = "\n".join(p.text for p in out.paragraphs) + assert "{{section:db_design}}" not in full_text + assert "3.1 テーブル一覧" in full_text + + # ---------- 行内占位符替换 ---------- def test_inline_meta_replaced(tmp_path): diff --git a/tests/test_phase5_template_mapper.py b/tests/test_phase5_template_mapper.py index d40b38a..7a4e2f4 100644 --- a/tests/test_phase5_template_mapper.py +++ b/tests/test_phase5_template_mapper.py @@ -30,3 +30,15 @@ def test_map_template_falls_back_without_placeholder(): specs = map_template(parsed) assert specs[0].chapter_id == "chapter_1" assert specs[0].section_placeholder is None + + +def test_map_template_section_re_case_insensitive(): + # 宽容:占位符键名大小写不敏感(解析端已归一,防御性双保险) + sections = [ + ChapterMarker(type="heading", name="画面一覧", level=1), + ChapterMarker(type="placeholder", name="Section:2", level=0), + ] + parsed = ParsedTemplate(file_name="t.docx", sections=sections, placeholders={}, styles={}) + specs = map_template(parsed) + assert specs[0].chapter_id == "2" + assert specs[0].section_placeholder == "Section:2" diff --git a/tests/test_phase5_writer_orchestrator.py b/tests/test_phase5_writer_orchestrator.py index 498598e..18aa618 100644 --- a/tests/test_phase5_writer_orchestrator.py +++ b/tests/test_phase5_writer_orchestrator.py @@ -83,3 +83,28 @@ def test_generate_delegates_to_factory_when_engine_none(monkeypatch, tmp_path): assert len(contents) == 1 loaded = Document(str(out)) assert "自动生成的内容" in "\n".join(p.text for p in loaded.paragraphs) + + +def test_generate_warns_on_unanchored_heading(tmp_path, caplog): + # 防静默丢章:无 {{section:id}} 锚点的章 → 打 WARNING 并列出章名 + tpl = tmp_path / "tpl.docx" + out = tmp_path / "out.docx" + doc = Document() + doc.add_paragraph("附録", style="Heading 1") + doc.save(str(tpl)) + parsed = ParsedTemplate( + file_name=str(tpl), + sections=[ChapterMarker(type="heading", name="附録", level=1)], + placeholders={}, + styles={"defined": ["Heading 1"], "used": ["Heading 1"]}, + ) + orch = WriteOrchestrator() + with caplog.at_level("WARNING", logger="genesis.writer.orchestrator"): + orch.generate( + SimpleNamespace(template=parsed), + str(out), + samples_dir="nonexistent_dir_xyz", + engine=FakeEngine(), + template_path=str(tpl), + ) + assert any("附録" in r.message for r in caplog.records) diff --git a/tests/test_word_template_parser.py b/tests/test_word_template_parser.py index 90cae49..3fbc730 100644 --- a/tests/test_word_template_parser.py +++ b/tests/test_word_template_parser.py @@ -104,3 +104,29 @@ def test_parse_styles_collected(tmp_path): assert "Heading 1" in result.styles["used"] assert "Normal" in result.styles["used"] assert "Heading 1" in result.styles["defined"] + + +def test_parse_extracts_placeholder_case_insensitive(tmp_path): + # 宽容:键名大小写不敏感 → 归一为小写 section:id + doc = new_document() + doc.add_paragraph("{{Section:2}}") + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert result.placeholders == {"section:2": "{{Section:2}}"} + ph = [s for s in result.sections if s.type == "placeholder"] + assert [s.name for s in ph] == ["section:2"] + + +def test_parse_extracts_placeholder_fullwidth_colon(tmp_path): + # 宽容:全角冒号(:)也视为分隔符 → 归一为半角 section:id + doc = new_document() + doc.add_paragraph("{{section:2}}") + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert result.placeholders == {"section:2": "{{section:2}}"} + ph = [s for s in result.sections if s.type == "placeholder"] + assert [s.name for s in ph] == ["section:2"]