feat: WordTemplateParser 章构成/占位符/样式名提取

This commit is contained in:
lhl
2026-08-10 09:38:37 +08:00
parent e8771aa797
commit 00fcbf18c3
4 changed files with 176 additions and 0 deletions
+2
View File
@@ -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 任务 TDDdocx_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.pynew_document/save_document/make_rule_docmake_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.pyheading_level 共享 helperTask2/3 复用,从 Heading N 样式名解析大纲级别,非数字/无后缀兜底 1)与 src/genesis/parsers/word_template_parser.pyPLACEHOLDER_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 验证 REDModuleNotFoundError: 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 |
+11
View File
@@ -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
@@ -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)},
)
+106
View File
@@ -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"]