Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 지원을 신규 추가**하고,
Expand Down
87 changes: 87 additions & 0 deletions document_adapter/diff.py
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 22 additions & 0 deletions document_adapter/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
}


Expand Down
46 changes: 46 additions & 0 deletions tests/test_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading