From 00fcbf18c352e39d192c0c6ae304a304ee609171 Mon Sep 17 00:00:00 2001 From: lhl Date: Mon, 10 Aug 2026 09:38:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20WordTemplateParser=20=E7=AB=A0=E6=9E=84?= =?UTF-8?q?=E6=88=90/=E5=8D=A0=E4=BD=8D=E7=AC=A6/=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=90=8D=E6=8F=90=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 2 + src/genesis/parsers/_word_common.py | 11 ++ src/genesis/parsers/word_template_parser.py | 57 +++++++++++ tests/test_word_template_parser.py | 106 ++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/genesis/parsers/_word_common.py create mode 100644 src/genesis/parsers/word_template_parser.py create mode 100644 tests/test_word_template_parser.py diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index e5acdb6..8eccb84 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -62,3 +62,5 @@ | 2026-08-09 | 架构设计 | Phase3 Word 解析优先设计(brainstorming):范围澄清(Word 解析优先=3.1/3.2/3.6/3.7,PPT/现有系统探索后续;规则分类按来源映射零LLM;SourceAggregator 全量整合 StructuredSource;样式名级提取;方案A 三模块门面聚合);输出设计文档 docs/superpowers/specs/2026-08-09-phase3-word-parser-design.md | docs/superpowers/specs/2026-08-09-phase3-word-parser-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-09 | 架构设计 | Phase3 spec 工程完备性审阅(用户审阅关卡):实测 samples 3 个 docx(模板7H1+占位符+1书签、记入规则全 H1+・列表、说明书纯文本+H1);修正 P0-1 做成说明书分类 ref→write(对齐 api-design §2.2 write_instruction RAG 归类 Type A 写入规则);P1-1 列表检测双通道(・前缀+List Bullet 样式);P1-2 SourceParser 显式角色参数对齐 file_type 枚举;P1-3 fail_under=99 覆盖路径;统一占位符正则;新增 docx_helpers.py 测试基建 | docs/superpowers/specs/2026-08-09-phase3-word-parser-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-09 | Agent 实现 | Phase3 Word 解析优先实施计划(writing-plans):5 任务 TDD(docx_helpers 基建 / WordTemplateParser / RuleDocParser / SourceParser 门面 / 真实样本集成测试);实证验证 python-docx 关键点(body 级遍历 Paragraph/Table 构造、书签 XML、样式提取、List Bullet 可用);清理 Task2 占位写法;输出 docs/superpowers/plans/2026-08-09-phase3-word-parser.md | docs/superpowers/plans/2026-08-09-phase3-word-parser.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-10 | Agent 实现 | Task1 补记:Word 解析测试基建。新建 tests/docx_helpers.py(new_document/save_document/make_rule_doc,make_rule_doc 支持 H1/H2/H3/List Bullet/普通段落 5 种行),供 Phase3 后续全部任务复用 | tests/docx_helpers.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-10 | Agent 实现 | Phase3 Task2 实现:WordTemplateParser 章构成/占位符/样式名提取。新建 src/genesis/parsers/_word_common.py(heading_level 共享 helper,Task2/3 复用,从 Heading N 样式名解析大纲级别,非数字/无后缀兜底 1)与 src/genesis/parsers/word_template_parser.py(PLACEHOLDER_RE 统一占位符正则 {{键名}}/{{键名:章节名}};Heading 段落→heading 章标记、bookmarkStart→bookmark、占位符→placeholder 并写 placeholders{名:段落上下文};styles 提取 defined/used 样式名集合去重);tests/test_word_template_parser.py 按 brief 8 用例(6 解析 + 2 heading_level 兜底分支);覆盖补齐:bookmark 用例增加无 name 的 bookmarkStart 覆盖 if name 假分支(98%→100%);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.parsers._word_common')→ GREEN(聚焦 8 passed);pytest 全量 140 passed 覆盖 100.00%(841 stmts/200 br),fail_under=99 达标 | src/genesis/parsers/_word_common.py, src/genesis/parsers/word_template_parser.py, tests/test_word_template_parser.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | diff --git a/src/genesis/parsers/_word_common.py b/src/genesis/parsers/_word_common.py new file mode 100644 index 0000000..8f16119 --- /dev/null +++ b/src/genesis/parsers/_word_common.py @@ -0,0 +1,11 @@ +"""Word 解析共享小工具(WordTemplateParser / RuleDocParser 复用)。""" + +from __future__ import annotations + + +def heading_level(style_name: str) -> int: + """从 Heading N 样式名解析大纲级别;非数字/无后缀兜底 1。""" + try: + return int(style_name.split()[-1]) + except (ValueError, IndexError): + return 1 diff --git a/src/genesis/parsers/word_template_parser.py b/src/genesis/parsers/word_template_parser.py new file mode 100644 index 0000000..4efc9bb --- /dev/null +++ b/src/genesis/parsers/word_template_parser.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from docx import Document +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_]*))?\}\}") + + +class WordTemplateParser: + """概要设计模板 docx 解析:章构成 / 占位符 / 样式名提取。""" + + def parse(self, path: str | Path) -> ParsedTemplate: + doc = Document(str(path)) + sections: list[ChapterMarker] = [] + placeholders: dict[str, str] = {} + used_styles: set[str] = set() + + # 文档命名样式(定义集合) + defined = {s.name for s in doc.styles if s.name} + + for para in doc.paragraphs: + style_name = para.style.name if para.style else "Normal" + used_styles.add(style_name) + text = para.text + + if style_name.startswith("Heading"): + sections.append(ChapterMarker( + type="heading", name=text, level=heading_level(style_name) + )) + + for m in PLACEHOLDER_RE.finditer(text): + if m.group(2): + key = f"{m.group(1)}:{m.group(2)}" + else: + key = m.group(1) + placeholders[key] = text + sections.append(ChapterMarker(type="placeholder", name=key, level=0)) + + # 书签:遍历 body 中全部 bookmarkStart + for bm in doc.element.body.iter(qn("w:bookmarkStart")): + name = bm.get(qn("w:name")) + if name: + sections.append(ChapterMarker(type="bookmark", name=name, level=0)) + + return ParsedTemplate( + file_name=Path(path).name, + sections=sections, + placeholders=placeholders, + styles={"defined": sorted(defined), "used": sorted(used_styles)}, + ) diff --git a/tests/test_word_template_parser.py b/tests/test_word_template_parser.py new file mode 100644 index 0000000..90cae49 --- /dev/null +++ b/tests/test_word_template_parser.py @@ -0,0 +1,106 @@ +from genesis.data_models import ChapterMarker, ParsedTemplate +from genesis.parsers._word_common import heading_level +from genesis.parsers.word_template_parser import WordTemplateParser + +from tests.docx_helpers import new_document, save_document + + +def test_heading_level_parses_numeric_suffix(): + assert heading_level("Heading 1") == 1 + assert heading_level("Heading 2") == 2 + assert heading_level("Heading 3") == 3 + + +def test_heading_level_fallback_on_invalid(): + # 兜底分支(非数字 / 无后缀)→ 1,分支覆盖必须命中 + assert heading_level("Heading X") == 1 + assert heading_level("Heading") == 1 + + +def test_parse_extracts_heading_levels(tmp_path): + doc = new_document() + doc.add_heading("1. はじめに", level=1) + doc.add_heading("2.1 画面遷移図", level=2) + doc.add_heading("2.1.1 詳細", level=3) + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert isinstance(result, ParsedTemplate) + headings = [s for s in result.sections if s.type == "heading"] + assert [(s.name, s.level) for s in headings] == [ + ("1. はじめに", 1), + ("2.1 画面遷移図", 2), + ("2.1.1 詳細", 3), + ] + + +def test_parse_extracts_bookmark(tmp_path): + from docx.oxml.ns import qn + doc = new_document() + para = doc.add_paragraph("アンカー") + bm_start = para._p.makeelement(qn("w:bookmarkStart"), {qn("w:id"): "0", qn("w:name"): "template_start"}) + para._p.insert(0, bm_start) + # 无 name 的书签:覆盖 if name 假分支,应被跳过 + bm_anon = para._p.makeelement(qn("w:bookmarkStart"), {qn("w:id"): "1"}) + para._p.insert(1, bm_anon) + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + bookmarks = [s for s in result.sections if s.type == "bookmark"] + assert [s.name for s in bookmarks] == ["template_start"] + + +def test_parse_extracts_placeholders(tmp_path): + doc = new_document() + doc.add_paragraph("{{doc_title}}") + doc.add_paragraph("{{section:introduction}}") + doc.add_paragraph("{{section:function_list}}") + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert result.placeholders == { + "doc_title": "{{doc_title}}", + "section:introduction": "{{section:introduction}}", + "section:function_list": "{{section:function_list}}", + } + ph = [s for s in result.sections if s.type == "placeholder"] + assert [s.name for s in ph] == ["doc_title", "section:introduction", "section:function_list"] + + +def test_parse_invalid_placeholder_kept_as_text(tmp_path): + doc = new_document() + doc.add_paragraph("{{ invalid }}") + doc.add_paragraph("ただの {text}") + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert result.placeholders == {} + assert [s for s in result.sections if s.type == "placeholder"] == [] + + +def test_parse_empty_document(tmp_path): + doc = new_document() + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert result.sections == [] + assert result.placeholders == {} + assert "Normal" in result.styles["defined"] + + +def test_parse_styles_collected(tmp_path): + doc = new_document() + doc.add_heading("章", level=1) + doc.add_paragraph("本文") + path = save_document(tmp_path, doc) + + result = WordTemplateParser().parse(path) + + assert "Heading 1" in result.styles["used"] + assert "Normal" in result.styles["used"] + assert "Heading 1" in result.styles["defined"]