Files
2026Technology-Competition/docs/superpowers/plans/2026-08-08-milestone2-excel-parser.md
T

991 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase1 里程碑2 Parser Agent - Excel 解析 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 ExcelParser,将要件定义 Excel(3 种模式:表格型/自由记述型/混合型)解析为 `ExcelParseResult`tables + comments),为后代 Agent 提供唯一数据源。
**Architecture:** 在已完成的 `data_models.py` 基础上,按 design §3.5 拆分为职责单一模块并由 `ExcelParser` 编排。自由记述型的 LLM 结构化依赖 InferenceEngine(后续里程碑),本里程碑交付可独立测试的文本分段与占位表(`extraction_method="llm_from_free_text"`)。
**Tech Stack:** Python ≥3.11、openpyxl ≥3.1、pytest 8。
## Global Constraints
- 仅读取 `.xlsx``.xls``excel_reader.open_workbook` 抛出 `ValueError`
- Sheet 类型判定关键词为日文(design §3.5.1),用于匹配真实日文要件定义(技术必要保留)。
- 每个单元格生成 `Provenance``source_uri` 格式 `file.xlsx#SheetName!CellRef`design §9.4.5`build_source_uri` 提供)。
- 合并单元格下行填充(forward_filldesign §3.5.3)。
- 取消线/背景色/批注保留在 `CellFormatting` / `CellComment`design §3.5.4 / §3.5.5)。
- 每个单元格使用 `data_models``CellValue`(字段 `value` / `provenance` / `formatting` / `comment`)。
- 交流语言统一中文(注释/提交信息/本计划正文);标识符与技术名保留英文/日文关键词。
- 每个任务结束前 `pytest` 全绿并提交。
---
### Task 1: ExcelParser 基础结构与来源标注
**Files:**
- Create: `src/genesis/parsers/__init__.py`
- Create: `src/genesis/parsers/provenance.py`
- Create: `src/genesis/parsers/excel_reader.py`
- Create: `tests/excel_helpers.py`
- Create: `tests/test_excel_reader.py`
**Interfaces:**
- Produces:
- `genesis.parsers.provenance.build_source_uri(file_name: str, sheet_name: str, cell_ref: str) -> str`
- `genesis.parsers.excel_reader.open_workbook(path: str | Path)``openpyxl.Workbook`(仅 `.xlsx`
- `genesis.parsers.excel_reader.sheet_matrix(ws) -> list[list[Any]]`
测试辅助 `tests/excel_helpers.py` 供全部任务复用。
- [ ] **Step 1: 写失败测试**
`tests/excel_helpers.py`:
```python
from openpyxl import Workbook
def new_workbook(sheets: dict[str, list[list]]) -> Workbook:
"""生成临时 Workbookkey=Sheet 名,value=gridcell 值)。"""
wb = Workbook()
wb.remove(wb.active)
for name, grid in sheets.items():
ws = wb.create_sheet(name)
for r, row in enumerate(grid, start=1):
for c, value in enumerate(row, start=1):
ws.cell(row=r, column=c, value=value)
return wb
def save_workbook(tmp_path, wb: Workbook) -> str:
"""落盘到 tmp_path 并返回路径字符串。"""
path = tmp_path / "source.xlsx"
wb.save(path)
return str(path)
```
`tests/test_excel_reader.py`:
```python
import pytest
from genesis.parsers.excel_reader import open_workbook, sheet_matrix
from genesis.parsers.provenance import build_source_uri
from tests.excel_helpers import new_workbook, save_workbook
def test_build_source_uri_format():
assert build_source_uri("要求.xlsx", "機能一覧", "A5") == "要求.xlsx#機能一覧!A5"
def test_open_workbook_and_sheet_matrix(tmp_path):
wb = new_workbook({"機能一覧": [["機能ID", "機能名"], ["F001", "社員登録"]]})
path = save_workbook(tmp_path, wb)
ws = open_workbook(path)["機能一覧"]
assert sheet_matrix(ws) == [["機能ID", "機能名"], ["F001", "社員登録"]]
def test_open_workbook_rejects_xls(tmp_path):
bad = tmp_path / "old.xls"
bad.write_bytes(b"not really xls")
with pytest.raises(ValueError):
open_workbook(bad)
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_excel_reader.py -v`
Expected: FAIL`ModuleNotFoundError: No module named 'genesis.parsers'`
- [ ] **Step 3: 实现基础模块**
`src/genesis/parsers/__init__.py`:
```python
"""Parser Agent:输入资料解析层。"""
```
`src/genesis/parsers/provenance.py`:
```python
def build_source_uri(file_name: str, sheet_name: str, cell_ref: str) -> str:
"""单元格来源 URIfile.xlsx#SheetName!CellRef"""
return f"{file_name}#{sheet_name}!{cell_ref}"
```
`src/genesis/parsers/excel_reader.py`:
```python
from __future__ import annotations
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from openpyxl.worksheet.worksheet import Worksheet
def open_workbook(path: str | Path):
"""普通模式打开 .xlsx(保留公式/样式/批注),.xls 报错。"""
path = Path(path)
if path.suffix.lower() != ".xlsx":
raise ValueError(f"不支持的 Excel 格式: {path.suffix}")
return load_workbook(path)
def sheet_matrix(ws: Worksheet) -> list[list[Any]]:
"""整表矩形值(含 None),保留到 max_column。"""
return [[cell.value for cell in row] for row in ws.iter_rows()]
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_excel_reader.py -v`
Expected: PASS3 passed
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers tests/excel_helpers.py tests/test_excel_reader.py
git commit -m "feat: ExcelParser 基础(workbook/sheet 读取 + source_uri"
```
> 注:计划中 Step 3 与 Step 4 之间未设独立 StepStep 3 写完后直接执行 Step 4 运行验证。
---
### Task 2: SheetDetector(自动识别 Sheet 类型)
**Files:**
- Create: `src/genesis/parsers/sheet_detector.py`
- Create: `tests/test_sheet_detector.py`
**Interfaces:**
- Consumes: `data_models.SheetType`
- Produces: `detect_sheet_type(sheet_name: str, matrix: list[list[Any]]) -> SheetType`
(优先级:Sheet 名关键词 → 表头关键词 → `GENERIC`
- [ ] **Step 1: 写失败测试**
`tests/test_sheet_detector.py`:
```python
from genesis.data_models import SheetType
from genesis.parsers.sheet_detector import detect_sheet_type
def test_detect_by_sheet_name():
assert detect_sheet_type("機能一覧", []) == SheetType.FUNCTION
assert detect_sheet_type("画面一覧", []) == SheetType.SCREEN
assert detect_sheet_type("帳票一覧", []) == SheetType.REPORT
assert detect_sheet_type("DB定義", []) == SheetType.DATABASE
assert detect_sheet_type("IF定義", []) == SheetType.INTERFACE
assert detect_sheet_type("バッチ一覧", []) == SheetType.BATCH
assert detect_sheet_type("コード管理", []) == SheetType.MASTER
def test_detect_by_header_keyword():
matrix = [["帳票ID", "帳票名"], ["TB1", "月次"]]
assert detect_sheet_type("補充シート", matrix) == SheetType.REPORT
def test_unknown_is_generic():
assert detect_sheet_type("メモ", [["随筆"]]) == SheetType.GENERIC
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_sheet_detector.py -v`
Expected: FAIL`ImportError: cannot import name 'detect_sheet_type'`
- [ ] **Step 3: 实现 sheet_detector.py**
`src/genesis/parsers/sheet_detector.py`:
```python
from __future__ import annotations
from typing import Any
from genesis.data_models import SheetType
# Sheet 名关键词(design §3.5.1,顺序即优先级)
NAME_KEYWORDS: list[tuple[str, SheetType]] = [
("機能", SheetType.FUNCTION),
("画面", SheetType.SCREEN),
("帳票", SheetType.REPORT),
("テーブル", SheetType.DATABASE),
("DB", SheetType.DATABASE),
("インターフェース", SheetType.INTERFACE),
("IF", SheetType.INTERFACE),
("バッチ", SheetType.BATCH),
("ジョブ", SheetType.BATCH),
("マスタ", SheetType.MASTER),
]
# 表头关键词
HEADER_KEYWORDS: list[tuple[str, SheetType]] = [
("機能ID", SheetType.FUNCTION),
("画面ID", SheetType.SCREEN),
("帳票ID", SheetType.REPORT),
("テーブルID", SheetType.DATABASE),
("IF名", SheetType.INTERFACE),
("バッチID", SheetType.BATCH),
]
def _name_hit(sheet_name: str) -> SheetType | None:
for kw, st in NAME_KEYWORDS:
if kw in sheet_name:
return st
return None
def _header_hit(matrix: list[list[Any]]) -> SheetType | None:
for row in matrix[:3]:
for cell in row:
if isinstance(cell, str):
for kw, st in HEADER_KEYWORDS:
if kw in cell:
return st
return None
def detect_sheet_type(sheet_name: str, matrix: list[list[Any]]) -> SheetType:
return _name_hit(sheet_name) or _header_hit(matrix) or SheetType.GENERIC
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_sheet_detector.py -v`
Expected: PASS3 passed
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers/sheet_detector.py tests/test_sheet_detector.py
git commit -m "feat: SheetDetector 类型识别(名称/表头关键词)"
```
---
### Task 3: Sheet 性质判定(表格型/自由记述型/混合型)
**Files:**
- Create: `src/genesis/parsers/sheet_nature.py`
- Create: `tests/test_sheet_nature.py`
**Interfaces:**
- Produces:
- `SheetNature(Enum)``TABLE` / `FREE_TEXT` / `MIXED`
- `classify_sheet(matrix: list[list[Any]]) -> SheetNature`
- `find_header_row(matrix: list[list[Any]]) -> int`(首个连续非空 ≥2 的行的下标;无则 -1)
判定启发式(design §3.5.2 落地):
- 空行占比 > 30%`非空行数 / 总行数 < 0.7`)→ FREE_TEXT
- 仅 A 列使用(`max_cols <= 1`)→ FREE_TEXT
- 无表头行(`find_header_row == -1`)→ FREE_TEXT
- 表头行后存在以「・」/「■」开头的碎片行 → MIXED
- 其余 → TABLE
- [ ] **Step 1: 写失败测试**
`tests/test_sheet_nature.py`:
```python
from genesis.parsers.sheet_nature import SheetNature, classify_sheet, find_header_row
def test_table_detection():
m = [["機能ID", "機能名"], ["A001", "社員登録"], ["A002", "退職処理"]]
assert classify_sheet(m) == SheetNature.TABLE
def test_free_text_single_col():
m = [["新入社員を登録できる。"], ["氏名・所属・入社日を入力する。"]]
assert classify_sheet(m) == SheetNature.FREE_TEXT
def test_free_text_many_empty_rows():
m = [["要求A"], [], [], [], ["要求B"]]
assert classify_sheet(m) == SheetNature.FREE_TEXT
def test_header_row_index():
m = [["機能ID", "名前"], ["1", "田中"]]
assert find_header_row(m) == 0
assert find_header_row([["自由テキスト"]]) == -1
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_sheet_nature.py -v`
Expected: FAIL(导入错误)
- [ ] **Step 3: 实现 sheet_nature.py**
`src/genesis/parsers/sheet_nature.py`:
```python
from __future__ import annotations
from enum import Enum
from typing import Any
class SheetNature(Enum):
TABLE = "table"
FREE_TEXT = "free_text"
MIXED = "mixed"
def _non_empty(row: list[Any]) -> list[Any]:
return [c for c in row if c is not None and str(c).strip() != ""]
def find_header_row(matrix: list[list[Any]]) -> int:
for i, row in enumerate(matrix):
if len(_non_empty(row)) >= 2:
return i
return -1
def _free_text_like(matrix: list[list[Any]]) -> bool:
if not matrix:
return True
max_cols = max((len(row) for row in matrix), default=0)
if max_cols <= 1:
return True
non_empty_rows = [r for r in matrix if _non_empty(r)]
if len(non_empty_rows) / len(matrix) < 0.7:
return True
return find_header_row(matrix) == -1
def classify_sheet(matrix: list[list[Any]]) -> SheetNature:
if _free_text_like(matrix):
return SheetNature.FREE_TEXT
header_row = find_header_row(matrix)
if header_row >= 0:
for row in matrix[header_row + 1:]:
if any(str(c).strip().startswith(("・", "■")) for c in _non_empty(row)):
return SheetNature.MIXED
return SheetNature.TABLE
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_sheet_nature.py -v`
Expected: PASS4 passed
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers/sheet_nature.py tests/test_sheet_nature.py
git commit -m "feat: Sheet 性质判定(table/free_text/mixed"
```
---
### Task 4: MergeHandler 与 TableExtractor
**Files:**
- Create: `src/genesis/parsers/merge_fill.py`
- Create: `src/genesis/parsers/table_extractor.py`
- Create: `tests/test_table_extractor.py`
**Interfaces:**
- Consumes: `data_models``CellValue``ExcelTable``Provenance``SheetType`)、`build_source_uri`
- Produces:
- `merge_fill.forward_fill(matrix, merged_ranges) -> list[list[Any]]`
- `table_extractor.extract_table(sheet_name, matrix, file_name, detected_type) -> ExcelTable`
- `table_extractor.column_letter(index) -> str`1→A、27→AA
合并单元格展开:范围 `(min_row, min_col, max_row, max_col)`(1-based);将范围内全部单元格填为主格(左上角)值。
- [ ] **Step 1: 写失败测试**
`tests/test_table_extractor.py`:
```python
from genesis.data_models import CellValue, ExcelTable, SheetType
from genesis.parsers.merge_fill import forward_fill
from genesis.parsers.table_extractor import column_letter, extract_table
def test_forward_fill_vertical():
matrix = [
["機能ID", "機能名", "備考"],
["A001", "", ""],
["", "", "メモ"],
]
filled = forward_fill(matrix, [(1, 2, 2, 2)]) # B1:B2 纵向合并
assert filled[1][1] == "機能名"
assert filled[2][2] == "メモ"
def test_forward_fill_horizontal():
matrix = [["A", "B"], ["x", ""]]
filled = forward_fill(matrix, [(2, 1, 2, 2)]) # A2:B2 横向合并
assert filled[1][1] == "x"
assert filled[1][0] == "x"
def test_column_letter():
assert column_letter(1) == "A"
assert column_letter(27) == "AA"
def test_extract_table_basic():
matrix = [["ID", "名前"], ["1", "田中"], ["2", "佐藤"]]
table = extract_table("社員一覧", matrix, "f.xlsx", SheetType.FUNCTION)
assert isinstance(table, ExcelTable)
assert table.headers == ["ID", "名前"]
assert table.extraction_method == "openpyxl"
assert len(table.rows) == 2
first = table.rows[0]
assert isinstance(first["ID"], CellValue)
assert first["ID"].value == "1"
assert first["ID"].provenance.sheet_name == "社員一覧"
assert first["ID"].provenance.column == "A"
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_table_extractor.py -v`
Expected: FAIL(导入错误)
- [ ] **Step 3: 实现**
`src/genesis/parsers/merge_fill.py`:
```python
from __future__ import annotations
from typing import Any
def forward_fill(
matrix: list[list[Any]],
merged_ranges: list[tuple[int, int, int, int]],
) -> list[list[Any]]:
"""合并单元格:用左上角主格值填充范围内全部单元格。"""
out = [list(row) for row in matrix]
for (min_row, min_col, max_row, max_col) in merged_ranges:
if not out or min_row > len(out) or min_col > len(out[min_row - 1]):
continue
main_value = out[min_row - 1][min_col - 1]
for r in range(min_row, min(max_row, len(out)) + 1):
row = out[r - 1]
for c in range(min_col, min(max_col, len(row)) + 1):
row[c - 1] = main_value
return out
```
`src/genesis/parsers/table_extractor.py`:
```python
from __future__ import annotations
from typing import Any
from genesis.data_models import CellValue, ExcelTable, Provenance, SheetType
def column_letter(index: int) -> str:
"""1 → A、27 → AA。"""
letters = ""
while index > 0:
index, rem = divmod(index - 1, 26)
letters = chr(65 + rem) + letters
return letters
def extract_table(
sheet_name: str,
matrix: list[list[Any]],
file_name: str,
detected_type: SheetType,
header_row: int = 0,
) -> ExcelTable:
"""从矩阵提取表格:首行视为表头,其后为数据行。"""
if not matrix:
return ExcelTable(
name=sheet_name, detected_type=detected_type,
extraction_method="openpyxl", headers=[], rows=[],
)
headers = [str(c) if c is not None else "" for c in matrix[header_row]]
rows = []
for r in range(header_row + 1, len(matrix)):
row_dict = {}
for c, h in enumerate(headers):
raw = matrix[r][c] if c < len(matrix[r]) else None
row_dict[h] = CellValue(
value=raw,
provenance=Provenance(
file_name=file_name,
sheet_name=sheet_name,
row=r - header_row, # 数据行号从 1 开始
column=column_letter(c + 1),
column_header=h,
),
)
rows.append(row_dict)
return ExcelTable(
name=sheet_name, detected_type=detected_type,
extraction_method="openpyxl", headers=headers, rows=rows,
)
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_table_extractor.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers/merge_fill.py src/genesis/parsers/table_extractor.py tests/test_table_extractor.py
git commit -m "feat: MergeHandler + TableExtractorforward_fill / 表格提取)"
```
---
### Task 5: FormattingDetector(取消线/背景色/批注)
**Files:**
- Create: `src/genesis/parsers/formatting_detector.py`
- Create: `tests/test_formatting_detector.py`
**Interfaces:**
- Consumes: openpyxl `Worksheet`/`Cell``data_models.CellFormatting / CellComment``build_source_uri`
- Produces:
- `cell_formatting(cell) -> CellFormatting | None`(strike / 字体色 / 背景色,纯默认则 None)
- `cell_comment(cell, file_name) -> CellComment | None`
- `collect_comments(ws, file_name) -> list[CellComment]`
- [ ] **Step 1: 写失败测试**
`tests/test_formatting_detector.py`:
```python
from openpyxl import Workbook
from openpyxl.comments import Comment
from genesis.data_models import CellComment, CellFormatting
from genesis.parsers.formatting_detector import (
cell_comment, cell_formatting, collect_comments,
)
def make_wb():
wb = Workbook()
ws = wb.active
ws.title = "機能一覧"
ws["A1"] = "F001"
ws["A1"].font.strike = True
ws["A2"] = "F002"
ws["A2"].comment = Comment("要確認", "reviewer")
return wb
def test_cell_formatting_detects_strike():
fmt = cell_formatting(make_wb().active["A1"])
assert fmt is not None
assert fmt.strikethrough is True
def test_cell_formatting_none_when_plain():
wb = Workbook()
ws = wb.active
ws["A1"] = "x"
assert cell_formatting(ws["A1"]) is None
def test_cell_comment_returns_obj():
wb = make_wb()
cm = cell_comment(wb.active["A2"], "f.xlsx")
assert isinstance(cm, CellComment)
assert cm.author == "reviewer"
assert cm.text == "要確認"
assert cm.source_uri == "f.xlsx#機能一覧!A2"
def test_collect_comments():
comments = collect_comments(make_wb().active, "f.xlsx")
assert len(comments) == 1
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_formatting_detector.py -v`
Expected: FAIL(导入错误)
- [ ] **Step 3: 实现**
`src/genesis/parsers/formatting_detector.py`:
```python
from __future__ import annotations
from genesis.data_models import CellComment, CellFormatting
from genesis.parsers.provenance import build_source_uri
def cell_formatting(cell) -> CellFormatting | None:
strike = bool(cell.font.strike)
font_color = None
if cell.font.color and str(cell.font.color.rgb) not in ("00000000", "FF000000"):
font_color = str(cell.font.color.rgb)
bg_color = None
fill = cell.fill
if fill and fill.fgColor and str(fill.fgColor.rgb) not in ("00000000", "FF000000"):
bg_color = str(fill.fgColor.rgb)
if strike or font_color or bg_color:
return CellFormatting(
strikethrough=strike, font_color=font_color, bg_color=bg_color,
)
return None
def cell_comment(cell, file_name: str) -> CellComment | None:
if cell.comment is None:
return None
return CellComment(
author=cell.comment.author or "",
text=cell.comment.text or "",
source_uri=build_source_uri(file_name, cell.parent.title, cell.coordinate),
)
def collect_comments(ws, file_name: str) -> list[CellComment]:
result = []
for row in ws.iter_rows():
for cell in row:
cm = cell_comment(cell, file_name)
if cm is not None:
result.append(cm)
return result
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_formatting_detector.py -v`
Expected: PASS4 passed
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers/formatting_detector.py tests/test_formatting_detector.py
git commit -m "feat: FormattingDetector(取消线/背景色/批注)"
```
---
### Task 6: FreeTextExtractor(自由记述型 Sheet 结构占位)
**Files:**
- Create: `src/genesis/parsers/free_text_extractor.py`
- Create: `tests/test_free_text_extractor.py`
**Interfaces:**
- Consumes: `data_models``CellValue``ExcelTable``Provenance``SheetType`
- Produces:
- `extract_text_blocks(matrix) -> list[str]`(全空行分段,行内单元格以空格连接)
- `build_free_text_table(sheet_name, blocks, file_name, detected_type=SheetType.GENERIC) -> ExcelTable``extraction_method="llm_from_free_text"`headers=["text"]
> LLM:本里程碑不调用 LLMInferenceEngine 未实现);占位表为后续 LLM 结构化保留入口。
- [ ] **Step 1: 写失败测试**
`tests/test_free_text_extractor.py`:
```python
from genesis.data_models import CellValue, ExcelTable, SheetType
from genesis.parsers.free_text_extractor import build_free_text_table, extract_text_blocks
def test_extract_blocks_splits_on_empty_rows():
m = [["新入社員を登録。"], [], ["テスト要件:入力。"], [], []]
assert extract_text_blocks(m) == ["新入社員を登録。", "テスト要件:入力。"]
def test_build_free_text_table():
table = build_free_text_table("機能要件", ["A", "B"], "f.xlsx", SheetType.FUNCTION)
assert isinstance(table, ExcelTable)
assert table.detected_type == SheetType.FUNCTION
assert table.extraction_method == "llm_from_free_text"
assert len(table.rows) == 2
assert isinstance(table.rows[0]["text"], CellValue)
assert table.rows[0]["text"].value == "A"
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_free_text_extractor.py -v`
Expected: FAIL(导入错误)
- [ ] **Step 3: 实现**
`src/genesis/parsers/free_text_extractor.py`:
```python
from __future__ import annotations
from typing import Any
from genesis.data_models import CellValue, ExcelTable, Provenance, SheetType
def extract_text_blocks(matrix: list[list[Any]]) -> list[str]:
"""按全空行分段;行内非空单元格以「 」连接。"""
blocks: list[str] = []
current: list[str] = []
for row in matrix:
cells = [str(c).strip() for c in row if c is not None and str(c).strip() != ""]
if not cells:
if current:
blocks.append(" ".join(current))
current = []
continue
current.append(" ".join(cells))
if current:
blocks.append(" ".join(current))
return blocks
def build_free_text_table(
sheet_name: str,
blocks: list[str],
file_name: str,
detected_type: SheetType = SheetType.GENERIC,
) -> ExcelTable:
rows = []
for i, text in enumerate(blocks, start=1):
rows.append({
"text": CellValue(
value=text,
provenance=Provenance(
file_name=file_name,
sheet_name=sheet_name,
row=i,
column="A",
column_header="text",
),
),
})
return ExcelTable(
name=sheet_name,
detected_type=detected_type,
extraction_method="llm_from_free_text",
headers=["text"],
rows=rows,
)
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_free_text_extractor.py -v`
Expected: PASS2 passed
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers/free_text_extractor.py tests/test_free_text_extractor.py
git commit -m "feat: FreeTextExtractor(文本分段 + 占位结构化表)"
```
---
### Task 7: ExcelParser 编排器
**Files:**
- Create: `src/genesis/parsers/excel_parser.py`
- Create: `tests/test_excel_parser.py`
**Interfaces:**
- Produces:
- `@dataclass ExcelParseResult``file_name: str``tables: list[ExcelTable]``comments: list[CellComment]``skipped: list[str]`
- `class ExcelParser: parse(path) -> ExcelParseResult`
`parse` 编排:
1. `open_workbook(path)`
2. 每 Sheet`matrix = sheet_matrix(ws)`;空 → skipped
3. `detected_type = detect_sheet_type(ws.title, matrix)`
4. `nature = classify_sheet(matrix)`
5. `FREE_TEXT``extract_text_blocks` + `build_free_text_table(detected_type=detected_type)`
6. 否则 `forward_fill(matrix, merged)` + `extract_table`
7. `collect_comments(ws, file_name)` 汇总
8. 返回 `ExcelParseResult`
- [ ] **Step 1: 写失败测试**
`tests/test_excel_parser.py`:
```python
from genesis.data_models import SheetType
from genesis.parsers.excel_parser import ExcelParseResult, ExcelParser
from tests.excel_helpers import new_workbook, save_workbook
def test_parse_table_sheets(tmp_path):
wb = new_workbook({
"機能一覧": [["機能ID", "機能名"], ["A001", "社員登録"]],
"バッチ一覧": [["バッチID", "処理名"], ["B1", "夜間集計"]],
})
path = save_workbook(tmp_path, wb)
result = ExcelParser().parse(path)
assert isinstance(result, ExcelParseResult)
by_name = {t.name: t for t in result.tables}
assert by_name["機能一覧"].detected_type == SheetType.FUNCTION
assert len(by_name["機能一覧"].rows) == 1
assert by_name["バッチ一覧"].detected_type == SheetType.BATCH
def test_parse_free_text_sheet(tmp_path):
wb = new_workbook({"メモ": [["新入社員を登録"], [], [], [], []]})
path = save_workbook(tmp_path, wb)
result = ExcelParser().parse(path)
assert len(result.tables) == 1
assert result.tables[0].extraction_method == "llm_from_free_text"
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_excel_parser.py -v`
Expected: FAIL(导入错误)
- [ ] **Step 3: 实现 excel_parser.py**
`src/genesis/parsers/excel_parser.py`:
```python
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from genesis.data_models import CellComment, ExcelTable
from genesis.parsers.excel_reader import open_workbook, sheet_matrix
from genesis.parsers.sheet_detector import detect_sheet_type
from genesis.parsers.sheet_nature import SheetNature, classify_sheet
from genesis.parsers.merge_fill import forward_fill
from genesis.parsers.table_extractor import extract_table
from genesis.parsers.formatting_detector import collect_comments
from genesis.parsers.free_text_extractor import build_free_text_table, extract_text_blocks
@dataclass
class ExcelParseResult:
file_name: str
tables: list[ExcelTable] = field(default_factory=list)
comments: list[CellComment] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
class ExcelParser:
"""要件定义 Excel 解析入口。"""
def parse(self, path: str | Path) -> ExcelParseResult:
wb = open_workbook(path)
file_name = Path(path).name
result = ExcelParseResult(file_name=file_name)
for ws in wb.worksheets:
matrix = sheet_matrix(ws)
if not matrix:
result.skipped.append(ws.title)
continue
detected_type = detect_sheet_type(ws.title, matrix)
nature = classify_sheet(matrix)
if nature == SheetNature.FREE_TEXT:
blocks = extract_text_blocks(matrix)
result.tables.append(
build_free_text_table(ws.title, blocks, file_name, detected_type)
)
else:
merged = [
(r.min_row, r.min_col, r.max_row, r.max_col)
for r in ws.merged_cells.ranges
]
filled = forward_fill(matrix, merged) if merged else matrix
result.tables.append(extract_table(ws.title, filled, file_name, detected_type))
result.comments.extend(collect_comments(ws, file_name))
return result
```
- [ ] **Step 4: 运行确认通过**
Run: `python -m pytest tests/test_excel_parser.py -v`
Expected: PASS2 passed
- [ ] **Step 5: Commit**
```bash
git add src/genesis/parsers/excel_parser.py tests/test_excel_parser.py
git commit -m "feat: ExcelParser 编排器(类型/性质/提取/汇总)"
```
---
### Task 8: 真实样本集成测试
**Files:**
- Create: `tests/test_real_samples.py`
**说明:** 使用 `samples/` 下 3 个脱敏要件定义样本做端到端验证。样本缺失时对应用例 `pytest.skip`
- [ ] **Step 1: 写集成测试**
`tests/test_real_samples.py`:
```python
from pathlib import Path
import pytest
from genesis.parsers.excel_parser import ExcelParser
SAMPLES = Path(__file__).resolve().parents[1] / "samples"
def _x(name: str) -> Path:
return SAMPLES / name
def test_new_dev_sample_has_tables():
p = _x("要件定義_新規開発.xlsx")
if not p.exists():
pytest.skip("样本缺失")
result = ExcelParser().parse(p)
by_name = {t.name: t for t in result.tables}
assert "機能一覧" in by_name
assert by_name["機能一覧"].rows
assert any(t.name == "DB定義" for t in result.tables)
def test_additional_modification_has_tables():
p = _x("要件定義_追加改修.xlsx")
if not p.exists():
pytest.skip("样本缺失")
result = ExcelParser().parse(p)
assert result.tables
assert any(t.rows for t in result.tables)
def test_free_text_sample_detected():
p = _x("要件定義_自由記述.xlsx")
if not p.exists():
pytest.skip("样本缺失")
result = ExcelParser().parse(p)
assert any(t.extraction_method == "llm_from_free_text" for t in result.tables)
```
- [ ] **Step 2: 运行集成测试**
Run: `python -m pytest tests/test_real_samples.py -v`
Expected: PASS(样本存在时)或 至少 2 passed 1 skipped
- [ ] **Step 3: 全量回归**
Run: `python -m pytest -v`
Expected: PASS(全体收集成功)
- [ ] **Step 4: Commit**
```bash
git add tests/test_real_samples.py
git commit -m "test: 真实样本集成测试(3 类型 xlsx)"
```
---
## 自审结论(计划完稿时执行)
- **Spec 覆盖**implementation-plan §2.1→Task1、§2.2→Task2、§2.3→Task3、§2.4+§2.5→Task4、§2.7→Task5、§2.6→Task6、§2.9→Task7+Task8design §3.5.1→T2、§3.5.2→T3、§3.5.3→T4、§3.5.4/§3.5.5→T5sample-spec §3.1-3.4→T8;验收标准(表格型/自由记述型/混合型解析、合并填充、source_uri)逐项覆盖。
- **占位符扫描**:无 TBD/TODO;任务指引 Task1 的 Step 编号已显式标注(Step 3 即实现、Step 4 运行),不含空白步骤。
- **类型一致性**`CellValue` / `ExcelTable` / `SheetType` / `CellFormatting` / `CellComment``data_models.py` 完全一致;`build_source_uri(file, sheet, ref)` 全局同名;`extract_table.column``column_letter` 生成。
- **依赖边界**:全程不依赖 InferenceEngineFreeText 表 `extraction_method="llm_from_free_text"` 显式标记后续替换,符合模块边界与 YAGNI。