fix(writer): 防静默丢章告警 + 占位符正则宽容化(大小写/全角冒号)
This commit is contained in:
+2
-1
@@ -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 |
|
||||
| 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 |
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
# 未提供该章节内容 → 保留占位符段落,交由残留检查报错
|
||||
|
||||
@@ -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:<id>}} 锚点的章节打显式告警。
|
||||
|
||||
管线会对模板中每个 Heading 都生成内容,但只有带锚点的章才会注入 docx;
|
||||
无锚点章生成后会被丢弃。此函数将其从「静默丢弃」变为「可见告警」。
|
||||
"""
|
||||
unanchored = [ctx.title for ctx in ctxs if not ctx.template_marker.section_placeholder]
|
||||
if unanchored:
|
||||
_LOGGER.warning(
|
||||
"章节已生成但模板缺少 {{section:<id>}} 锚点,内容未注入(静默丢弃):%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)
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user