# 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_fill,design §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: """生成临时 Workbook:key=Sheet 名,value=grid(cell 值)。""" 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: """单元格来源 URI:file.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: PASS(3 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 之间未设独立 Step;Step 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: PASS(3 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: PASS(4 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 + TableExtractor(forward_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: PASS(4 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:本里程碑不调用 LLM(InferenceEngine 未实现);占位表为后续 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: PASS(2 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: PASS(2 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+Task8;design §3.5.1→T2、§3.5.2→T3、§3.5.3→T4、§3.5.4/§3.5.5→T5;sample-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` 生成。 - **依赖边界**:全程不依赖 InferenceEngine;FreeText 表 `extraction_method="llm_from_free_text"` 显式标记后续替换,符合模块边界与 YAGNI。