feat: RuleDocParser 规则文档 Markdown 化与分类
This commit is contained in:
@@ -64,3 +64,4 @@
|
||||
| 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 |
|
||||
| 2026-08-10 | Agent 实现 | Phase3 Task3 实现:RuleDocParser 规则文档 Markdown 化与分类。新建 src/genesis/parsers/rule_doc_parser.py(body 级遍历保段落/表格交错顺序;Heading N→#×N;列表双通道检测 List 样式或 ・/-/• 前缀;表格→GFM;空段→空行;file_type 固定 word、hash=sha256 hex;复用 _word_common.heading_level 无本地重复)与 tests/test_rule_doc_parser.py 按 brief 6 用例;覆盖补齐:test_parse_empty_document 增加 doc.add_paragraph("") 使空段分支(原 new_document 无任何 w:p 不进分支)达 100%;TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.parsers.rule_doc_parser')→ GREEN(聚焦 6 passed);pytest 全量 146 passed 覆盖 100.00%(886 stmts/218 br),fail_under=99 达标 | src/genesis/parsers/rule_doc_parser.py, tests/test_rule_doc_parser.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from genesis.data_models import RuleDocument
|
||||
from genesis.parsers._word_common import heading_level
|
||||
|
||||
# 列表项前缀(真实样本为 Normal 样式 + ・ 前缀,样式名通道不足以命中)
|
||||
LIST_PREFIXES = ("・", "-", "•")
|
||||
|
||||
|
||||
def _is_list_item(text: str, style_name: str) -> bool:
|
||||
"""双通道列表检测:List 样式或文本前缀(spec §3.3)。"""
|
||||
if "List" in style_name:
|
||||
return True
|
||||
return text.startswith(LIST_PREFIXES)
|
||||
|
||||
|
||||
def _table_to_markdown(table) -> list[str]:
|
||||
"""docx 表格 → GFM 表格行。"""
|
||||
lines: list[str] = []
|
||||
for r_idx, row in enumerate(table.rows):
|
||||
cells = [cell.text.replace("|", "\\|").strip() for cell in row.cells]
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
if r_idx == 0:
|
||||
lines.append("| " + " | ".join(["---"] * len(cells)) + " |")
|
||||
return lines
|
||||
|
||||
|
||||
class RuleDocParser:
|
||||
"""规则文档 docx 解析:Markdown 化 + 分类。"""
|
||||
|
||||
def parse(self, path: str | Path, category: str = "write") -> RuleDocument:
|
||||
doc = Document(str(path))
|
||||
md: list[str] = []
|
||||
|
||||
# 按文档顺序遍历段落与表格(body 级子元素)
|
||||
for child in doc.element.body.iterchildren():
|
||||
tag = child.tag
|
||||
if tag == qn("w:p"):
|
||||
from docx.text.paragraph import Paragraph
|
||||
para = Paragraph(child, doc)
|
||||
text = para.text
|
||||
style_name = para.style.name if para.style else "Normal"
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
md.append("")
|
||||
elif style_name.startswith("Heading"):
|
||||
md.append("#" * heading_level(style_name) + " " + stripped)
|
||||
elif _is_list_item(stripped, style_name):
|
||||
md.append("- " + stripped.lstrip("・-•").strip())
|
||||
else:
|
||||
md.append(stripped)
|
||||
elif tag == qn("w:tbl"):
|
||||
from docx.table import Table
|
||||
md.extend(_table_to_markdown(Table(child, doc)))
|
||||
md.append("")
|
||||
|
||||
content = "\n".join(md).strip()
|
||||
return RuleDocument(
|
||||
file_name=Path(path).name,
|
||||
category=category,
|
||||
markdown_content=content,
|
||||
source_path=str(path),
|
||||
file_type="word",
|
||||
hash=hashlib.sha256(Path(path).read_bytes()).hexdigest(),
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
from genesis.data_models import RuleDocument
|
||||
from genesis.parsers.rule_doc_parser import RuleDocParser
|
||||
|
||||
from tests.docx_helpers import make_rule_doc, new_document, save_document
|
||||
|
||||
|
||||
def test_parse_heading_and_bullet(tmp_path):
|
||||
path = make_rule_doc(tmp_path, [
|
||||
("H1", "1. 機能一覧の書き方"),
|
||||
("list", "・機能ID は F001 から連番で付与する。"),
|
||||
("list", "・省略記号(~、…)は禁止し、正式名称を記載する。"),
|
||||
])
|
||||
|
||||
result = RuleDocParser().parse(path, category="write")
|
||||
|
||||
assert isinstance(result, RuleDocument)
|
||||
assert result.category == "write"
|
||||
assert result.file_type == "word"
|
||||
assert result.file_name == "source.docx"
|
||||
assert "# 1. 機能一覧の書き方" in result.markdown_content
|
||||
assert "- 機能ID は F001 から連番で付与する。" in result.markdown_content
|
||||
assert "- 省略記号(~、…)は禁止し、正式名称を記載する。" in result.markdown_content
|
||||
|
||||
|
||||
def test_parse_prefix_bullet_without_list_style(tmp_path):
|
||||
# 真实样本的「・」前缀在 Normal 样式段落(非 List Bullet)——双通道检测
|
||||
doc = new_document()
|
||||
doc.add_paragraph("・表ヘッダーは太字とし、下線を付ける。")
|
||||
path = save_document(tmp_path, doc)
|
||||
|
||||
result = RuleDocParser().parse(path)
|
||||
|
||||
assert "- 表ヘッダーは太字とし、下線を付ける。" in result.markdown_content
|
||||
|
||||
|
||||
def test_parse_table_to_markdown(tmp_path):
|
||||
doc = new_document()
|
||||
table = doc.add_table(rows=3, cols=2)
|
||||
headers = ["項目", "規則"]
|
||||
for c, h in enumerate(headers):
|
||||
table.cell(0, c).text = h
|
||||
table.cell(1, 0).text = "表ヘッダー"
|
||||
table.cell(1, 1).text = "太字のみ"
|
||||
table.cell(2, 0).text = "枠線"
|
||||
table.cell(2, 1).text = "付ける"
|
||||
path = save_document(tmp_path, doc)
|
||||
|
||||
result = RuleDocParser().parse(path)
|
||||
|
||||
lines = result.markdown_content.splitlines()
|
||||
assert "| 項目 | 規則 |" in lines
|
||||
assert "| --- | --- |" in lines
|
||||
assert "| 表ヘッダー | 太字のみ |" in lines
|
||||
assert "| 枠線 | 付ける |" in lines
|
||||
|
||||
|
||||
def test_parse_empty_document(tmp_path):
|
||||
doc = new_document()
|
||||
doc.add_paragraph("")
|
||||
path = save_document(tmp_path, doc)
|
||||
|
||||
result = RuleDocParser().parse(path)
|
||||
|
||||
assert result.markdown_content == ""
|
||||
|
||||
|
||||
def test_parse_hash_stable_and_sha256(tmp_path):
|
||||
path = make_rule_doc(tmp_path, [("H1", "章"), ("", "本文")])
|
||||
|
||||
r1 = RuleDocParser().parse(path)
|
||||
r2 = RuleDocParser().parse(path)
|
||||
|
||||
assert r1.hash == r2.hash
|
||||
assert len(r1.hash) == 64 # sha256 hex
|
||||
|
||||
|
||||
def test_parse_design_category(tmp_path):
|
||||
path = make_rule_doc(tmp_path, [("H1", "アーキテクチャ制約")])
|
||||
|
||||
result = RuleDocParser().parse(path, category="design")
|
||||
|
||||
assert result.category == "design"
|
||||
Reference in New Issue
Block a user