Skip to content

Commit 1768172

Browse files
DanMeonclaude
andcommitted
feat: v0.2.0 Document IR v1 구현 (Phase 2 착수)
변경사항: - Pydantic V2 기반 공개 IR 모델 10개 추가 (rhwp.ir.nodes) — callable Discriminator 로 UnknownBlock forward-compat - Rust → dict → Pydantic 매퍼 (src/ir.rs) + OnceCell<Py<PyAny>> lazy 캐시 + Document.to_ir() / to_ir_json() 바인딩 - HwpDocument.iter_blocks(*, scope, recurse) DFS 스트리밍 — body/furniture/all + TableCell 재귀 - 표 3중 표현 (구조화 cells + HtmlRAG 호환 HTML + 평문 text), 중첩 표 재귀 지원 - JSON Schema (Draft 2020-12) export — rhwp.ir.schema + in-package hwp_ir_v1.json + GitHub Pages 배포 워크플로우 (불변 v1 경로) - HwpLoader 에 mode="ir-blocks" 추가 — LangChain Document 매핑 (표는 HTML, 단락은 text, Provenance 메타) - pydantic>=2.5 코어 의존성, jsonschema>=4 testing 그룹 추가 - 163 Python + 5 Rust 테스트 통과, code-reviewer 5회 fresh-context 검증 - 설계 문서: docs/implementation/v0.2.0/stages/stage-{1..5}.md + ir.md 필드 스펙 보강 + CHANGELOG Unreleased Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 537c544 commit 1768172

28 files changed

Lines changed: 4032 additions & 33 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ jobs:
6666
uv run pyright python/ \
6767
tests/test_smoke.py tests/test_parse.py tests/test_text_extraction.py \
6868
tests/test_errors.py tests/test_svg_rendering.py tests/test_pdf_rendering.py \
69-
tests/test_langchain_loader.py tests/conftest.py tests/type_check_samples.py
69+
tests/test_langchain_loader.py tests/test_langchain_loader_ir.py \
70+
tests/test_ir_schema.py tests/test_ir_roundtrip.py tests/test_ir_tables.py \
71+
tests/test_ir_iter_blocks.py tests/test_ir_schema_export.py \
72+
tests/conftest.py tests/type_check_samples.py
7073
- name: Run pyright (intentional errors — expect 4)
7174
if: matrix.lint
7275
run: |
@@ -120,11 +123,13 @@ jobs:
120123
uv venv
121124
uv pip install pytest
122125
- run: uv run maturin develop --release
123-
- name: Run pytest — langchain tests must auto-skip via importorskip
124-
# ^ 파일-레벨 importorskip 은 해당 파일 전체를 skip 1개로 카운트
126+
- name: Run pytest — extras-gated tests must auto-skip via importorskip
127+
# ^ 파일-레벨 importorskip 은 해당 파일 전체를 skip 1개로 카운트.
128+
# 현재 gated 파일: test_langchain_loader.py + test_langchain_loader_ir.py
129+
# (langchain-core), test_ir_schema_export.py (jsonschema) → 총 3 파일
125130
run: |
126131
uv run pytest tests/ -m "not slow" -v | tee pytest-output.txt
127-
if ! grep -qE '(^|[^0-9])1 skipped([^0-9]|$)' pytest-output.txt; then
128-
echo "::error::expected test_langchain_loader.py to auto-skip via importorskip"
132+
if ! grep -qE '(^|[^0-9])3 skipped([^0-9]|$)' pytest-output.txt; then
133+
echo "::error::expected 3 extras-gated files to auto-skip via importorskip (langchain×2, jsonschema)"
129134
exit 1
130135
fi
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Publish JSON Schema
2+
3+
# Document IR v1 JSON Schema 를 GitHub Pages 로 배포.
4+
# 불변 경로 정책 (ir.md §JSON Schema 공개): v1 URL 영구 보존.
5+
# Breaking change 는 v2/schema.json 새 URL 로 (v1 덮어쓰기 금지).
6+
7+
on:
8+
push:
9+
branches: [main]
10+
paths:
11+
- 'python/rhwp/ir/schema/hwp_ir_v1.json'
12+
- 'python/rhwp/ir/nodes.py'
13+
- 'python/rhwp/ir/schema.py'
14+
- '.github/workflows/publish-schema.yml'
15+
workflow_dispatch: {}
16+
17+
permissions:
18+
contents: read
19+
pages: write
20+
id-token: write
21+
22+
concurrency:
23+
group: 'pages'
24+
cancel-in-progress: false
25+
26+
jobs:
27+
# * 코드 변경이 in-package JSON 과 sync 되는지 검증 (CI 가드)
28+
verify-sync:
29+
name: Verify schema sync
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v6
33+
with:
34+
submodules: recursive
35+
- uses: dtolnay/rust-toolchain@stable
36+
- uses: Swatinem/rust-cache@v2
37+
- uses: astral-sh/setup-uv@v8.1.0
38+
with:
39+
python-version: '3.12'
40+
- run: uv sync --no-install-project --group testing
41+
- run: uv run maturin develop --release
42+
- name: Regenerate schema and diff against checked-in file
43+
run: |
44+
uv run python -m rhwp.ir.schema > /tmp/regenerated.json
45+
diff -u python/rhwp/ir/schema/hwp_ir_v1.json /tmp/regenerated.json
46+
47+
# * GitHub Pages 배포 — v1 경로 불변
48+
deploy:
49+
name: Deploy to GitHub Pages
50+
needs: verify-sync
51+
environment:
52+
name: github-pages
53+
url: ${{ steps.deployment.outputs.page_url }}
54+
runs-on: ubuntu-latest
55+
steps:
56+
- uses: actions/checkout@v6
57+
- name: Prepare pages directory — copy every versioned schema
58+
# ^ 불변 경로 정책: repo 의 hwp_ir_v*.json 을 모두 각 버전 URL 로 배포.
59+
# v2 도입 시 python/rhwp/ir/schema/hwp_ir_v2.json 을 추가하기만 하면
60+
# 이 루프가 자동으로 v1/v2 양쪽 모두를 pages 아티팩트에 포함한다.
61+
# `actions/deploy-pages@v4` 의 replace-all 동작으로 v1 이 누락되는 것을 원천 차단.
62+
run: |
63+
set -euo pipefail
64+
mkdir -p pages/schema/hwp_ir
65+
shopt -s nullglob
66+
copied=0
67+
for f in python/rhwp/ir/schema/hwp_ir_v*.json; do
68+
name=$(basename "$f" .json) # hwp_ir_v1, hwp_ir_v2, ...
69+
ver="${name#hwp_ir_}" # v1, v2, ...
70+
mkdir -p "pages/schema/hwp_ir/$ver"
71+
cp "$f" "pages/schema/hwp_ir/$ver/schema.json"
72+
echo "Published $f -> pages/schema/hwp_ir/$ver/schema.json"
73+
copied=$((copied + 1))
74+
done
75+
if [ "$copied" -eq 0 ]; then
76+
echo "::error::no hwp_ir_v*.json files found under python/rhwp/ir/schema/"
77+
exit 1
78+
fi
79+
- uses: actions/configure-pages@v5
80+
- uses: actions/upload-pages-artifact@v3
81+
with:
82+
path: pages
83+
- id: deployment
84+
uses: actions/deploy-pages@v4
85+
86+
# v2 추가 절차 (breaking change 발생 시):
87+
# 1) python/rhwp/ir/schema/hwp_ir_v2.json 생성 (v1 파일은 절대 수정 금지)
88+
# 2) SCHEMA_ID 를 v2 URL 로 점프, 새 CURRENT_SCHEMA_VERSION 설정
89+
# 3) 본 워크플로우 변경 불필요 — 위 루프가 v1/v2 모두 자동 배포
90+
# 4) SchemaStore catalog 에 v2 URL 을 alongside 등록 (v1 entry 는 legacy 로 유지)

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added — Document IR v1 (Phase 2 — targets v0.2.0)
11+
12+
**Document IR v1** — RAG / LLM 파이프라인이 직접 소비 가능한 구조화 문서 모델. Pydantic V2 기반 공개 타입 + JSON Schema (Draft 2020-12).
13+
14+
- `rhwp.ir.nodes` 모듈 — `HwpDocument` / `ParagraphBlock` / `TableBlock` / `TableCell` / `InlineRun` / `Provenance` / `UnknownBlock` / `Furniture` / `DocumentMetadata` / `Section` (10 노드, 전부 `frozen=True` + `extra="forbid"`).
15+
- Callable `Discriminator` 기반 `Block` 태그드 유니온 — 미지 `kind``UnknownBlock` 으로 라우팅하여 forward-compat 보장 (v0.3.0 의 새 블록 타입이 v0.2.0 소비자를 깨뜨리지 않음).
16+
- `Document.to_ir() -> HwpDocument` + `Document.to_ir_json(*, indent=None) -> str` — Rust `OnceCell<Py<PyAny>>` lazy 캐시 (unsendable 덕에 lock 불필요).
17+
- `HwpDocument.iter_blocks(*, scope, recurse)` — body/furniture/all scope + TableCell 재귀 DFS 순회.
18+
- Rust 측 HTML/text 직렬화 — attribute 순서 고정 (rowspan→colspan), HtmlRAG 호환.
19+
- 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.
20+
- Discriminator 후처리 — `_harden_unknown_variant()` 가 UnknownBlock.kind 에 `not.enum: [known kinds]` 주입하여 oneOf 검증 정확도 보장.
21+
- `HwpLoader``mode="ir-blocks"` 추가 — Block 을 LangChain `Document` 로 매핑 (표는 HTML content + 구조화 메타, 단락은 text + Provenance).
22+
- `.github/workflows/publish-schema.yml` — GitHub Pages 배포 파이프라인, 불변 경로 정책 (v1 URL 영구) 자동화.
23+
- Provenance 단위는 **Unicode codepoint** — Python `str[i]` 슬라이싱과 직접 호환 (이모지/SMP CJK 혼용에서도 off-by-one 없음).
24+
- 신규 런타임 의존성: `pydantic>=2.5,<3`. 테스트 의존성: `jsonschema>=4`.
25+
- 문서: `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`.
26+
- 테스트: **163 passed** — IR schema/roundtrip/tables/iter/export + LangChain ir-blocks + Rust unit tests (`cargo test` 5 passed).
27+
28+
### Changed — Phase 2 계획 전환
29+
30+
- 원안의 CLI 도구 (`rhwp` 바이너리) 는 **폐기**. 업스트림 `edwardkim/rhwp` 의 Rust 바이너리가 같은 이름을 점유하므로 충돌 방지 + Python 고유 가치 (RAG / LangChain 통합) 에 집중. 상세: `docs/roadmap/v0.2.0/ir.md` §방향 전환 배경.
31+
- `python/rhwp/__init__.pyi``Document.to_ir` / `to_ir_json` 타입 힌트 추가.
32+
- `pyproject.toml [tool.maturin] include``python/rhwp/ir/schema/*.json` 포함 (wheel + sdist).
33+
34+
### Deferred to v0.3.0+
35+
36+
- `PictureBlock` / `FormulaBlock` / `FootnoteBlock` / `ListItemBlock` / `CaptionBlock` / `TocEntryBlock` / `FieldBlock` — 현재는 미지 `kind``UnknownBlock` 폴백.
37+
- Furniture 본문 파싱 (머리글/꼬리말/각주 내용).
38+
- `DocumentMetadata.creation_time` / `modification_time``datetime` 으로 교체 (현재 `str | None`).
39+
- text/table 정확 interleaving (컨트롤 문자 0x0B 위치 기반).
40+
- LLM strict-mode 완전 호환 — `export_schema(strict=True)` 옵션.
41+
- SchemaStore 카탈로그 등록 / content-addressed alias — GA 후 별도 PR.
42+
1043
## [0.1.1] — 2026-04-23
1144

1245
Patch release: fixes the sdist packaging so the source distribution stays within PyPI's 100 MB file size limit.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Stage S1 — Pydantic 모델 초안 (완료)
2+
3+
**작업일**: 2026-04-24
4+
**계획 문서**: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md) §구현 스테이지 분할
5+
**설계 근거**: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md)
6+
7+
## 스코프
8+
9+
Document IR v1 의 **공개 데이터 모델 (Pydantic V2)** 만. Rust 바인딩 (S2), JSON Schema export (S4), `Document.to_ir()` 메서드 (S2) 는 본 스테이지 범위 밖.
10+
11+
## 산출물
12+
13+
| 파일 | 라인 | 내용 |
14+
|---|---|---|
15+
| `python/rhwp/ir/__init__.py` | 7 | docstring only (프로젝트 규칙 — 순환 import 방지) |
16+
| `python/rhwp/ir/__init__.pyi` | 57 | 타입 체커용 `__all__` 재-export |
17+
| `python/rhwp/ir/nodes.py` | 288 | 10개 모델 + `Block` tagged union + `model_rebuild()` |
18+
| `tests/test_ir_schema.py` | 297 | 35 테스트 케이스 (파라미터화 포함) |
19+
| `pyproject.toml` || `[project] dependencies = ["pydantic>=2.5,<3"]` 추가 |
20+
| `docs/roadmap/v0.2.0/ir.md` || §노드 타입 섹션 S1 최소 필드 스펙 보강 |
21+
22+
## 구현된 타입 (nodes.py)
23+
24+
- **Leaf**: `Provenance`, `InlineRun`, `DocumentMetadata`, `Section`
25+
- **블록**: `ParagraphBlock` (kind="paragraph"), `TableBlock` (kind="table"), `UnknownBlock` (catch-all, v1.0 포함)
26+
- **재귀**: `TableCell` (blocks: list["Block"]) ↔ `Block``TableBlock.cells: list[TableCell]` — 문자열 전방 참조 + 파일 하단 `model_rebuild()` 3회
27+
- **루트**: `HwpDocument`, `Furniture`
28+
- **유니온**: `Block = Annotated[Union[...], Discriminator(_block_discriminator)]` — callable discriminator 로 미지 `kind``UnknownBlock` 으로 라우팅
29+
- **버전**: `SchemaVersion = Annotated[str, StringConstraints(pattern=r"^\d+\.\d+(\.\d+)?$")]` + `@field_validator` — major 상향 시 `UserWarning`
30+
31+
## S1 확정 결정 사항 (ir.md 에 소급 반영)
32+
33+
| 타입 | v0.2.0 S1 필드 | 이월 |
34+
|---|---|---|
35+
| `Section` | `section_idx: int`| 용지·단·헤더 레퍼런스는 S2 Rust 매핑 시 MINOR 확장 |
36+
| `DocumentMetadata` | `title` / `author` / `creation_time` / `modification_time` — 전부 `str \| None` | `datetime` 교체는 v0.3.0 MINOR 호환 |
37+
| `TableBlock.caption` | `str \| None` (단순 텍스트) | 복합 캡션 (캡션 안의 블록) 은 v0.3.0+ |
38+
39+
## 비타협 제약 준수
40+
41+
- 모든 IR 모델 `ConfigDict(extra="forbid", frozen=True)`. `UnknownBlock``extra="allow"` 예외
42+
- `from __future__ import annotations` 사용 **없음**
43+
- `Field(ge=/le=/gt=/lt=)` 사용 **없음** — 범위 서술은 `description`
44+
- Python 3.9 런타임 호환: `Optional[T]` / `Union[T, U]` 사용 (PEP 604 `T | None` 은 3.10+)
45+
- `list[T]` / `tuple[T, ...]` 는 PEP 585 (3.9+ OK) 로 내장 타입 사용
46+
47+
## 검증
48+
49+
| 검사 | 결과 |
50+
|---|---|
51+
| `uv run pytest tests/test_ir_schema.py -v` | **35 passed** |
52+
| `uv run pytest -m "not slow"` | **102 passed** (회귀 없음 — 기존 67 + 신규 35) |
53+
| `uv run ruff check python/rhwp/ir/ tests/test_ir_schema.py` | clean |
54+
| `uv run pyright python/rhwp/ir/ tests/test_ir_schema.py` | **0 errors** |
55+
| `uv run pyright python/ tests/` | 의도된 `type_check_errors.py` 4 errors 만 (CLAUDE.md 규약) |
56+
| `code-reviewer` fresh-context 검증 (16개 항목) | 전원 통과, Critical/Minor/Nitpick 0건 |
57+
58+
## 테스트 커버리지 매핑 (ir.md §단위 테스트 → 실제 케이스)
59+
60+
| ir.md 요구 | 테스트 |
61+
|---|---|
62+
| 직렬화 왕복 (HwpDocument/ParagraphBlock/TableBlock) | `test_hwp_document_roundtrip`, `test_paragraph_block_roundtrip`, `test_table_block_simple_roundtrip` |
63+
| discriminator 분기 — 잘못된 kind | `test_discriminator_routes_unknown_kind`, `test_discriminator_routes_known_kinds` |
64+
| 재귀 3단 (중첩 표) | `test_table_nested_three_levels` |
65+
| `extra="forbid"` 실효성 | `test_extra_forbid_raises_on_unknown_field[8 parametrized]` |
66+
| `schema_version` pattern 검증 | `test_schema_version_accepts_valid[4]`, `test_schema_version_rejects_invalid[7]`, `test_schema_version_warns_on_future_major`, `test_schema_version_minor_bump_does_not_warn` |
67+
| `frozen=True` | `test_frozen_raises_on_mutation`, `test_frozen_unknown_block_cannot_be_mutated` |
68+
| codepoint offset (이모지 SMP 호환) | `test_provenance_char_offsets_are_codepoint_based` |
69+
70+
LLM strict-mode 스키마 conformance / `jsonschema` meta-validation 은 S4 (JSON Schema export) 이후.
71+
72+
## S2 진입 조건 (인수인계)
73+
74+
S2 는 "Rust → Python dict → Pydantic `model_validate`" 매핑을 `src/document.rs` + 신규 `src/ir.rs` 에 작성. S1 에서 고정한 계약:
75+
76+
1. **모든 IR 모델 frozen** — S2 에서 `Document.to_ir()` 의 Rust `OnceCell<PyObject>` 캐시와 함께 aliasing 방어 완성
77+
2. **`char_start`/`char_end` codepoint** — S2 Rust 바인딩이 상류 UTF-16 `char_offsets` → codepoint 변환 `to_ir()` 시점 1회 수행
78+
3. **`body` / `furniture` 분리** — Rust 매퍼는 머리글/꼬리말 본문을 식별해 `furniture` 쪽으로 라우팅 (v0.2.0 은 빈 리스트 출고)
79+
4. **`Section` / `DocumentMetadata` 최소 필드** — S2 에서 상류 Rust 타입 매핑 시 필드 확장은 MINOR 호환
80+
81+
## 참조
82+
83+
- 상위 설계: [roadmap/v0.2.0/ir.md](../../../roadmap/v0.2.0/ir.md)
84+
- 결정 사항 증거: [design/v0.2.0/ir-design-research.md](../../../design/v0.2.0/ir-design-research.md)
85+
- 상류 타입 (S2 에서 매핑): `external/rhwp/src/model/{document,paragraph,table}.rs`

0 commit comments

Comments
 (0)