From d7769821310433c8d15d4a86662327c84ab11295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=84=B1=EC=A4=80?= Date: Tue, 2 Jun 2026 09:46:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20diff=5Fdocuments=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20API=20(=ED=8E=B8=EC=A7=91=20=EC=A0=84/=ED=9B=84=20=EC=85=80?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=EB=B9=84=EA=B5=90=20+=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=ED=94=8C=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리서치 기반 궁극 로드맵의 핵심(신뢰성 해자) 1번 항목 구현. 이번 세션에서 사람이 스크린샷으로 잡던 검증(스페이서·라벨오염·오버플로)을 자동화한다. - document_adapter/diff.py: diff_documents(path_a, path_b) — preview 그리드 비교로 변경 셀의 before/after + overflow_risk + tables_added/removed 반환. 4포맷 공통. - MCP 도구 diff_documents 등록 → 총 12개. fill 후 원본과 diff 해 LLM 이 자가검증. - 회귀 테스트: 변경 검출/동일=0/도구 경로. 82개 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++ document_adapter/diff.py | 87 +++++++++++++++++++++++++++++++++++++++ document_adapter/tools.py | 22 ++++++++++ tests/test_scenarios.py | 46 +++++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 document_adapter/diff.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 132cbc5..3e38666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: ## [Unreleased] +### Added +- **`diff_documents(path_a, path_b)`** — 편집 전/후 문서를 셀 단위로 비교해 변경된 + 셀의 before/after 와 overflow_risk 를 반환하는 **검증 도구**(MCP 도구 → 총 12 개). + fill 후 원본과 diff 해 "무엇이 어디서 바뀌었고 깨짐 위험은 없나" 를 LLM 이 스스로 + 확인·자가교정할 수 있다. 4 포맷 공통. +- **xlsx 수식 계산값**: 캐시된 계산값이 있으면 그 값을, 없으면 수식 문자열을 표시 + (`data_only` lazy 폴백). 수식은 편집/저장 시 보존. + ## [0.10.0] — 2026-06-01 코드 감사로 포맷별(docx/pptx/xlsx) 격차를 점검해 **Excel 지원을 신규 추가**하고, diff --git a/document_adapter/diff.py b/document_adapter/diff.py new file mode 100644 index 0000000..53971c7 --- /dev/null +++ b/document_adapter/diff.py @@ -0,0 +1,87 @@ +"""문서 diff — 편집 전/후를 셀 단위로 비교해 변경 사항을 구조적으로 반환. + +LLM 워크플로우의 **검증 도구**: 원본을 복사해 두고 fill_form/set_cell 로 편집한 뒤 +``diff_documents(원본, 편집본)`` 으로 "무엇이 어디서 어떻게 바뀌었나 + 오버플로 +위험은 없나" 를 확인한다. 사람이 스크린샷으로 잡던 검증을 자동화한다. + +포맷 무관(docx/pptx/hwpx/xlsx) — get_tables 의 preview 그리드를 비교한다. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from document_adapter import load +from document_adapter.base import _overflow_risk + +# 비교 시 전체 셀을 보기 위한 충분히 큰 한계(폼·보고서 수준에서 안전). +_FULL_ROWS = 100_000 +_FULL_LEN = 100_000 + + +def _grid_get(preview: list[list[str | None]], r: int, c: int) -> str | None: + if r < len(preview) and c < len(preview[r]): + return preview[r][c] + return None + + +def diff_documents( + path_a: str | Path, + path_b: str | Path, + *, + include_overflow: bool = True, +) -> dict[str, Any]: + """두 문서를 셀 단위로 비교. (A=이전/원본, B=이후/편집본) + + Returns: + { + "changed": 변경 셀 수, + "changes": [{table_index, location, row, col, before, after, + overflow_risk?}, ...], + "tables_added": [...], "tables_removed": [...], + } + """ + a = load(path_a) + b = load(path_b) + try: + ta = {t.index: t for t in + a.get_tables(preview_rows=_FULL_ROWS, max_cell_len=_FULL_LEN)} + tb = {t.index: t for t in + b.get_tables(preview_rows=_FULL_ROWS, max_cell_len=_FULL_LEN)} + + changes: list[dict[str, Any]] = [] + for idx in sorted(set(ta) | set(tb)): + sa, sb = ta.get(idx), tb.get(idx) + if sa is None or sb is None: + continue + rows = max(sa.rows, sb.rows) + cols = max(sa.cols, sb.cols) + for r in range(rows): + for c in range(cols): + va = _grid_get(sa.preview, r, c) + vb = _grid_get(sb.preview, r, c) + if (va or "") == (vb or ""): + continue + entry: dict[str, Any] = { + "table_index": idx, + "location": sb.location or sa.location, + "row": r, "col": c, + "before": va, "after": vb, + } + if include_overflow and vb: + try: + cell = b.get_cell(idx, r, c) + entry["overflow_risk"] = _overflow_risk(vb, cell.width_cm) + except Exception: + pass + changes.append(entry) + + return { + "changed": len(changes), + "changes": changes, + "tables_added": sorted(set(tb) - set(ta)), + "tables_removed": sorted(set(ta) - set(tb)), + } + finally: + a.close() + b.close() diff --git a/document_adapter/tools.py b/document_adapter/tools.py index f3f0e8d..8f183cf 100644 --- a/document_adapter/tools.py +++ b/document_adapter/tools.py @@ -234,6 +234,22 @@ "required": ["path", "slide_index", "shape_id", "text"], }, }, + { + "name": "diff_documents", + "description": ( + "두 문서(예: 원본 vs 편집본)를 셀 단위로 비교해 **무엇이 어디서 어떻게 " + "바뀌었는지** 반환. 편집/채우기 후 검증용 — 변경 셀의 before/after 와 " + "overflow_risk(값이 칸을 넘쳐 깨질 위험)를 함께 준다. 4 포맷 공통." + ), + "input_schema": { + "type": "object", + "properties": { + "path_a": {"type": "string", "description": "이전/원본 경로"}, + "path_b": {"type": "string", "description": "이후/편집본 경로"}, + }, + "required": ["path_a", "path_b"], + }, + }, { "name": "get_form_controls", "description": ( @@ -555,6 +571,11 @@ def set_form_control(path: str, name: str, value: Any, } +def diff_documents(path_a: str, path_b: str) -> dict[str, Any]: + from document_adapter.diff import diff_documents as _diff + return _diff(path_a, path_b) + + def fill_form(path: str, data: dict[str, str], direction: str = "auto", strict: bool = False, output_path: str | None = None) -> dict[str, Any]: @@ -587,6 +608,7 @@ def fill_form(path: str, data: dict[str, str], "set_shape_text": set_shape_text, "get_form_controls": get_form_controls, "set_form_control": set_form_control, + "diff_documents": diff_documents, } diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index a402498..95ca0a3 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -714,6 +714,52 @@ def test_pptx_notes_placeholders_and_render(tmp_path: Path) -> None: assert any("확인됨" in n for n in notes) +def test_diff_documents(tmp_path: Path) -> None: + """diff_documents: 편집 전/후 변경 셀을 before/after + overflow 로 반환.""" + import shutil + from document_adapter.diff import diff_documents + + orig = tmp_path / "orig.hwpx" + _make_form_hwpx(orig, [("성명", ""), ("부서", "")]) + + # 동일 문서끼리는 변경 0 + same = diff_documents(orig, orig) + assert same["changed"] == 0 + + # 채운 뒤 diff + edited = tmp_path / "edited.hwpx" + shutil.copy2(orig, edited) + ad = load(edited) + ad.fill_form({"성명": "홍길동", "부서": "개발팀"}) + ad.save(edited) + ad.close() + + r = diff_documents(orig, edited) + assert r["changed"] == 2 + by_cell = {(ch["row"], ch["col"]): ch for ch in r["changes"]} + assert by_cell[(0, 1)]["before"] == "" and by_cell[(0, 1)]["after"] == "홍길동" + assert by_cell[(1, 1)]["after"] == "개발팀" + assert all("overflow_risk" in ch for ch in r["changes"]) + + +def test_diff_documents_via_tool(tmp_path: Path) -> None: + """MCP call_tool 경로로 diff_documents 동작.""" + import shutil + from document_adapter.tools import call_tool + + orig = tmp_path / "o.hwpx" + _make_form_hwpx(orig, [("a", ""), ("b", "")]) + edited = tmp_path / "e.hwpx" + shutil.copy2(orig, edited) + ad = load(edited) + ad.set_cell(0, 0, 1, "X") + ad.save(edited) + ad.close() + r = call_tool("diff_documents", {"path_a": str(orig), "path_b": str(edited)}) + assert r["changed"] == 1 + assert r["changes"][0]["after"] == "X" + + def test_get_cell_out_of_bounds_raises(tmp_path: Path) -> None: """경계를 벗어난 좌표는 CellOutOfBoundsError(IndexError 하위).""" from document_adapter.base import CellOutOfBoundsError