From b6ce228f6eb51b3b25a930769747a72c90921cd2 Mon Sep 17 00:00:00 2001 From: hyungi Date: Fri, 19 Jun 2026 21:58:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(hier):=20ASME=20=EC=A0=88=20=EC=8B=9D?= =?UTF-8?q?=EB=B3=84=EC=9E=90=20ATX=20heading=20=EC=9D=84=20node=5Ftype=3D?= =?UTF-8?q?'clause'=20=EB=A1=9C=20=ED=83=80=EC=9D=B4=ED=95=91=20+=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EC=A0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-1: _detect_heading 의 ATX 분기가 절번호 식별자(UG-79/PG-27.4.1/UW-11/A-69 등, [A-Z]{1,4}-\d+(\.\d+)*)를 node_type='clause' 로 분류(과거 ATX=무조건 None). ASME clause=0 사각지대의 근본 원인 — 절은 이미 ATX heading 으로 탐지되나 'clause' 타이핑이 한국 제N조 전용이었음(5180 Sec I = clause 0, heading_path 1637 = window/None). C-4: _clean_label 로 marker LaTeX/markdown/페이지번호 아티팩트 ('$\textbf{PG-20.1 ...}', '(25) **A-69**')를 패턴 매칭 전 정제 — 없으면 노이즈에 막혀 매칭 0. 표시 라벨도 동시 정제. 한국 법령/일반 ATX 엔 inert(무회귀). A-2: 큰 절(>LEAF_HARD_MAX)은 기존 window-split 이 'clause'→'clause_split' (char_start 점프 타깃 보존)로 자동 처리 — 추가 코드 없음. 검증(순수함수, DB/GPU/재마크다운 0): test_asme_clause 6/6 신규 + test_eng_matcher 4/4 (PG-1 계약을 clause 로 갱신) + test_builder_char_start 7/7(char_start 무영향). DS 적용(V-0 스모크 → 기존 md V-1 0-cost 검증)은 후속. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/hier_decomp/builder.py | 19 ++++- tests/hier_decomp/test_asme_clause.py | 107 ++++++++++++++++++++++++++ tests/hier_decomp/test_eng_matcher.py | 4 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/hier_decomp/test_asme_clause.py diff --git a/app/services/hier_decomp/builder.py b/app/services/hier_decomp/builder.py index 416ef4d..4d54e51 100644 --- a/app/services/hier_decomp/builder.py +++ b/app/services/hier_decomp/builder.py @@ -42,6 +42,21 @@ _ENG = re.compile( _FENCE = re.compile(r'^\s{0,3}(```|~~~)') +# ASME 절 식별자 (A-1): UG-79 · PG-27.4.1 · UW-11 · UCS-56 · A-69 · PFT-14 +# (대문자 1~4 + 하이픈 + 숫자[.숫자]*). _detect_heading 의 ATX 분기에서 node_type='clause' 판정에 사용. +# 한국 법령(제N조)은 _KO_JO 가 별도 처리 — 본 패턴/정제와 무관(무회귀). +_ASME_CLAUSE = re.compile(r'^[A-Z]{1,4}-\d+(?:\.\d+)*\b') + + +def _clean_label(title: str) -> str: + r"""C-4: marker 가 박는 LaTeX/markdown/페이지번호 아티팩트 제거 — 절번호 패턴 매칭의 전처리 겸 표시 라벨 정제. + 실데이터 예: '$\textbf{PG-20.1 …} \hspace{0.2cm} \textbf{(25)}$' → 'PG-20.1 …' / '(25) **A-69**' → 'A-69'. + 노이즈 없는 제목(한국 법령·일반 ATX 등)엔 inert(무회귀).""" + t = re.sub(r'\\textbf|\\textit|\\mathbf|\\hspace\{[^}]*\}|[${}]|\*\*', '', title) + t = re.sub(r'^\s*\(\d+\)\s*', '', t) # 선두 페이지번호 '(25) ' + return re.sub(r'\s{2,}', ' ', t).strip() + + def _utf16_units(s: str) -> int: """JS 문자열 .length(= UTF-16 code unit 수) 와 동일. astral(BMP 밖)=surrogate pair=2 units. FE 의 `raw.length` / `out.slice(off)` 가 UTF-16 code unit 단위라 char_start 도 같은 단위여야 함. @@ -72,7 +87,9 @@ def _detect_heading(line: str) -> tuple[int, str, str] | None: """(level, title, node_type) 또는 None. level 은 상대 깊이.""" m = _ATX.match(line) if m: - return (len(m.group(1)), m.group("title").strip(), None) # node_type 은 후처리에서 + title = _clean_label(m.group("title").strip()) # C-4: LaTeX/md/페이지번호 정제(전처리) + nt = "clause" if _ASME_CLAUSE.match(title) else None # A-1: ASME 절 식별자(UG-79 등) → clause + return (len(m.group(1)), title, nt) for pat, lvl, nt in ((_KO_JANG, 1, "chapter"), (_KO_JEOL, 2, "section"), (_KO_JO, 3, "clause"), (_ENG, 1, "chapter")): m = pat.match(line) diff --git a/tests/hier_decomp/test_asme_clause.py b/tests/hier_decomp/test_asme_clause.py new file mode 100644 index 0000000..f58afb3 --- /dev/null +++ b/tests/hier_decomp/test_asme_clause.py @@ -0,0 +1,107 @@ +"""ASME 절(clause) 타이핑 + 라벨 정제 단위테스트 (A-1 / C-4, presegment-multigranularity). + +핵심 불변식: + - (A-1) ATX heading 의 제목이 ASME 절 식별자(UG-79·PG-27.4.1·UW-11·A-69 …)면 node_type='clause'. + builder 가 과거 ATX 를 무조건 node_type=None 으로 반환해 ASME 절이 'clause' 로 안 잡히던 것을 고침. + - (C-4) marker LaTeX/markdown/페이지번호 아티팩트('$\\textbf{PG-20.1 …}', '(25) **A-69**')를 절번호 + 매칭 전에 정제 — 정제 없으면 패턴이 노이즈에 막혀 매칭 0. + - (A-2) 큰 절(>LEAF_HARD_MAX)은 기존 window-split 로직으로 자동 'clause_split' 이 됨 + (char_start 보존 = 단일 점프 타깃). 추가 코드 없이 타이핑만으로 확보. + - (무회귀) 한국 법령 제N조(_KO_JO 경로)·일반 ATX 헤딩은 영향 없음(정제 inert, 타이핑 None 유지). + +pytest + 단독 실행 양쪽 지원: + PYTHONPATH=. python3 tests/hier_decomp/test_asme_clause.py +""" +from __future__ import annotations + +try: # pytest 경로 (앱 패키지) + from app.services.hier_decomp.builder import _detect_heading, _clean_label, build_hier_tree +except Exception: # 단독 실행 (앱 deps 없이 builder.py 직접 로드 — stdlib only) + import importlib.util + import pathlib + import sys + + _bp = pathlib.Path(__file__).resolve().parents[2] / "app/services/hier_decomp/builder.py" + _spec = importlib.util.spec_from_file_location("_hier_builder_t", _bp) + _m = importlib.util.module_from_spec(_spec) + sys.modules[_spec.name] = _m # dataclass __module__ 해소 + _spec.loader.exec_module(_m) + _detect_heading, _clean_label, build_hier_tree = _m._detect_heading, _m._clean_label, _m.build_hier_tree + + +# 5180/5210 실데이터에서 뽑은 noisy 라벨 (marker LaTeX/markdown/페이지번호 범벅). +ASME_NOISY = [ + (r"# $\textbf{PG-20.1 Carbon and Carbon-Molybdenum Tube and} \hspace{0.2cm} \textbf{(25)}$", "PG-20.1"), + ("# (25) **A-69**", "A-69"), + ("# (25) PFT-14 GENERAL", "PFT-14"), + ("## (25) PG-27.4.1", "PG-27.4.1"), + ("### UG-79 Forming of Pressure Parts", "UG-79"), + ("# UW-11 Radiographic Examination", "UW-11"), + ("#### UCS-56 Requirements for Postweld Heat Treatment", "UCS-56"), +] + + +def test_asme_clause_typed_and_cleaned(): + for line, head in ASME_NOISY: + r = _detect_heading(line) + assert r is not None, f"미탐지: {line!r}" + _lvl, title, nt = r + assert nt == "clause", f"{line!r} → node_type={nt} (clause 여야)" + assert title.startswith(head), f"{line!r} → 정제 라벨 {title!r} 가 {head!r} 로 시작 안 함" + assert "\\textbf" not in title and "$" not in title and "**" not in title, f"라벨에 아티팩트 잔류: {title!r}" + + +def test_clean_label_strips_artifacts(): + assert _clean_label(r"$\textbf{PG-20.1 Foo} \hspace{0.2cm} \textbf{(25)}$").startswith("PG-20.1 Foo") + assert _clean_label("(25) **A-69**") == "A-69" + assert _clean_label("(25) PFT-14 GENERAL") == "PFT-14 GENERAL" + + +def test_korean_jo_unaffected(): + # 한국 법령 제N조 = _KO_JO 경로(ATX 아님) → clause 유지, _clean_label 미적용·inert. + r = _detect_heading("제3조(정의) 이 규칙에서 사용하는 용어의 뜻은") + assert r is not None and r[2] == "clause" and "제3조" in r[1], r + assert _clean_label("제3조(정의)") == "제3조(정의)" # 노이즈 없음 → inert(무회귀) + + +def test_plain_atx_not_clause(): + # ASME 절 식별자가 아닌 일반 ATX 헤딩은 node_type None 유지 + 라벨 무변. + for line, want in [("# Introduction", "Introduction"), ("## Overview of Methods", "Overview of Methods")]: + r = _detect_heading(line) + assert r is not None and r[2] is None and r[1] == want, r + + +def test_large_clause_becomes_clause_split(): + # A-2: 큰 절(>5000자) → 기존 window-split 이 'clause' 를 'clause_split' 로(char_start 보존=점프 타깃) + window 자식. + big = "# UG-22 Loadings\n\n" + ("This is a body paragraph describing loadings in detail. " * 30 + "\n\n") * 8 + nodes = build_hier_tree(big) + splits = [n for n in nodes if n.node_type == "clause_split"] + assert splits, f"clause_split 없음: {[n.node_type for n in nodes]}" + assert all(n.char_start is not None for n in splits), "clause_split char_start(점프 타깃) 유실" + assert any(n.node_type == "window" for n in nodes), "window 자식 없음" + + +def test_typing_ratio_sample(): + # V-1 스타일: 4 ASME 절 + 1 일반 → clause 4개만. + md = "\n\n".join(f"# {x}\n\nbody for {x} here.\n" + for x in ["UG-1 Scope", "UG-79 Forming", "PG-5 Service", "Introduction", "UW-11 RT"]) + clauses = [n for n in build_hier_tree(md) if n.node_type in ("clause", "clause_split")] + assert len(clauses) == 4, [n.section_title for n in clauses] + + +if __name__ == "__main__": + import sys + import traceback + + fns = [(k, v) for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for name, fn in fns: + try: + fn() + print(f" PASS {name}") + except Exception: + failed += 1 + print(f" FAIL {name}") + traceback.print_exc() + print(f"\n{len(fns) - failed}/{len(fns)} passed") + sys.exit(1 if failed else 0) diff --git a/tests/hier_decomp/test_eng_matcher.py b/tests/hier_decomp/test_eng_matcher.py index 7705bb0..480936a 100644 --- a/tests/hier_decomp/test_eng_matcher.py +++ b/tests/hier_decomp/test_eng_matcher.py @@ -67,7 +67,9 @@ def test_atx_part_and_item_still_detected(): assert lvl == 1 and nt is None, r # ATX = level(# 수), node_type None assert title.startswith("PART PG") r2 = _detect_heading("#### PG-1 SCOPE") - assert r2 is not None and r2[0] == 4 and r2[2] is None, r2 + # A-1(asme-clause): ASME 절 식별자(PG-1) 는 이제 node_type='clause' 로 타이핑된다(과거 None). + # ATX 탐지·level(# 수) 보존은 그대로 — 변경은 타이핑 한정. + assert r2 is not None and r2[0] == 4 and r2[2] == "clause", r2 def test_build_hier_tree_drops_false_part_section():