diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 39230ad..00a1f64 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -34,3 +34,4 @@ | 2026-08-09 03:30 | 整体迭代 | 里程碑2 最终评审修正(must-fix x2):① find_header_row 结果未传入 extract_table(恒 header_row=0,标题行置上的 sheet 会损坏表头)→ excel_parser.py 结构化路径改用 find_header_row(filled) 并 <0 回落 0;② cell_formatting 死代码(CellValue.formatting 恒 None,取消线/背景色到不了下游)→ extract_table 新增可选 formatting_map 参数,excel_parser 遍历 ws.iter_rows 构建 fmt_map 传入。tests/test_excel_parser.py 新增 2 用例(标题行表头定位、strikethrough 回填);pytest 全量 41 passed | src/genesis/parsers/excel_parser.py, src/genesis/parsers/table_extractor.py, tests/test_excel_parser.py | deepseek-v4-flash-free | | 2026-08-09 03:59 | Agent 实现 | 里程碑2.5 Task9 实现:段落分割器 split_paragraphs(MIXED 完整段落解析第一步,纯函数)。新建 src/genesis/parsers/paragraph_splitter.py(_is_blank_row 行空白判定 + split_paragraphs(matrix)→list[(start,end)] 含端点 0-based,以全空行为界返回段落区间,空矩阵→[])与 tests/test_paragraph_splitter.py(5 用例:空矩阵/单段落无空行/中间空行分割/尾部空行不产生多余段落/前导空行从首个非空行开始);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.parsers.paragraph_splitter')→ GREEN(5 passed);pytest 全量 46 passed(41 baseline + 5 新增);不依赖 openpyxl 与 data_models;提交 34ef28b | src/genesis/parsers/paragraph_splitter.py, tests/test_paragraph_splitter.py | deepseek-v4-flash-free | | 2026-08-09 04:20 | Agent 实现 | 里程碑2.5 Task10 实现:data_models 扩展 MixedParagraph/MixedSheet 段落容器,并新增 ExcelParseResult.mixed 字段(默认空列表)。TDD:先追加 3 个测试(字段存在性与默认行为)确认 RED(ImportError: cannot import name 'MixedParagraph'),再在 data_models.py 末尾追加两个 dataclass(顶部 from typing 加 Literal、dataclasses 加 field),excel_parser.py 更新导入并为 ExcelParseResult 加 mixed 字段,确认 GREEN(3 passed),全量 pytest 49 passed(46 baseline + 3)。未修改既有类与字段,仅追加。 | src/genesis/data_models.py, src/genesis/parsers/excel_parser.py, tests/test_excel_parser.py | deepseek-v4-flash-free | +| 2026-08-09 04:06 | Agent 实现 | 里程碑2.5 Task11 实现:MIXED 完整段落装配(分割→逐段最优解析)。excel_parser.py 新增 MIXED 分支:整表先 forward_fill(合并单元格)再按段切片;对每段 classify_sheet,TABLE 段用 find_header_row(seg)(段内相对,<0 回落 0)并抽出段内子 map seg_fmt_map(物理行 pr→段内 pr-s,修复 formatting_map 坐标错位)传给 extract_table(header_row=header_row);非表格段归 FREE_TEXT(防二次 MIXED 递归),走 extract_text_blocks + build_free_text_table 构造 kind="free_text"。MixedParagraph 段落追加到 MixedSheet 并进 result.mixed,表/自由文本表进 result.tables。原 else 分支保留为 TABLE 专用,既有 TABLE/FREE_TEXT 路径不变。TDD:先追加 2 测试(段落分割/碎片捕获、表格段不在物理行 0 时的取消线回填)确认 RED(result.mixed 空 + IndexError),再实现确认 GREEN(9 passed),全量 pytest 51 passed(49 baseline + 2)。提交见 git log | src/genesis/parsers/excel_parser.py, tests/test_excel_parser.py | deepseek-v4-flash-free | diff --git a/src/genesis/parsers/excel_parser.py b/src/genesis/parsers/excel_parser.py index daeb7c0..9b653ac 100644 --- a/src/genesis/parsers/excel_parser.py +++ b/src/genesis/parsers/excel_parser.py @@ -3,11 +3,12 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path -from genesis.data_models import CellComment, ExcelTable, MixedSheet +from genesis.data_models import CellComment, ExcelTable, MixedParagraph, MixedSheet 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, find_header_row from genesis.parsers.merge_fill import forward_fill +from genesis.parsers.paragraph_splitter import split_paragraphs from genesis.parsers.table_extractor import extract_table from genesis.parsers.formatting_detector import cell_formatting, collect_comments from genesis.parsers.free_text_extractor import build_free_text_table, extract_text_blocks @@ -36,12 +37,59 @@ class ExcelParser: continue detected_type = detect_sheet_type(ws.title, matrix) nature = classify_sheet(matrix) - if nature == SheetNature.FREE_TEXT: + if nature == SheetNature.MIXED: + # 合并单元格:整 sheet 先填充再按段切片 + merged = [ + (r.min_row, r.min_col, r.max_row, r.max_col) + for r in ws.merged_cells.ranges + ] + filled_all = forward_fill(matrix, merged) if merged else matrix + # 整 sheet 构建格式映射一次(按物理坐标) + fmt_map = {} + for row in ws.iter_rows(): + for cell in row: + fmt = cell_formatting(cell) + if fmt is not None: + fmt_map[(cell.row - 1, cell.column - 1)] = fmt + mixed_sheet = MixedSheet(name=ws.title) + for (s, e) in split_paragraphs(matrix): + seg = filled_all[s:e + 1] + seg_nature = classify_sheet(seg) + if seg_nature == SheetNature.TABLE: + 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=seg_fmt_map, + ) + result.tables.append(table) + mixed_sheet.paragraphs.append(MixedParagraph( + kind="table", matrix=seg, table=table, + source_range=(s, e), + )) + else: + blocks = extract_text_blocks(seg) + table = build_free_text_table(ws.title, blocks, file_name, detected_type) + result.tables.append(table) + mixed_sheet.paragraphs.append(MixedParagraph( + kind="free_text", matrix=seg, + text="\n".join(blocks), source_range=(s, e), + )) + result.mixed.append(mixed_sheet) + elif nature == SheetNature.FREE_TEXT: blocks = extract_text_blocks(matrix) result.tables.append( build_free_text_table(ws.title, blocks, file_name, detected_type) ) else: + # 现有 TABLE 路径(MIXED 已独立,不折叠至此) merged = [ (r.min_row, r.min_col, r.max_row, r.max_col) for r in ws.merged_cells.ranges diff --git a/tests/test_excel_parser.py b/tests/test_excel_parser.py index 1e6a6b1..e1af748 100644 --- a/tests/test_excel_parser.py +++ b/tests/test_excel_parser.py @@ -81,3 +81,58 @@ def test_mixed_sheet_holds_paragraphs(): ms = MixedSheet(name="混合", paragraphs=[p1, p2]) assert ms.name == "混合" assert [p.kind for p in ms.paragraphs] == ["table", "free_text"] + + +def test_parse_mixed_sheet_segmented(tmp_path): + wb = new_workbook({ + "混合": [ + ["機能ID", "機能名"], + ["F101", "社員登録"], + ["F102", "退職処理"], + [], + ["・改修ポイント:F102 追加バリデーション"], + ["■対象画面:SC001"], + ], + }) + path = save_workbook(tmp_path, wb) + result = ExcelParser().parse(path) + assert result.mixed, "混合 sheet 应产产出 mixed 段落" + ms = result.mixed[0] + assert ms.name == "混合" + kinds = [p.kind for p in ms.paragraphs] + assert "table" in kinds and "free_text" in kinds + # 表格段无碎片污染:table 段应含 2 数据行,机能ID 首行为 F101 + tbl = [p.table for p in ms.paragraphs if p.kind == "table"][0] + assert tbl is not None and len(tbl.rows) == 2 + assert tbl.rows[0]["機能ID"].value == "F101" + # 自由文本段捕获碎片 + 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