diff --git a/docs/superpowers/plans/2026-08-09-mixed-paragraph-parsing.md b/docs/superpowers/plans/2026-08-09-mixed-paragraph-parsing.md index fdc261f..b3cd2ab 100644 --- a/docs/superpowers/plans/2026-08-09-mixed-paragraph-parsing.md +++ b/docs/superpowers/plans/2026-08-09-mixed-paragraph-parsing.md @@ -250,6 +250,34 @@ def test_parse_mixed_sheet_segmented(tmp_path): # 自由文本段捕获碎片 ft = [p for p in ms.paragraphs if p.kind == "free_text"][0] assert "改修ポイント" in (ft.text or "") + + +def test_parse_mixed_sheet_formatting_in_mid_segment(tmp_path): + # 表格段不在物理行 0(自由文本段在前),数据行 F101 设取消线 —— 锁住 formatting_map 坐标错位 + from copy import copy + from openpyxl import Workbook + wb = Workbook() + ws = wb.active + ws.title = "混合" + data = [ + ["■はじめに"], ["前提説明"], [], + ["機能ID", "機能名"], ["F101", "社員登録"], ["F102", "退職処理"], + [], ["・改修ポイント"], + ] + for r, row in enumerate(data, start=1): + for c, v in enumerate(row, start=1): + if v: + ws.cell(row=r, column=c, value=v) + font = copy(ws.cell(row=5, column=1).font) # F101 所在物理行(第 5 行) + font.strike = True + ws.cell(row=5, column=1).font = font + path = save_workbook(tmp_path, wb) + result = ExcelParser().parse(path) + ms = result.mixed[0] + tbl = [p.table for p in ms.paragraphs if p.kind == "table"][0] + cv = tbl.rows[0]["機能ID"] # F101 + assert cv.formatting is not None + assert cv.formatting.strikethrough is True ``` - [ ] **Step 2: 运行确认失败** @@ -283,9 +311,15 @@ Expected: FAIL(当前 MIXED 折叠进表格路径,`result.mixed` 为空) header_row = find_header_row(seg) if header_row < 0: header_row = 0 + # extract_table 的 formatting_map 用段内矩阵坐标 (r,c); + # 从整 sheet 物理 map 抽出段内子 map(物理行 pr → 段内 pr-s) + seg_fmt_map = {} + for (pr, pc), fmt in fmt_map.items(): + if s <= pr <= e: + seg_fmt_map[(pr - s, pc)] = fmt table = extract_table( ws.title, seg, file_name, detected_type, - header_row=header_row, formatting_map=fmt_map, + header_row=header_row, formatting_map=seg_fmt_map, ) result.tables.append(table) mixed_sheet.paragraphs.append(MixedParagraph( @@ -309,7 +343,7 @@ Expected: FAIL(当前 MIXED 折叠进表格路径,`result.mixed` 为空) else: # 现有 TABLE 路径(含 MIXED 旧折叠) ``` -> 注:原 `else` 分支现在是 TABLE 专用;MIXED 已独立。`fmt_map` 对整 sheet 构建后再段内使用(对表格段坐标有效,自由文本段无表格 CellValue 使用)。 +> 注:原 `else` 分支现在是 TABLE 专用;MIXED 已独立。`fmt_map` 对整 sheet 按物理坐标构建一次;表格段经 `seg_fmt_map` 抽为段内坐标后传给 `extract_table`(避免段内相对索引与物理坐标错位)。自由文本段无表格 CellValue,不使用格式化。 - [ ] **Step 4: 运行确认通过** @@ -319,7 +353,7 @@ Expected: PASS(含新 MIXED 用例) - [ ] **Step 5: 全量回归** Run: `python -m pytest -v` -Expected: PASS(44 + 1 = 45 passed) +Expected: PASS(44 + 2 = 46 passed) - [ ] **Step 6: 提交** @@ -355,7 +389,6 @@ rows = [ ["F103", "給与計算", "SC003"], [], ["・改修ポイント:F103 に年末調整バッチ連携を追加する。"], - [], ["■対象期間:2026年度下半期"], ] for r, row in enumerate(rows, start=1): @@ -365,7 +398,7 @@ for r, row in enumerate(rows, start=1): wb.save(r"samples\要件定義_混合型.xlsx") print("saved") ``` -(实际执行时用 PowerShell 运行;注意行尾没有多余空行——`[]` 行是显式空行分隔,最后一个非空行为「■…」行后无额外空行,保证 `split_paragraphs` 尾部截断不产生空段。) +(实际执行时用 PowerShell 运行;「・」与「■」行**连续无空行**,`split_paragraphs` 将其合并为**一段**碎片段,返回 `[(0,3),(5,6)]` 两段:表格段 + 碎片段。) - [ ] **Step 2: 写端到端测试** @@ -380,11 +413,16 @@ def test_mixed_sample_segments_detected(): assert "機能一覧" in by_name assert result.mixed, "混合样本应产产出段落" mixed = result.mixed[0] - kinds = [p.kind for p in mixed.paragraphs] - assert "table" in kinds and "free_text" in kinds + assert len(mixed.paragraphs) == 2 # 表格段 + 碎片段(・/■ 连续) + assert [p.kind for p in mixed.paragraphs] == ["table", "free_text"] # 表格段无碎片污染 table = [p.table for p in mixed.paragraphs if p.kind == "table"][0] assert table.rows[0]["機能ID"].value == "F101" + assert len(table.rows) == 3 + # 碎片段含 ・ 与 ■ 两行文本 + ft = [p for p in mixed.paragraphs if p.kind == "free_text"][0] + assert "改修ポイント" in (ft.text or "") + assert "対象期間" in (ft.text or "") ``` - [ ] **Step 3: 运行端到端** @@ -395,7 +433,7 @@ Expected: PASS(4 passed,无 skip) - [ ] **Step 4: 全量回归** Run: `python -m pytest -v` -Expected: PASS(45 + 1 = 46 passed) +Expected: PASS(46 + 1 = 47 passed) - [ ] **Step 5: 提交** @@ -423,7 +461,7 @@ git commit -m "test: MIXED 混合样本 + 端到端段落验证" - [ ] **Step 1: 写失败测试(MIXED 断言 + formatting 正向)** -`tests/test_sheet_nature.py` 追加: +`tests/test_sheet_nature.py` 追加(补 MIXED 正向用例,`test_table_detection` 已覆盖 TABLE 无需重复): ```python def test_classify_sheet_mixed_with_bullet_line(): m = [["ID", "名前"], ["1", "田中"], ["・備考行"]] @@ -433,11 +471,27 @@ def test_classify_sheet_mixed_with_bullet_line(): def test_classify_sheet_mixed_with_square_line(): m = [["ID", "名前"], ["1", "田中"], ["■備考行"]] assert classify_sheet(m) == SheetNature.MIXED +``` + +`tests/test_table_extractor.py` 追加(锁枚举同源): +```python +from genesis.data_models import ExtractionMethod -def test_classify_table_no_semicolon(): - m = [["ID", "名前"], ["1", "田中"]] - assert classify_sheet(m) == SheetNature.TABLE +def test_extract_table_uses_enum_value(): + matrix = [["ID"], ["1"]] + table = extract_table("社員", matrix, "f.xlsx", SheetType.FUNCTION) + assert table.extraction_method == ExtractionMethod.OPENPYXL.value +``` + +`tests/test_free_text_extractor.py` 追加: +```python +from genesis.data_models import ExtractionMethod + + +def test_free_text_uses_enum_value(): + table = build_free_text_table("メモ", ["A"], "f.xlsx") + assert table.extraction_method == ExtractionMethod.LLM_FROM_FREE_TEXT.value ``` `tests/test_formatting_detector.py` 追加(复用现有 make_wb 复制字体技巧): @@ -478,10 +532,33 @@ extraction_method=ExtractionMethod.OPENPYXL.value, if header_row < 0 or header_row >= len(matrix): header_row = 0 ``` -`merge_fill.py` 防御非法入参: +`merge_fill.py` 防御非法入参(完整替换 `forward_fill`): ```python -def forward_fill(matrix, merged_ranges): - # 前置校验:range 值不合法(<1 或超出矩阵)时直接返回深拷贝 +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: + # 防御:合并范围 1-based,非法(<1)或越界时跳过该范围 + if not out or min_row < 1 or min_col < 1: + continue + if 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 +``` +`tests/test_table_extractor.py` 追加(验证防御): +```python +def test_forward_fill_ignores_invalid_range(): + matrix = [["A"], ["B"]] + assert forward_fill(matrix, [(0, 1, 2, 1)]) == matrix # min_row=0 非法 → 不崩溃不改数据 + assert forward_fill(matrix, [(9, 9, 9, 9)]) == matrix # 越界 → 不崩溃 ``` `data_models.py` 注释(`Provenance.row`): @@ -498,17 +575,17 @@ class Provenance: - [ ] **Step 3: 运行全部新增/修改测试** Run: `python -m pytest tests/test_sheet_nature.py tests/test_formatting_detector.py tests/test_free_text_extractor.py tests/test_table_extractor.py -v` -Expected: PASS +Expected: PASS(新增 2 MIXED + 1 formatting + 1 自由文本枚举 + 2 表提取枚举/防御 = 6 个新用例) - [ ] **Step 4: 全量回归** Run: `python -m pytest -v` -Expected: PASS(46 + 新增断言数) +Expected: PASS(47 + 6 = 53 passed) - [ ] **Step 5: 提交** ```bash -git add src/genesis/parsers/free_text_extractor.py src/genesis/parsers/table_extractor.py src/genesis/parsers/merge_fill.py src/genesis/data_models.py tests/test_sheet_nature.py tests/test_formatting_detector.py +git add src/genesis/parsers/free_text_extractor.py src/genesis/parsers/table_extractor.py src/genesis/parsers/merge_fill.py src/genesis/data_models.py tests/test_sheet_nature.py tests/test_formatting_detector.py tests/test_free_text_extractor.py tests/test_table_extractor.py git commit -m "fix: 遗留清理(枚举同源/边界防御/断言补强)" ```