Skip to content

Commit 4c81639

Browse files
DanMeonclaude
andcommitted
feat: TableCell 병합 빈 셀 layout role 자동 태깅 + IR 예제 추가
변경사항: - `is_layout_cell()` 도입 (`src/ir.rs`) — 병합 (`row_span>1` 또는 `col_span>1`) AND 모든 paragraph 텍스트가 공백인 셀을 `role="layout"` 으로 분류하는 보수적 heuristic. 병합 non-empty 나 비병합 empty 는 `"data"` 유지. Rust 단위 테스트 5종 (`cell_role_*`) + pytest 회귀 테스트 1종 (`test_layout_role_on_merged_empty_cells`) 추가 - `examples/04_document_ir.py` (typer) — `to_ir()` 구조 탐색, 블록 타입 분포, layout 셀 카운트, 첫 표 HTML, `--out` JSON 덤프 - `examples/05_langchain_ir_blocks.py` (typer) — `HwpLoader(mode="ir-blocks")` 의 단락/표 매핑 미리보기 + dual-track RAG 가이드 - `examples/README.md` — 4/5 섹션 추가, "01~05 예제 일괄 설치" 로 업데이트 - `python/rhwp/ir/nodes.py` — `Optional[T]` → `T | None`, `Union[A,B,C]` → `A | B | C` 일괄 전환 (Python 3.10+ 드랍 반영) + docstring/주석 간소화 - `src/ir.rs`, `src/document.rs`, `python/rhwp/ir/schema.py`, `python/rhwp/integrations/langchain.py` 의 개발 스테이지 참조 주석 (S1/S2/S3, v0.2.0 S*) 정리 — 코드가 독립적으로 읽히도록 - `docs/roadmap/v0.2.0/ir.md` — Role 매핑 heuristic 표 신설 (is_header / 병합-empty / 그 외) - `hwp_ir_v1.json` 재생성 (모델 description 반영), CHANGELOG v0.2.0 에 layout 항목 + 테스트 수 164 → 165 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 29fb832 commit 4c81639

12 files changed

Lines changed: 396 additions & 147 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ MINOR release — Phase 2 착수. RAG / LLM 파이프라인이 직접 소비하
2323
- JSON Schema export — `rhwp.ir.schema.export_schema()` / `load_schema()` / `SCHEMA_ID` / `SCHEMA_DIALECT` + in-package `hwp_ir_v1.json` + `python -m rhwp.ir.schema` CLI.
2424
- Discriminator 후처리 — `_harden_unknown_variant()` 가 UnknownBlock.kind 에 `not.enum: [known kinds]` 주입하여 oneOf 검증 정확도 보장.
2525
- `HwpLoader``mode="ir-blocks"` 추가 — Block 을 LangChain `Document` 로 매핑 (표는 HTML content + 구조화 메타, 단락은 text + Provenance).
26+
- `TableCell.role="layout"` 자동 태깅 — 병합된 빈 셀 (구조 유지용 비의미 셀) 을 LLM 이 "레이아웃 요소" 로 인식하도록 시맨틱 구분. 보수적 heuristic: 병합 AND 공백만 있는 셀만 `layout`, 그 외 empty 셀은 `data` 유지.
2627
- `.github/workflows/publish-schema.yml` — GitHub Pages 배포 파이프라인, 불변 경로 정책 (v1 URL 영구) 자동화.
2728
- Provenance 단위는 **Unicode codepoint** — Python `str[i]` 슬라이싱과 직접 호환 (이모지/SMP CJK 혼용에서도 off-by-one 없음).
2829
- 신규 런타임 의존성: `pydantic>=2.5,<3`. 테스트 의존성: `jsonschema>=4`.
2930
- 문서: `docs/roadmap/v0.2.0/ir.md` (사양), `docs/design/v0.2.0/ir-design-research.md` (7개 결정 증거), `docs/implementation/v0.2.0/stages/stage-{1..5}.md`.
30-
- 테스트: **164 passed** — IR schema/roundtrip/tables/iter/export + LangChain ir-blocks + Rust unit tests (`cargo test` 5 passed).
31+
- 테스트: **165 passed** — IR schema/roundtrip/tables/iter/export + LangChain ir-blocks + Rust unit tests (`cargo test` 5 passed).
3132

3233
### Changed — Phase 2 계획 전환
3334

docs/roadmap/v0.2.0/ir.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ match block:
198198

199199
`role: Literal["data" | "column_header" | "row_header" | "layout"]` — DocLayNet 파생 어휘. `"layout"` 은 간격용 빈 셀 등 비의미 셀(연구 결과의 반패턴 5 방지).
200200

201+
**Role 매핑 heuristic** (Rust 매퍼):
202+
203+
| 조건 | role |
204+
|---|---|
205+
| `Cell.is_header == true` | `"column_header"` |
206+
| 병합 (`row_span > 1` 또는 `col_span > 1`) AND 모든 paragraph 텍스트가 공백 | `"layout"` |
207+
| 그 외 | `"data"` |
208+
209+
병합 + empty 조합만 `"layout"` 으로 분류하는 보수적 기준 — 병합되지 않은 빈 셀은 "아직 채워지지 않은 데이터 칸" 일 가능성을 배제할 수 없어 `"data"` 를 유지한다. 더 정교한 detection (주변 context, fill ratio 등) 은 상위 시맨틱 레이어에서 확장할 수 있다. HWP 스펙상 row vs column header 구분이 없어 `"row_header"` 는 현재 출고되지 않지만 enum 값은 예약되어 있다.
210+
201211
### 단락 내 InlineRun
202212

203213
상류 `Paragraph.char_shapes: Vec<CharShapeRef>` 를 UTF-16 위치 기준으로 런으로 변환하되, **의미 있는 속성만** 노출:

examples/04_document_ir.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""HWP → Document IR (Pydantic HwpDocument) 변환 + 블록 순회 + JSON 직렬화 시연.
2+
3+
Document IR 은 구역/단락/표/셀을 계층 구조로 보존하며, 표는 cells 배열 + HTML
4+
+ 평문 3중 표현을 병기한다. 병합된 빈 셀은 ``role="layout"`` 으로 자동 태깅된다.
5+
6+
사용법:
7+
python examples/04_document_ir.py path/to/your/file.hwp
8+
python examples/04_document_ir.py path/to/your/file.hwp --limit 20
9+
python examples/04_document_ir.py path/to/your/file.hwp --out ir.json
10+
11+
설치:
12+
pip install "rhwp-python[examples]"
13+
"""
14+
15+
from pathlib import Path as PathLibPath
16+
17+
import rhwp
18+
import typer
19+
from rhwp.ir.nodes import ParagraphBlock, TableBlock, UnknownBlock
20+
21+
22+
def main(
23+
path: PathLibPath = typer.Argument(
24+
PathLibPath("external/rhwp/samples/table-vpos-01.hwpx"),
25+
exists=False,
26+
help="HWP 또는 HWPX 파일 경로 (기본값은 submodule 샘플 — 표가 9개 포함됨)",
27+
),
28+
limit: int = typer.Option(15, "--limit", "-n", help="미리보기할 블록 최대 개수"),
29+
out: PathLibPath = typer.Option(
30+
None,
31+
"--out",
32+
"-o",
33+
help="IR 전체를 JSON 파일로 저장 (예: ir.json)",
34+
),
35+
) -> None:
36+
"""Document IR 구조를 탐색하고 선택적으로 JSON 으로 덤프한다."""
37+
if not path.exists():
38+
typer.echo(f"파일이 없습니다: {path}", err=True)
39+
raise typer.Exit(code=1)
40+
41+
doc = rhwp.parse(str(path))
42+
ir = doc.to_ir()
43+
44+
# * 문서 요약
45+
typer.echo("=" * 60)
46+
typer.echo("[문서 메타]")
47+
typer.echo("=" * 60)
48+
typer.echo(f" schema: {ir.schema_name} v{ir.schema_version}")
49+
typer.echo(f" sections: {len(ir.sections)}")
50+
typer.echo(f" body: {len(ir.body)} 블록 (top-level)")
51+
52+
# * 블록 타입 분포 (recurse=True 로 중첩 표 내부까지)
53+
type_counts: dict[str, int] = {}
54+
layout_cells = 0
55+
for block in ir.iter_blocks(scope="body", recurse=True):
56+
type_counts[type(block).__name__] = type_counts.get(type(block).__name__, 0) + 1
57+
if isinstance(block, TableBlock):
58+
layout_cells += sum(1 for c in block.cells if c.role == "layout")
59+
60+
typer.echo("\n[블록 분포 — recurse=True]")
61+
for name, cnt in sorted(type_counts.items()):
62+
typer.echo(f" {name:20s} {cnt}")
63+
if layout_cells:
64+
typer.echo(f" {'layout cells':20s} {layout_cells} (병합된 비의미 셀)")
65+
66+
# * 블록 리스트 미리보기
67+
typer.echo("\n" + "=" * 60)
68+
typer.echo(f"[body 미리보기 — 최대 {limit} 블록]")
69+
typer.echo("=" * 60)
70+
for i, block in enumerate(ir.iter_blocks(scope="body")):
71+
if i >= limit:
72+
typer.echo(f" ... ({len(ir.body) - limit} more)")
73+
break
74+
prov = f"s={block.prov.section_idx} p={block.prov.para_idx}"
75+
if isinstance(block, ParagraphBlock):
76+
text = block.text[:60].replace("\n", "⏎")
77+
typer.echo(f" [P {prov}] {text!r}")
78+
elif isinstance(block, TableBlock):
79+
cap = f" caption={block.caption!r}" if block.caption else ""
80+
typer.echo(f" [T {prov}] {block.rows}x{block.cols} cells={len(block.cells)}{cap}")
81+
# ^ 첫 행만 간단 덤프
82+
first_row = [c for c in block.cells if c.row == 0]
83+
for c in first_row[:4]:
84+
snippet = block.text.split("\n")[0][:50]
85+
typer.echo(f" cell(0,{c.col}) role={c.role} row1={snippet!r}")
86+
break # first row 한 줄만
87+
elif isinstance(block, UnknownBlock):
88+
typer.echo(f" [? {prov}] kind={block.kind!r} (forward-compat)")
89+
90+
# * 첫 TableBlock 의 HTML 미리보기
91+
first_table = next((b for b in ir.iter_blocks(scope="body") if isinstance(b, TableBlock)), None)
92+
if first_table:
93+
typer.echo("\n" + "=" * 60)
94+
typer.echo(f"[첫 표의 HTML — HtmlRAG 호환, {first_table.rows}x{first_table.cols}]")
95+
typer.echo("=" * 60)
96+
typer.echo(first_table.html)
97+
98+
# * 선택: JSON 파일로 덤프
99+
if out is not None:
100+
json_str = doc.to_ir_json(indent=2)
101+
out.parent.mkdir(parents=True, exist_ok=True)
102+
out.write_text(json_str, encoding="utf-8")
103+
typer.echo(f"\n전체 IR 을 {out} 에 저장 ({len(json_str):,} 바이트).")
104+
105+
typer.echo(
106+
"\n다음 단계: `rhwp.ir.schema.export_schema()` 또는 `load_schema()` 로 JSON Schema 확보."
107+
)
108+
typer.echo(" 또는 `examples/05_langchain_ir_blocks.py` 로 LangChain ir-blocks 모드 시연.")
109+
110+
111+
if __name__ == "__main__":
112+
typer.run(main)

examples/05_langchain_ir_blocks.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""HwpLoader(mode="ir-blocks") — 표는 HTML content, 단락은 text, Provenance 메타.
2+
3+
03 예제는 single/paragraph 모드 + 청킹만 다룬다. 본 예제는 ir-blocks 모드로
4+
표 구조 (rowspan/colspan) 를 보존하면서 RAG 인덱스를 구성할 때 얻는 이점을
5+
보여준다 — HtmlRAG 패턴에서 LLM 이 병합 셀의 의미를 읽을 수 있게 한다.
6+
7+
사용법:
8+
python examples/05_langchain_ir_blocks.py path/to/your/file.hwp
9+
python examples/05_langchain_ir_blocks.py path/to/your/file.hwp --kind-filter table
10+
11+
설치:
12+
pip install "rhwp-python[langchain,examples]"
13+
"""
14+
15+
from pathlib import Path as PathLibPath
16+
17+
import typer
18+
from rhwp.integrations.langchain import HwpLoader
19+
20+
21+
def main(
22+
path: PathLibPath = typer.Argument(
23+
PathLibPath("external/rhwp/samples/table-vpos-01.hwpx"),
24+
exists=False,
25+
help="HWP 또는 HWPX 파일 경로 (기본값은 표 9개 포함 샘플)",
26+
),
27+
kind_filter: str = typer.Option(
28+
"all",
29+
"--kind-filter",
30+
"-k",
31+
help="표시할 블록 종류: all | paragraph | table",
32+
),
33+
limit: int = typer.Option(10, "--limit", "-n", help="표시할 Document 최대 개수"),
34+
) -> None:
35+
"""ir-blocks 모드의 Document 매핑을 단락/표 유형별로 미리본다."""
36+
if not path.exists():
37+
typer.echo(f"파일이 없습니다: {path}", err=True)
38+
raise typer.Exit(code=1)
39+
if kind_filter not in ("all", "paragraph", "table"):
40+
typer.echo(f"잘못된 --kind-filter: {kind_filter!r}", err=True)
41+
raise typer.Exit(code=1)
42+
43+
docs = HwpLoader(str(path), mode="ir-blocks").load()
44+
45+
# * 전체 요약
46+
by_kind: dict[str, int] = {}
47+
for d in docs:
48+
k = d.metadata.get("kind", "?")
49+
by_kind[k] = by_kind.get(k, 0) + 1
50+
51+
typer.echo("=" * 60)
52+
typer.echo("[ir-blocks 모드 요약]")
53+
typer.echo("=" * 60)
54+
typer.echo(f" 총 Document 수: {len(docs)}")
55+
for k, v in sorted(by_kind.items()):
56+
typer.echo(f" {k:12s} {v}")
57+
58+
# * 종류별 미리보기
59+
if kind_filter == "all":
60+
to_show = docs
61+
else:
62+
to_show = [d for d in docs if d.metadata.get("kind") == kind_filter]
63+
64+
typer.echo("\n" + "=" * 60)
65+
typer.echo(f"[Document 미리보기 — kind={kind_filter}, 최대 {limit}개]")
66+
typer.echo("=" * 60)
67+
68+
for i, d in enumerate(to_show[:limit]):
69+
kind = d.metadata.get("kind", "?")
70+
prov = f"s={d.metadata.get('section_idx')} p={d.metadata.get('para_idx')}"
71+
typer.echo(f"\n[{i + 1}] kind={kind} {prov}")
72+
73+
if kind == "paragraph":
74+
typer.echo(f" page_content: {d.page_content[:80]!r}")
75+
typer.echo(
76+
f" char range: [{d.metadata.get('char_start')}, {d.metadata.get('char_end')})"
77+
)
78+
elif kind == "table":
79+
rows, cols = d.metadata.get("rows"), d.metadata.get("cols")
80+
typer.echo(f" shape: {rows}x{cols}")
81+
typer.echo(f" caption: {d.metadata.get('caption')!r}")
82+
typer.echo(f" HTML[:120]: {d.page_content[:120]}")
83+
typer.echo(f" text[:80]: {d.metadata.get('text', '')[:80]!r}")
84+
85+
if len(to_show) > limit:
86+
typer.echo(f"\n ... ({len(to_show) - limit} more)")
87+
88+
typer.echo("\n" + "=" * 60)
89+
typer.echo("RAG 팁: 표 Document 의 page_content 는 HTML 이라 LLM 이 rowspan/colspan 의")
90+
typer.echo("의미를 해석할 수 있다. 검색 색인에는 metadata['text'] (평문) 를 사용하고")
91+
typer.echo("LLM 프롬프트에 HTML 을 넘기는 dual-track RAG 가 손실 없이 동작한다.")
92+
93+
94+
if __name__ == "__main__":
95+
typer.run(main)

examples/README.md

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
## 사전 준비
77

88
```bash
9-
# 01 ~ 03 예제 전부 한 방에 설치 (typer + langchain-core + text-splitters)
9+
# 01 ~ 05 예제 전부 한 방에 설치 (typer + langchain-core + text-splitters)
1010
pip install "rhwp-python[examples]"
1111
```
1212

@@ -49,12 +49,33 @@ python examples/03_langchain_rag.py path/to/your/file.hwp --chunk-size 1000 --ch
4949
- `--chunk-size INT` : 청크 최대 문자 수 (기본 500)
5050
- `--chunk-overlap INT` : 청크 간 오버랩 (기본 50)
5151

52-
## 릴리스 전 실제 HWP 검증
52+
### 4. Document IR — `04_document_ir.py`
53+
54+
```bash
55+
python examples/04_document_ir.py path/to/your/file.hwp
56+
python examples/04_document_ir.py path/to/your/file.hwp --limit 20
57+
python examples/04_document_ir.py path/to/your/file.hwp --out ir.json
58+
```
59+
60+
`to_ir()` 로 구조화 IR 을 얻어 블록 타입 분포, layout 셀 개수, 첫 표의 HTML 직렬화를 출력. `--out` 으로 전체 IR 을 JSON 파일로 저장 가능.
61+
62+
옵션:
63+
- `--limit / -n INT` : 미리보기할 블록 최대 개수 (기본 15)
64+
- `--out / -o PATH` : 전체 IR 을 JSON 파일로 덤프
5365

54-
릴리스 직전 **본인의 업무 HWP 파일 3종 (일반 문서 / 장문 / HWPX)** 으로 세 스크립트를 순서대로 돌려 출력을 육안 확인한다. 한컴오피스 뷰어로 연 원본과 대조해 섹션/문단/페이지 수치가 맞는지, SVG/PDF 가 깨지지 않는지 본다.
66+
### 5. LangChain `ir-blocks` 모드 — `05_langchain_ir_blocks.py`
5567

56-
## 향후 CLI 도입 계획
68+
```bash
69+
python examples/05_langchain_ir_blocks.py path/to/your/file.hwp
70+
python examples/05_langchain_ir_blocks.py path/to/your/file.hwp --kind-filter table
71+
```
72+
73+
`HwpLoader(mode="ir-blocks")` 가 단락은 text, 표는 **HTML** (HtmlRAG 호환) 로 매핑하는 것을 단락/표 유형별로 미리본다. 표에는 `rows`/`cols`/`caption`/`text` 가 메타로 함께 노출되어 dual-track RAG (임베딩=평문, LLM=HTML) 가 가능.
74+
75+
옵션:
76+
- `--kind-filter / -k {all,paragraph,table}` : 표시 종류 필터 (기본 `all`)
77+
- `--limit / -n INT` : 미리보기할 Document 최대 개수 (기본 10)
78+
79+
## 릴리스 전 실제 HWP 검증
5780

58-
예제의 `typer.run(main)` 패턴은 추후 v0.2 에서 `python/rhwp/cli.py` 모듈로 승격 예정.
59-
그 시점엔 `pip install rhwp-python` 만으로 `rhwp parse file.hwp`, `rhwp render file.hwp` 같은
60-
명령을 바로 쓸 수 있도록 `[project.scripts]` 에 엔트리포인트를 노출할 계획.
81+
릴리스 직전 **본인의 업무 HWP 파일 3종 (일반 문서 / 장문 / HWPX)** 으로 다섯 스크립트를 순서대로 돌려 출력을 육안 확인한다. 한컴오피스 뷰어로 연 원본과 대조해 섹션/문단/페이지 수치, SVG/PDF 렌더, IR 의 block/table 구조, LangChain Document 매핑이 깨지지 않는지 본다.

python/rhwp/integrations/langchain.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,8 @@ def _block_to_content_and_meta(block: Block) -> tuple[str, dict[str, Any]]:
124124
"text": block.text,
125125
"caption": block.caption,
126126
}
127-
# UnknownBlock — forward-compat. kind 를 메타에 노출하고 page_content 는 비움
128-
# ^ v0.3.0+ 에서 PictureBlock/FormulaBlock 등 새 variant 가 추가되면 그 variant 의
129-
# elif isinstance(block, XxxBlock): ... 분기를 이 assert 보다 위에 먼저 추가해야 한다.
130-
# 그러지 않으면 AssertionError 로 fail-fast (의도적인 방어 — silent fallback 방지)
127+
# 새 Block variant 가 추가되면 그 variant 의 elif 를 이 assert 보다 위에 먼저
128+
# 추가해야 한다. 그러지 않으면 AssertionError 로 fail-fast (silent fallback 방지)
131129
assert isinstance(block, UnknownBlock)
132130
return "", {
133131
"kind": block.kind,

0 commit comments

Comments
 (0)