Skip to content

Commit 75d0f68

Browse files
DanMeonclaude
andcommitted
feat: 문서 출처 DocumentSource 메타 IR 추가
변경사항: - DocumentSource 모델 신설 (uri: str) — HwpDocument.source 타입을 Provenance | None 에서 DocumentSource | None 으로 교체 - rhwp.parse(path) 경로가 IR 의 source.uri 에 원본 경로를 주입 (normalize 미수행 — 소비자 책임) - Document.source_uri getter 추가 — IR 생성 없이도 출처 조회 가능 - InlineRun.raw_style_id description 보강 — None 은 char_shape 없는 손상 입력 방어 경로임을 명시 - 테스트 5건 추가 (HWP/HWPX 경로, getter, null 직접 구성, JSON null roundtrip, frozen 위반) - JSON Schema 재생성 + ir.md 에 LLM strict-mode 후처리 주의 반영 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e91d1a commit 75d0f68

9 files changed

Lines changed: 148 additions & 11 deletions

File tree

docs/roadmap/v0.2.0/ir.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ v0.2.0 의 원안은 `rhwp` 커맨드라인 도구 제공이었으나(`docs/road
102102
HwpDocument (root)
103103
├── schema_name: Literal["HwpDocument"]
104104
├── schema_version: Literal["1.0"] # 인스턴스 버전 추적
105-
├── source # Provenance — 원본 경로/해시
105+
├── source: DocumentSource | None # 문서 출처 (uri) — RAG 역추적
106106
├── metadata: DocumentMetadata # 제목/작성자/생성일 등
107107
├── sections: list[Section] # 구역(용지·단 정의 포함)
108108
@@ -121,6 +121,7 @@ HwpDocument (root)
121121
v0.2.0 에서 구현 완료되는 구체 타입:
122122

123123
- `HwpDocument` — 문서 루트 (`schema_name`/`schema_version`/`source?`/`metadata`/`sections`/`body`/`furniture`)
124+
- `DocumentSource` — 문서 출처. `uri: str` 만 필수 (파일 경로·URL·custom 식별자). `format`/`bytes_size`/`sha256` 등 재현성 필드는 기본값 있는 옵셔널로 향후 MINOR 확장. `rhwp.parse(path)` 경로는 `uri` 에 원본 path 를 그대로 기록 (normalize 미수행 — 소비자 책임). **LLM Strict-mode 주의**: `HwpDocument.source` 는 기본값 `null` 을 가지므로 Pydantic 이 root `required` 에 올리지 않는다. OpenAI Structured Outputs `strict=true` 소비자는 (a) `source` 를 root `required` 에 명시 추가 + (b) `anyOf: [$ref, null]` → 동일 형태 유지하되 `required` 재계산, 후처리가 필요하다. 옵셔널 필드가 추가될수록 동일 패턴이 반복된다
124125
- `DocumentMetadata``title`/`author`/`creation_time`/`modification_time` — 전부 `str | None`. S2 에서 `datetime` 교체는 MINOR 호환 (optional 필드 타입 확장)
125126
- `Section` — v0.2.0 S1 은 `section_idx: int` 만. 용지·단·헤더 레퍼런스는 S2 Rust 매핑 시점에 MINOR 확장
126127
- `ParagraphBlock` — 단락, `kind="paragraph"` + `text`/`inlines: list[InlineRun]`/`prov`

python/rhwp/__init__.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ class Document:
4040
직접 생성자를 호출하거나 :func:`parse` 를 사용할 수 있다.
4141
"""
4242

43+
source_uri: str | None
44+
"""생성자에 전달된 원본 경로. IR 의 ``source.uri`` 와 동일 값 — IR 을 생성하지 않고도 출처 조회 가능."""
45+
4346
section_count: int
4447
"""섹션 수."""
4548

python/rhwp/ir/__init__.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ from rhwp.ir.nodes import (
99
from rhwp.ir.nodes import (
1010
DocumentMetadata as DocumentMetadata,
1111
)
12+
from rhwp.ir.nodes import (
13+
DocumentSource as DocumentSource,
14+
)
1215
from rhwp.ir.nodes import (
1316
Furniture as Furniture,
1417
)
@@ -56,6 +59,7 @@ __all__ = [
5659
"CURRENT_SCHEMA_VERSION",
5760
"Block",
5861
"DocumentMetadata",
62+
"DocumentSource",
5963
"Furniture",
6064
"HwpDocument",
6165
"InlineRun",

python/rhwp/ir/nodes.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"CURRENT_SCHEMA_VERSION",
2323
"Block",
2424
"DocumentMetadata",
25+
"DocumentSource",
2526
"Furniture",
2627
"HwpDocument",
2728
"InlineRun",
@@ -86,7 +87,33 @@ class InlineRun(BaseModel):
8687
strikethrough: bool = False
8788
href: str | None = None
8889
ruby: str | None = None
89-
raw_style_id: int | None = None
90+
raw_style_id: int | None = Field(
91+
default=None,
92+
description=(
93+
"Upstream doc_info 스타일 인덱스. 폰트/크기/색상 등 non-binary 서식을 escape 한다. "
94+
"None 은 char_shape 레코드가 없는 손상/비정상 입력 방어 경로 — "
95+
"정상 HWP 는 모든 런이 char_shape 에 대응되어 항상 값이 채워진다."
96+
),
97+
)
98+
99+
100+
class DocumentSource(BaseModel):
101+
"""문서 출처 — RAG 응답 역추적 시 "이 답이 어느 파일에서 나왔나" 에 응답한다.
102+
103+
스키마는 ``uri`` 형식을 강제하지 않으므로 소비자가 file://, https://, 혹은
104+
``mem://{hash}`` 같은 custom 스킴으로 정규화할 수 있다. 향후 ``format``,
105+
``bytes_size``, ``sha256`` 등 재현성 필드는 기본값 있는 옵셔널로만 추가 —
106+
이 경로는 기존 JSON 과 MINOR 호환 유지.
107+
"""
108+
109+
model_config = ConfigDict(extra="forbid", frozen=True)
110+
111+
uri: str = Field(
112+
description=(
113+
"파일 시스템 경로, URL, 혹은 소비자 정의 식별자. "
114+
"RFC 3986 URI reference 로 해석 가능한 문자열이면 충분하다."
115+
),
116+
)
90117

91118

92119
class DocumentMetadata(BaseModel):
@@ -206,7 +233,9 @@ def _block_discriminator(v: Any) -> str:
206233

207234

208235
Block = Annotated[
209-
Annotated[ParagraphBlock, Tag("paragraph")] | Annotated[TableBlock, Tag("table")] | Annotated[UnknownBlock, Tag("unknown")],
236+
Annotated[ParagraphBlock, Tag("paragraph")]
237+
| Annotated[TableBlock, Tag("table")]
238+
| Annotated[UnknownBlock, Tag("unknown")],
210239
Discriminator(_block_discriminator),
211240
]
212241

@@ -235,7 +264,7 @@ class HwpDocument(BaseModel):
235264

236265
schema_name: Annotated[str, StringConstraints(pattern=r"^HwpDocument$")] = "HwpDocument"
237266
schema_version: SchemaVersion = CURRENT_SCHEMA_VERSION
238-
source: Provenance | None = None
267+
source: DocumentSource | None = None
239268
metadata: DocumentMetadata = Field(default_factory=DocumentMetadata)
240269
sections: list[Section] = Field(default_factory=list)
241270
body: list["Block"] = Field(default_factory=list)

python/rhwp/ir/schema/hwp_ir_v1.json

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@
5858
"title": "DocumentMetadata",
5959
"type": "object"
6060
},
61+
"DocumentSource": {
62+
"additionalProperties": false,
63+
"description": "문서 출처 — RAG 응답 역추적 시 \"이 답이 어느 파일에서 나왔나\" 에 응답한다.\n\n스키마는 ``uri`` 형식을 강제하지 않으므로 소비자가 file://, https://, 혹은\n``mem://{hash}`` 같은 custom 스킴으로 정규화할 수 있다. 향후 ``format``,\n``bytes_size``, ``sha256`` 등 재현성 필드는 기본값 있는 옵셔널로만 추가 —\n이 경로는 기존 JSON 과 MINOR 호환 유지.",
64+
"properties": {
65+
"uri": {
66+
"description": "파일 시스템 경로, URL, 혹은 소비자 정의 식별자. RFC 3986 URI reference 로 해석 가능한 문자열이면 충분하다.",
67+
"title": "Uri",
68+
"type": "string"
69+
}
70+
},
71+
"required": [
72+
"uri"
73+
],
74+
"title": "DocumentSource",
75+
"type": "object"
76+
},
6177
"Furniture": {
6278
"additionalProperties": false,
6379
"description": "장식 노드 컨테이너 — RAG 가 임베딩에서 필터링 가능.\n\n현재 파서는 본문 블록을 넣지 않으므로 세 리스트는 기본적으로 비어있다.",
@@ -179,6 +195,7 @@
179195
}
180196
],
181197
"default": null,
198+
"description": "Upstream doc_info 스타일 인덱스. 폰트/크기/색상 등 non-binary 서식을 escape 한다. None 은 char_shape 레코드가 없는 손상/비정상 입력 방어 경로 — 정상 HWP 는 모든 런이 char_shape 에 대응되어 항상 값이 채워진다.",
182199
"title": "Raw Style Id"
183200
}
184201
},
@@ -472,7 +489,7 @@
472489
"source": {
473490
"anyOf": [
474491
{
475-
"$ref": "#/$defs/Provenance"
492+
"$ref": "#/$defs/DocumentSource"
476493
},
477494
{
478495
"type": "null"

src/document.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use crate::ir;
1111
#[pyclass(name = "Document", module = "rhwp", unsendable)]
1212
pub struct PyDocument {
1313
pub(crate) inner: rhwp::document_core::DocumentCore,
14+
// ^ 생성자에 전달된 파일 경로 — IR `DocumentSource.uri` 로 전파. RAG 응답 역추적 경로.
15+
source_uri: Option<String>,
1416
// ^ 첫 to_ir() 호출 시 1회 구성, 이후 재사용. unsendable 단일-스레드 보장 덕에 lock 불필요
1517
ir_cache: OnceCell<Py<PyAny>>,
1618
}
@@ -27,15 +29,23 @@ impl PyDocument {
2729
fn new(py: Python<'_>, path: &str) -> PyResult<Self> {
2830
// ^ py.detach 로 파일 I/O + 파싱 동안 GIL 해제 (DocumentCore 는 클로저 내부에서만 생성)
2931
let path_owned = path.to_owned();
32+
let source_uri = path_owned.clone();
3033
let doc = py
3134
.detach(move || load_document(path_owned))
3235
.map_err(parse_error_to_py)?;
3336
Ok(PyDocument {
3437
inner: doc,
38+
source_uri: Some(source_uri),
3539
ir_cache: OnceCell::new(),
3640
})
3741
}
3842

43+
#[getter]
44+
fn source_uri(&self) -> Option<&str> {
45+
// ^ IR 을 만들지 않고도 출처 확인 가능 — 관찰성·디버깅용. to_ir() 후의 ir.source.uri 와 동일 값
46+
self.source_uri.as_deref()
47+
}
48+
3949
#[getter]
4050
fn section_count(&self) -> usize {
4151
self.inner.document().sections.len()
@@ -146,7 +156,7 @@ impl PyDocument {
146156
if let Some(cached) = self.ir_cache.get() {
147157
return Ok(cached.clone_ref(py));
148158
}
149-
let ir = ir::build_hwp_document(py, self.inner.document())?;
159+
let ir = ir::build_hwp_document(py, self.inner.document(), self.source_uri.as_deref())?;
150160
self.ir_cache
151161
.set(ir)
152162
.expect("ir_cache was empty just above");

src/ir.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,36 @@ static HWP_DOCUMENT_CLASS: PyOnceLock<Py<PyType>> = PyOnceLock::new();
2222
///
2323
/// 호출 경로 전체가 GIL 유지 구간에서 실행된다 — dict 생성과 Pydantic 호출 모두
2424
/// Python heap 접근을 요구하기 때문.
25-
pub fn build_hwp_document(py: Python<'_>, doc: &Document) -> PyResult<Py<PyAny>> {
26-
let raw = build_document_dict(py, doc)?;
25+
///
26+
/// `source_uri` 는 `HwpDocument.source.uri` 로 주입된다 — 파일 경로/URL/custom 식별자.
27+
/// `None` 이면 `source` 가 `None` 으로 남는다 (메모리 bytes 파싱 등 출처 불명 경로).
28+
pub fn build_hwp_document(
29+
py: Python<'_>,
30+
doc: &Document,
31+
source_uri: Option<&str>,
32+
) -> PyResult<Py<PyAny>> {
33+
let raw = build_document_dict(py, doc, source_uri)?;
2734
let hwp_class = HWP_DOCUMENT_CLASS.import(py, "rhwp.ir.nodes", "HwpDocument")?;
2835
let ir = hwp_class.call_method1("model_validate", (raw,))?;
2936
Ok(ir.unbind())
3037
}
3138

32-
fn build_document_dict<'py>(py: Python<'py>, doc: &Document) -> PyResult<Bound<'py, PyDict>> {
39+
fn build_document_dict<'py>(
40+
py: Python<'py>,
41+
doc: &Document,
42+
source_uri: Option<&str>,
43+
) -> PyResult<Bound<'py, PyDict>> {
3344
let dict = PyDict::new(py);
3445

46+
match source_uri {
47+
Some(uri) => {
48+
let source = PyDict::new(py);
49+
source.set_item("uri", uri)?;
50+
dict.set_item("source", source)?;
51+
}
52+
None => dict.set_item("source", py.None())?,
53+
}
54+
3555
let metadata = PyDict::new(py);
3656
metadata.set_item("title", py.None())?;
3757
metadata.set_item("author", py.None())?;

tests/test_ir_roundtrip.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
Table 전용 검증은 ``test_ir_tables.py``.
1515
"""
1616

17+
from pathlib import Path
18+
1719
import pytest
1820
import rhwp
1921
from pydantic import ValidationError
20-
from rhwp.ir.nodes import HwpDocument, ParagraphBlock, TableBlock
22+
from rhwp.ir.nodes import DocumentSource, HwpDocument, ParagraphBlock, TableBlock
2123

2224
# * 반환 타입 / 캐시
2325

@@ -205,3 +207,53 @@ def test_metadata_fields_are_none(parsed_hwp: rhwp.Document):
205207
assert md.author is None
206208
assert md.creation_time is None
207209
assert md.modification_time is None
210+
211+
212+
# * source — rhwp.parse(path) 경로는 uri 에 원본 경로를 기록한다
213+
214+
215+
def test_source_uri_matches_parse_path(parsed_hwp: rhwp.Document, hwp_sample: Path):
216+
"""rhwp.parse(str(path)) 경로는 `HwpDocument.source.uri == str(path)` 를 보장한다.
217+
218+
RAG 응답 역추적 경로. normalize 는 수행하지 않는다 — 소비자 책임.
219+
"""
220+
ir = parsed_hwp.to_ir()
221+
assert isinstance(ir.source, DocumentSource)
222+
assert ir.source.uri == str(hwp_sample)
223+
224+
225+
def test_source_uri_matches_parse_path_hwpx(parsed_hwpx: rhwp.Document, hwpx_sample: Path):
226+
"""HWPX 경로도 동일 계약."""
227+
ir = parsed_hwpx.to_ir()
228+
assert isinstance(ir.source, DocumentSource)
229+
assert ir.source.uri == str(hwpx_sample)
230+
231+
232+
def test_document_source_uri_property(parsed_hwp: rhwp.Document, hwp_sample: Path):
233+
"""``Document.source_uri`` getter 는 IR 생성 없이도 출처를 조회할 수 있어야 한다."""
234+
assert parsed_hwp.source_uri == str(hwp_sample)
235+
236+
237+
def test_hwp_document_direct_construction_allows_null_source():
238+
"""Python 소비자가 IR 을 직접 구성하는 경로 (loader 등) — source=None 허용."""
239+
ir = HwpDocument()
240+
assert ir.source is None
241+
assert ir.schema_name == "HwpDocument"
242+
assert ir.schema_version == "1.0"
243+
244+
245+
def test_hwp_document_json_null_source_roundtrip():
246+
"""source=None 상태의 JSON 도 Pydantic 재파싱 가능 — forward-compat 경로."""
247+
import json
248+
249+
original = HwpDocument()
250+
dumped = original.model_dump_json()
251+
parsed = HwpDocument.model_validate(json.loads(dumped))
252+
assert parsed.source is None
253+
254+
255+
def test_document_source_is_frozen():
256+
"""``DocumentSource`` 는 ``frozen=True`` — 재할당은 ValidationError 로 거부."""
257+
src = DocumentSource(uri="file:///tmp/example.hwp")
258+
with pytest.raises(ValidationError):
259+
src.uri = "file:///tmp/other.hwp" # type: ignore[misc]

tests/test_ir_schema_export.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_export_schema_root_additional_properties_false():
4242

4343

4444
def test_export_schema_defs_are_exactly_the_known_nodes():
45-
"""`$defs` 는 HwpDocument (root) 를 제외한 9개 노드 정확히 일치.
45+
"""`$defs` 는 HwpDocument (root) 를 제외한 10개 노드 정확히 일치.
4646
4747
새 block variant 가 v0.3.0+ 에 추가되면 이 set 도 갱신해야 한다 — 의도적인
4848
강한 계약으로 스키마 형상 회귀를 조기에 탐지한다.
@@ -53,6 +53,7 @@ def test_export_schema_defs_are_exactly_the_known_nodes():
5353
"Provenance",
5454
"InlineRun",
5555
"DocumentMetadata",
56+
"DocumentSource",
5657
"Section",
5758
"ParagraphBlock",
5859
"TableBlock",

0 commit comments

Comments
 (0)