feat: WordTemplateParser 章构成/占位符/样式名提取
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user