b6ce228f6e
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) <noreply@anthropic.com>
109 lines
4.3 KiB
Python
109 lines
4.3 KiB
Python
"""_ENG 매처 노이즈 차단 단위테스트 (asme-item-decomp-1 D1).
|
|
|
|
핵심 불변식: 영문 구조 헤딩 매처(_ENG)가
|
|
- (음성) 본문 중간 'Part III to demonstrate…' 같은 소문자 문장연속을 가짜 절로 잡지 않고,
|
|
- (양성) 진짜 영문 구조 헤딩(PART PG / Part 1 / Section 3.31 / Part UHX …)은 탐지하며,
|
|
- (ATX 보존) _ENG 축소가 ATX 파트(`# PART PG`)·항목(`#### PG-1`)을 떨구지 않는다(ATX 우선).
|
|
|
|
pytest + 단독 실행 양쪽 지원:
|
|
PYTHONPATH=. python3 tests/hier_decomp/test_eng_matcher.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
try: # pytest 경로 (앱 패키지)
|
|
from app.services.hier_decomp.builder import _detect_heading, 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, build_hier_tree = _m._detect_heading, _m.build_hier_tree
|
|
|
|
|
|
# ── 음성: 본문 문장은 헤딩 아님 (가짜 절 차단 — D1 회귀의 핵심) ──
|
|
NEG = [
|
|
"Part III to demonstrate to the satisfaction of the represen-",
|
|
"Section V of the agreement applies to all parties",
|
|
"Part IV is hereby amended as follows",
|
|
"Article II shall be interpreted broadly",
|
|
"Chapter 3 describes the general method used here",
|
|
]
|
|
|
|
# ── 양성: 진짜 영문 구조 헤딩 ──
|
|
POS = [
|
|
"PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION",
|
|
"Part 1",
|
|
"Part PFH",
|
|
"Part UHX (TUBESHEET CALCULATION)",
|
|
"Section 3.31",
|
|
"Chapter 1 Introduction",
|
|
"Article 5 Definitions",
|
|
]
|
|
|
|
|
|
def test_eng_negatives_not_detected():
|
|
for line in NEG:
|
|
assert _detect_heading(line) is None, f"가짜 절로 잡힘: {line!r}"
|
|
|
|
|
|
def test_eng_positives_detected_as_chapter():
|
|
for line in POS:
|
|
r = _detect_heading(line)
|
|
assert r is not None, f"진짜 헤딩 미탐지: {line!r}"
|
|
_lvl, _title, nt = r
|
|
assert nt == "chapter", f"{line!r} node_type={nt}"
|
|
|
|
|
|
def test_atx_part_and_item_still_detected():
|
|
# _ENG 축소가 진짜 ATX 파트/항목을 떨구지 않음 (ATX 우선 탐지)
|
|
r = _detect_heading("# PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION")
|
|
assert r is not None
|
|
lvl, title, nt = r
|
|
assert lvl == 1 and nt is None, r # ATX = level(# 수), node_type None
|
|
assert title.startswith("PART PG")
|
|
r2 = _detect_heading("#### PG-1 SCOPE")
|
|
# 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():
|
|
# 본문에 'Part III to demonstrate…' 가 섞여도 가짜 절이 생기지 않음
|
|
md = (
|
|
"# PART PG GENERAL REQUIREMENTS\n"
|
|
"#### PG-1 SCOPE\n"
|
|
"The rules cover power boilers.\n"
|
|
"Part III to demonstrate to the satisfaction of the representative\n"
|
|
"that the requirements are met, the manufacturer shall proceed...\n"
|
|
"#### PG-2 SERVICE LIMITATIONS\n"
|
|
"body of pg-2 here.\n"
|
|
)
|
|
titles = [n.section_title for n in build_hier_tree(md) if n.section_title]
|
|
assert any(t.startswith("PART PG") for t in titles), titles
|
|
assert any(t.startswith("PG-1") for t in titles), titles
|
|
assert any(t.startswith("PG-2") for t in titles), titles
|
|
assert not any("demonstrate" in (t or "") for t in titles), f"가짜 절 누출: {titles}"
|
|
|
|
|
|
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 as e:
|
|
failed += 1
|
|
print(f"FAIL {name}: {e}")
|
|
traceback.print_exc()
|
|
print(f"\n{len(fns) - failed}/{len(fns)} passed")
|
|
sys.exit(1 if failed else 0)
|