diff --git a/app/main.py b/app/main.py index b2b4339..4d6b6fb 100644 --- a/app/main.py +++ b/app/main.py @@ -53,7 +53,6 @@ async def lifespan(app: FastAPI): from workers.dedup_reconcile import run as dedup_reconcile_run from workers.digest_worker import run as global_digest_run from workers.file_watcher import watch_inbox - from workers.law_monitor import run as law_monitor_run from workers.mailplus_archive import run as mailplus_run from workers.news_collector import run as news_collector_run from workers.fulltext_worker import reconcile_unresolved as fulltext_reconcile_run @@ -120,7 +119,9 @@ async def lifespan(app: FastAPI): # safety > law > manual 우선순위로 25건씩. 6720 레거시 → 야간당 ~150건 → 약 45일 소화. scheduler.add_job(tier_backfill_run, "interval", minutes=30, id="tier_backfill") # 일일 스케줄 (KST) - scheduler.add_job(law_monitor_run, CronTrigger(hour=7, timezone=KST), id="law_monitor") + # law_monitor 스케줄 제거 (safety-library-1 B-1 PR①, 2026-06-13) — 매일 버전 체인 밖 + # 레거시 스냅샷을 증식하던 유일 경로 차단. 파일은 강등 보존(1사이클 관찰 후 삭제), + # 대체 = statute_collector (스케줄 등록은 PR② 잡 코드와 함께 — R8-B1). scheduler.add_job(mailplus_run, CronTrigger(hour=7, timezone=KST), id="mailplus_morning") scheduler.add_job(mailplus_run, CronTrigger(hour=18, timezone=KST), id="mailplus_evening") scheduler.add_job(daily_digest_run, CronTrigger(hour=20, timezone=KST), id="daily_digest") diff --git a/app/workers/statute_adapters/__init__.py b/app/workers/statute_adapters/__init__.py new file mode 100644 index 0000000..846444c --- /dev/null +++ b/app/workers/statute_adapters/__init__.py @@ -0,0 +1,25 @@ +"""statute_collector 나라별 어댑터 패키지 (plan safety-library-1 B-1). + +어댑터 계약 (2함수 + 상수): + JURISDICTION: str — 어댑터 상수 고정. 코어가 적재 직전 assert (파싱 결과 추론 금지). + poll_changes(client, watch_rows) -> list[ChangeEvent] — 개정 감지만 (경량 호출). + fetch_version(client, act, change) -> list[VersionPayload] — PR②. + payload 리스트: primary + annex 각각 자기 version_key (R4-M4). + +ChangeEvent.kind: amend / repeal / bootstrap(합성 — PR② 부트스트랩이 amend 와 +동일 ingest 경로 재사용, R6-m2). +""" + +from dataclasses import dataclass + + +@dataclass +class ChangeEvent: + """개정 감지 이벤트 — poll_changes 산출물.""" + family_id: str + kind: str # amend / repeal / bootstrap + new_version_key: str # KR = MST (법령일련번호) + title: str + promulgation_date: str | None = None # YYYYMMDD + effective_date: str | None = None # YYYYMMDD (목록 시행일자 — 조문별 차등 시행 주의) + revision_type: str | None = None # 제개정구분명 diff --git a/app/workers/statute_adapters/kr.py b/app/workers/statute_adapters/kr.py new file mode 100644 index 0000000..321017d --- /dev/null +++ b/app/workers/statute_adapters/kr.py @@ -0,0 +1,121 @@ +"""KR 법령 어댑터 — 국가법령정보센터 (law.go.kr DRF) (plan safety-library-1 B-1 PR①). + +poll_changes = lawSearch 목록 diff: 워치리스트 행별 정식 법령명 exact 조회 → +MST(법령일련번호) != watermark 이면 ChangeEvent. law_monitor 의 검증된 호출 형태 재사용. + +fixture (2026-06-13 라이브 박제, tests/fixtures/statute_kr/): + - lawsearch_*.xml — 목록 필드: 법령ID(불변)·법령일련번호(MST)·공포일자·시행일자·제개정구분명 + - lawservice_*.xml.gz — 전문 1콜 XML: 조문단위 853(산안기준규칙) + 별표단위 23 전부 포함 + = 스냅샷 의미론 확정(R7-M3 ①: annex 부분 fetch 실패 개념 없음 — 같은 응답에 없는 + 별표 = 삭제 간주 가능). 별표번호+별표가지번호 = 구조화 필드(R7-M3 ② — suffix 문자열 + 파싱 불요, version_key 합성은 이 필드 기반. PR② fetch_version 소관). + - 조문 취득 방식 판정(R2-m1): 전문 1콜 + 로컬 파싱 확정 — lawjosub 조 단위 호출이면 + 산안기준규칙(853조)은 개정당 호출 폭증. lawjosub fixture 는 보조 박제. + +주의: 응답의 '법령상세링크' 필드에 OC 키가 포함됨 — fixture/로그에 raw 응답을 남길 때 +새니타이즈 필수 (repo fixture 는 __OC_REDACTED__ 처리됨). +""" + +import asyncio +import os +import xml.etree.ElementTree as ET + +import httpx + +from core.crawl_politeness import CRAWL_UA +from core.utils import setup_logger +from workers.statute_adapters import ChangeEvent + +logger = setup_logger("statute_kr") + +JURISDICTION = "KR" +SOURCE_API = "law.go.kr" + +LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do" +LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do" + +# 같은 도메인 연속 호출 간격 (일 1회 x 26콜 — 보수적) +_POLL_DELAY_S = 1.5 + + +def _oc() -> str: + oc = os.getenv("LAW_OC", "") + if not oc: + raise RuntimeError("LAW_OC 미설정 — statute KR 어댑터 사용 불가") + return oc + + +def parse_search_hit(xml_text: str, official_title: str) -> dict | None: + """lawSearch XML 에서 정식 법령명 exact match 1건 추출 (순수 함수 — fixture 테스트 대상). + + 정식명 기준 exact match — 워치리스트 title 이 정식명(가운뎃점 포함)이므로 안전. + (law_monitor 의 하드코딩 '유해위험작업...'(점 없음)이 영구 미매칭이던 함정의 교훈: + 조회 키는 반드시 레지스트리의 정식명을 쓴다.) + """ + root = ET.fromstring(xml_text) + for law in root.findall(".//law"): + if (law.findtext("법령명한글") or "").strip() != official_title: + continue + mst = (law.findtext("법령일련번호") or "").strip() + if not mst: + continue + return { + "mst": mst, + "law_id": (law.findtext("법령ID") or "").strip(), + "promulgation_date": (law.findtext("공포일자") or "").strip() or None, + "effective_date": (law.findtext("시행일자") or "").strip() or None, + "revision_type": (law.findtext("제개정구분명") or "").strip() or None, + "status_code": (law.findtext("현행연혁코드") or "").strip() or None, + } + return None + + +def detect_change(hit: dict | None, act_family_id: str, act_title: str, + watermark: str | None) -> ChangeEvent | None: + """목록 hit + 워터마크 → ChangeEvent (순수 함수 — fixture 테스트 대상). + + - hit 없음 = 감지 불가 (None — 호출측이 fail-loud 로그. 폐지 단정 금지: + 검색 누락/표기 변경 가능성과 구분 불가하므로 repeal 은 제개정구분명 기준만) + - MST == watermark = 변경 없음 + - 제개정구분명에 '폐지' = repeal, 그 외 = amend + """ + if hit is None: + return None + if watermark and hit["mst"] == watermark: + return None + kind = "repeal" if (hit.get("revision_type") or "").find("폐지") >= 0 else "amend" + return ChangeEvent( + family_id=act_family_id, + kind=kind, + new_version_key=hit["mst"], + title=act_title, + promulgation_date=hit.get("promulgation_date"), + effective_date=hit.get("effective_date"), + revision_type=hit.get("revision_type"), + ) + + +async def poll_changes(client: httpx.AsyncClient, watch_rows: list) -> list[ChangeEvent]: + """워치리스트 행별 lawSearch diff. 행 단위 실패 격리 (한 법령 실패가 나머지를 막지 않음).""" + oc = _oc() + events: list[ChangeEvent] = [] + for act in watch_rows: + try: + resp = await client.get( + LAW_SEARCH_URL, + params={"OC": oc, "target": "law", "type": "XML", "query": act.title}, + headers={"User-Agent": CRAWL_UA}, + ) + resp.raise_for_status() + hit = parse_search_hit(resp.text, act.title) + if hit is None: + # fail-loud: 정식명 미매칭 = 표기 변경/검색 누락 의심 — 침묵 skip 금지 + logger.warning(f"[statute-kr] 목록 미매칭: {act.family_id} {act.title!r}") + else: + ev = detect_change(hit, act.family_id, act.title, act.watermark) + if ev: + events.append(ev) + except Exception as e: + logger.error(f"[statute-kr] poll 실패 ({act.family_id}): {type(e).__name__}: {e!r}") + await asyncio.sleep(_POLL_DELAY_S) + return events diff --git a/app/workers/statute_collector.py b/app/workers/statute_collector.py new file mode 100644 index 0000000..d016d5e --- /dev/null +++ b/app/workers/statute_collector.py @@ -0,0 +1,76 @@ +"""statute_collector — 법령 수집 코어 (plan safety-library-1 B-1). + +PR① 범위 (본 파일 현재 상태) = poll_changes 관찰 전용: + legal_acts 워치리스트(KR, watch=true) 순회 → 어댑터 poll_changes → 감지 이벤트 로그. + - 워터마크 영속 안 함 (계약: '파싱 검증 통과 후에만' — ingest 는 PR②. + 여기서 영속하면 PR② 가 이 변경들을 영원히 못 봄) + - 스케줄 미등록 (PR② 에서 잡 코드 통째 — 승격·supersede·스윕·repeal — 와 함께 등록. + R8-B1: 승격과 스윕의 PR 분리 = 배포 갭 동안 이중 노출 무음 윈도) + - jurisdiction 불변식: 어댑터 상수 == 행 jurisdiction assert (파싱 추론 금지) + +PR② 에서 추가될 것 (카드 = 스펙): fetch_version(payload 리스트) + ingest 4축 주입 +(material_type='law'/jurisdiction=어댑터 상수/published_date=COALESCE(시행,공포)/ +license=public_domain) + 생애주기 잡(버전 시리즈 단위 승격·supersede + 상태 기반 +레거시 스윕 + repeal — 한 트랜잭션, KST) + 26 family 부트스트랩(kind='bootstrap', +extract_meta.backfill=true) + 법령명 매핑 단위 테스트. + +수동 실행: docker compose exec -T fastapi python -m workers.statute_collector +""" + +import asyncio + +import httpx +from sqlalchemy import select + +from core.database import async_session +from core.utils import setup_logger +from models.legal_act import LegalAct +from workers.statute_adapters import kr + +logger = setup_logger("statute_collector") + +# jurisdiction → 어댑터 모듈 (Phase 1 = KR 단독, 해외는 B-5 게이트 뒤) +_ADAPTERS = {"KR": kr} + + +async def poll_once() -> int: + """워치리스트 1회 폴링 — 감지 이벤트 수 반환 (관찰 전용, 상태 변경 0).""" + async with async_session() as session: + result = await session.execute( + select(LegalAct).where(LegalAct.watch.is_(True)) + .order_by(LegalAct.family_id) + ) + rows = result.scalars().all() + + if not rows: + logger.warning("[statute] 워치리스트 비어 있음 — 시드(migration 356) 미적용?") + return 0 + + total = 0 + by_jur: dict[str, list] = {} + for row in rows: + by_jur.setdefault(row.jurisdiction, []).append(row) + + async with httpx.AsyncClient(timeout=30) as client: + for jur, acts in by_jur.items(): + adapter = _ADAPTERS.get(jur) + if adapter is None: + logger.warning(f"[statute] 어댑터 없는 jurisdiction skip: {jur} ({len(acts)}건)") + continue + # jurisdiction 불변식 — 어댑터 상수와 행 값 일치 (적재 전 단계에서도 동일 규율) + assert adapter.JURISDICTION == jur, f"어댑터/행 jurisdiction 불일치: {adapter.JURISDICTION} != {jur}" + events = await adapter.poll_changes(client, acts) + for ev in events: + logger.info( + f"[statute] 변경 감지 ({ev.kind}): {ev.family_id} {ev.title} " + f"MST={ev.new_version_key} 공포={ev.promulgation_date} " + f"시행={ev.effective_date} 구분={ev.revision_type}" + ) + total += len(events) + + logger.info(f"[statute] poll 완료 — 워치 {len(rows)}건 중 변경 {total}건 (관찰 전용, 영속 0)") + return total + + +if __name__ == "__main__": + asyncio.run(poll_once()) diff --git a/migrations/356_seed_legal_acts_kr.sql b/migrations/356_seed_legal_acts_kr.sql new file mode 100644 index 0000000..2fbe30d --- /dev/null +++ b/migrations/356_seed_legal_acts_kr.sql @@ -0,0 +1,41 @@ +-- 356_seed_legal_acts_kr.sql +-- 안전 자료실 B-1 PR① — legal_acts KR 시드 26행 (레거시 law_monitor 26개 superset). +-- plan: safety-library-1 B-1. watch=true 26개 전부 (R3-B1 ① — '우선순위'는 정렬일 뿐 제외 아님). +-- 법령ID/공포/시행 = 2026-06-13 lawSearch 라이브 실측 (tests/fixtures/statute_kr/seed_26laws.tsv). +-- ★ '유해ㆍ위험작업...' = 정식명에 가운뎃점(U+318D) — law_monitor 하드코딩(점 없음)은 exact match +-- 불일치로 이 법령을 영구 미매칭하던 잠복 누락이었음 (R8-m1 의 watchlist 판 실증). +-- parent 계열: 법률 → 시행령/시행규칙/위임 부령. VALUES 순서 = 부모 선행 (FK). +INSERT INTO legal_acts (family_id, jurisdiction, law_level, title, parent_family_id, native_id, source_api, watch, poll_cycle) +SELECT v.family_id, v.jurisdiction, v.law_level, v.title, v.parent_family_id, v.native_id, v.source_api, v.watch, v.poll_cycle +FROM (VALUES + -- 법률 (statute, 14) + ('kr-law:001766', 'KR', 'statute', '산업안전보건법', NULL, '001766', 'law.go.kr', TRUE, 'daily'), + ('kr-law:013993', 'KR', 'statute', '중대재해 처벌 등에 관한 법률', NULL, '013993', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001807', 'KR', 'statute', '건설기술 진흥법', NULL, '001807', 'law.go.kr', TRUE, 'daily'), + ('kr-law:000237', 'KR', 'statute', '시설물의 안전 및 유지관리에 관한 특별법', NULL, '000237', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009502', 'KR', 'statute', '위험물안전관리법', NULL, '009502', 'law.go.kr', TRUE, 'daily'), + ('kr-law:000162', 'KR', 'statute', '화학물질관리법', NULL, '000162', 'law.go.kr', TRUE, 'daily'), + ('kr-law:011857', 'KR', 'statute', '화학물질의 등록 및 평가 등에 관한 법률', NULL, '011857', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009503', 'KR', 'statute', '소방시설 설치 및 관리에 관한 법률', NULL, '009503', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001854', 'KR', 'statute', '전기사업법', NULL, '001854', 'law.go.kr', TRUE, 'daily'), + ('kr-law:013718', 'KR', 'statute', '전기안전관리법', NULL, '013718', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001850', 'KR', 'statute', '고압가스 안전관리법', NULL, '001850', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001849', 'KR', 'statute', '액화석유가스의 안전관리 및 사업법', NULL, '001849', 'law.go.kr', TRUE, 'daily'), + ('kr-law:001872', 'KR', 'statute', '근로기준법', NULL, '001872', 'law.go.kr', TRUE, 'daily'), + ('kr-law:002016', 'KR', 'statute', '환경영향평가법', NULL, '002016', 'law.go.kr', TRUE, 'daily'), + -- 대통령령 (decree, 7) + ('kr-law:003786', 'KR', 'decree', '산업안전보건법 시행령', 'kr-law:001766', '003786', 'law.go.kr', TRUE, 'daily'), + ('kr-law:014159', 'KR', 'decree', '중대재해 처벌 등에 관한 법률 시행령', 'kr-law:013993', '014159', 'law.go.kr', TRUE, 'daily'), + ('kr-law:002111', 'KR', 'decree', '건설기술 진흥법 시행령', 'kr-law:001807', '002111', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009707', 'KR', 'decree', '위험물안전관리법 시행령', 'kr-law:009502', '009707', 'law.go.kr', TRUE, 'daily'), + ('kr-law:004390', 'KR', 'decree', '화학물질관리법 시행령', 'kr-law:000162', '004390', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009694', 'KR', 'decree', '소방시설 설치 및 관리에 관한 법률 시행령', 'kr-law:009503', '009694', 'law.go.kr', TRUE, 'daily'), + ('kr-law:002246', 'KR', 'decree', '고압가스 안전관리법 시행령', 'kr-law:001850', '002246', 'law.go.kr', TRUE, 'daily'), + -- 부령 (rule, 5) + ('kr-law:007364', 'KR', 'rule', '산업안전보건법 시행규칙', 'kr-law:001766', '007364', 'law.go.kr', TRUE, 'daily'), + ('kr-law:007363', 'KR', 'rule', '산업안전보건기준에 관한 규칙', 'kr-law:001766', '007363', 'law.go.kr', TRUE, 'daily'), + ('kr-law:007844', 'KR', 'rule', '유해ㆍ위험작업의 취업 제한에 관한 규칙', 'kr-law:001766', '007844', 'law.go.kr', TRUE, 'daily'), + ('kr-law:006175', 'KR', 'rule', '건설기술 진흥법 시행규칙', 'kr-law:001807', '006175', 'law.go.kr', TRUE, 'daily'), + ('kr-law:009732', 'KR', 'rule', '위험물안전관리법 시행규칙', 'kr-law:009502', '009732', 'law.go.kr', TRUE, 'daily') +) AS v(family_id, jurisdiction, law_level, title, parent_family_id, native_id, source_api, watch, poll_cycle) +WHERE NOT EXISTS (SELECT 1 FROM legal_acts la WHERE la.family_id = v.family_id); diff --git a/tests/fixtures/statute_kr/lawjosub_probe.xml b/tests/fixtures/statute_kr/lawjosub_probe.xml new file mode 100644 index 0000000..811a59f --- /dev/null +++ b/tests/fixtures/statute_kr/lawjosub_probe.xml @@ -0,0 +1,46 @@ + +<법령 법령키="0017662026021921374"> +<기본정보> +<법령ID>001766 +<공포일자>20260219 +<공포번호>21374 +<언어>한글 +<법종구분 법종구분코드="A0002">법률 +<법령명_한글> +<법령명_한자> +<제명변경여부>N +<한글법령여부>Y +<편장절관>40040000 +<소관부처 소관부처코드="1492000">고용노동부 +<전화번호>044-202-8810, 8813, 8815, 8997 +<시행일자>20260601 +<제개정구분>일부개정 +<조문별시행일자>20260601 +<조문시행일자문자열>20260801:제10조의2, 제23조, 제175조제4항제1호의2 +<별표편집여부>N +<공포법령여부>Y +<시행일기준편집여부>Y + +<조문> +<조문단위 조문키="0001000"> +<조문번호>1 +<조문여부>전문 +<조문시행일자>20260601 +<조문이동이전> +<조문이동이후> +<조문변경여부>N + +<조문단위 조문키="0001001"> +<조문번호>1 +<조문여부>조문 +<조문제목> +<조문시행일자>20260601 +<조문이동이전> +<조문이동이후> +<조문변경여부>N +<조문내용> +]]> + + + + diff --git a/tests/fixtures/statute_kr/lawsearch_rule.xml b/tests/fixtures/statute_kr/lawsearch_rule.xml new file mode 100644 index 0000000..a88c62c --- /dev/null +++ b/tests/fixtures/statute_kr/lawsearch_rule.xml @@ -0,0 +1,2 @@ +law<키워드>산업안전보건기준에 관한 규칙
lawNm
11100success<법령일련번호>273603<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>007363<공포일자>20250901<공포번호>00450<제개정구분명>일부개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>고용노동부령<공동부령정보><시행일자>20260302<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=273603&type=HTML&mobileYn=&efYd=20260302
+ diff --git a/tests/fixtures/statute_kr/lawsearch_sanab.xml b/tests/fixtures/statute_kr/lawsearch_sanab.xml new file mode 100644 index 0000000..cdc76b7 --- /dev/null +++ b/tests/fixtures/statute_kr/lawsearch_sanab.xml @@ -0,0 +1,2 @@ +law<키워드>산업안전보건법
lawNm
31300success<법령일련번호>283449<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>001766<공포일자>20260219<공포번호>21374<제개정구분명>일부개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>법률<공동부령정보><시행일자>20260601<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=283449&type=HTML&mobileYn=&efYd=20260601<법령일련번호>284771<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>003786<공포일자>20260324<공포번호>36220<제개정구분명>타법개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>대통령령<공동부령정보><시행일자>20260324<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=284771&type=HTML&mobileYn=&efYd=20260324<법령일련번호>286657<현행연혁코드>현행<법령명한글><법령약칭명><법령ID>007364<공포일자>20260529<공포번호>00470<제개정구분명>일부개정<소관부처코드>1492000<소관부처명>고용노동부<법령구분명>고용노동부령<공동부령정보><시행일자>20260601<자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=286657&type=HTML&mobileYn=&efYd=20260601
+ diff --git a/tests/fixtures/statute_kr/lawservice_rule.xml.gz b/tests/fixtures/statute_kr/lawservice_rule.xml.gz new file mode 100644 index 0000000..92595bc Binary files /dev/null and b/tests/fixtures/statute_kr/lawservice_rule.xml.gz differ diff --git a/tests/fixtures/statute_kr/lawservice_sanab.xml.gz b/tests/fixtures/statute_kr/lawservice_sanab.xml.gz new file mode 100644 index 0000000..154916c Binary files /dev/null and b/tests/fixtures/statute_kr/lawservice_sanab.xml.gz differ diff --git a/tests/fixtures/statute_kr/seed_26laws.tsv b/tests/fixtures/statute_kr/seed_26laws.tsv new file mode 100644 index 0000000..8dbfe3f --- /dev/null +++ b/tests/fixtures/statute_kr/seed_26laws.tsv @@ -0,0 +1,26 @@ +산업안전보건법 001766 283449 20260219 20260601 법률 +산업안전보건법 시행령 003786 284771 20260324 20260324 대통령령 +산업안전보건법 시행규칙 007364 286657 20260529 20260601 고용노동부령 +산업안전보건기준에 관한 규칙 007363 273603 20250901 20260302 고용노동부령 +유해위험작업의 취업 제한에 관한 규칙 MISS +중대재해 처벌 등에 관한 법률 013993 228817 20210126 20220127 법률 +중대재해 처벌 등에 관한 법률 시행령 014159 277417 20251001 20251001 대통령령 +건설기술 진흥법 001807 276921 20251001 20251001 법률 +건설기술 진흥법 시행령 002111 286847 20260609 20260609 대통령령 +건설기술 진흥법 시행규칙 006175 286885 20260611 20260611 국토교통부령 +시설물의 안전 및 유지관리에 관한 특별법 000237 266683 20241203 20251204 법률 +위험물안전관리법 009502 259933 20240206 20250807 법률 +위험물안전관리법 시행령 009707 273077 20250805 20250807 대통령령 +위험물안전관리법 시행규칙 009732 262765 20240520 20250521 행정안전부령 +화학물질관리법 000162 276815 20251001 20251001 법률 +화학물질관리법 시행령 004390 280507 20251223 20251223 대통령령 +화학물질의 등록 및 평가 등에 관한 법률 011857 279805 20251111 20260512 법률 +소방시설 설치 및 관리에 관한 법률 009503 236977 20211130 20241201 법률 +소방시설 설치 및 관리에 관한 법률 시행령 009694 284781 20260324 20260324 대통령령 +전기사업법 001854 283981 20260310 20260310 법률 +전기안전관리법 013718 268805 20250131 20260201 법률 +고압가스 안전관리법 001850 283919 20260310 20260310 법률 +고압가스 안전관리법 시행령 002246 286839 20260609 20260609 대통령령 +액화석유가스의 안전관리 및 사업법 001849 276549 20251001 20251128 법률 +근로기준법 001872 265959 20241022 20251023 법률 +환경영향평가법 002016 276833 20251001 20251023 법률 \ No newline at end of file diff --git a/tests/test_statute_kr_adapter.py b/tests/test_statute_kr_adapter.py new file mode 100644 index 0000000..715c9e4 --- /dev/null +++ b/tests/test_statute_kr_adapter.py @@ -0,0 +1,89 @@ +"""B-1 PR① — KR 어댑터 순수 파서 fixture 테스트 (plan safety-library-1). + +fixture = 2026-06-13 law.go.kr 라이브 박제 (OC 새니타이즈, tests/fixtures/statute_kr/). +파서는 순수 함수라 httpx/DB 불요 — 컨테이너 밖 로컬 실행. +""" + +import gzip +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "app")) + +from workers.statute_adapters import ChangeEvent # noqa: E402 +from workers.statute_adapters.kr import detect_change, parse_search_hit # noqa: E402 + +FIX = Path(__file__).parent / "fixtures" / "statute_kr" + + +def _read(name: str) -> str: + p = FIX / name + if name.endswith(".gz"): + return gzip.decompress(p.read_bytes()).decode("utf-8") + return p.read_text(encoding="utf-8") + + +def test_parse_search_hit_exact_match(): + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + assert hit is not None + assert hit["law_id"] == "001766" + assert hit["mst"] == "283449" + assert hit["promulgation_date"] == "20260219" + assert hit["effective_date"] == "20260601" + assert hit["status_code"] == "현행" + + +def test_parse_search_hit_rejects_partial_name(): + # totalCnt 3 인 응답에서 '산업안전보건법 시행령' 등 부분 일치는 비매칭이어야 함 + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건") + assert hit is None + + +def test_detect_change_same_watermark_is_silent(): + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + assert detect_change(hit, "kr-law:001766", "산업안전보건법", watermark="283449") is None + + +def test_detect_change_new_mst_is_amend(): + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + ev = detect_change(hit, "kr-law:001766", "산업안전보건법", watermark="283448") + assert isinstance(ev, ChangeEvent) + assert ev.kind == "amend" + assert ev.new_version_key == "283449" + assert ev.effective_date == "20260601" + + +def test_detect_change_empty_watermark_is_amend(): + # 첫 폴링(워터마크 부재) = 변경으로 감지 — PR② 부트스트랩 전 관찰 모드의 기대 동작 + hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법") + ev = detect_change(hit, "kr-law:001766", "산업안전보건법", watermark=None) + assert ev is not None and ev.kind == "amend" + + +def test_detect_change_repeal_keyword(): + hit = {"mst": "9", "revision_type": "폐지", "promulgation_date": None, + "effective_date": None, "law_id": "x", "status_code": None} + ev = detect_change(hit, "kr-law:x", "x", watermark="1") + assert ev is not None and ev.kind == "repeal" + + +def test_lawservice_snapshot_semantics_rule(): + """R7-M3 판정 박제: 전문 1콜 XML = 조문+별표 전체 스냅샷 (PR② payload 계약의 전제).""" + root = ET.fromstring(_read("lawservice_rule.xml.gz")) + articles = root.findall(".//조문단위") + annexes = root.findall(".//별표단위") + assert len(articles) >= 800, "산안기준규칙 조문 853 기대 — 전문 1콜 판정 근거" + assert len(annexes) == 23, "별표 23 전부 본문 XML 포함 = 스냅샷 의미론" + # R7-M3 ②: 별표 식별 = 구조화 필드 (suffix 문자열 파싱 불요) + first = annexes[0] + assert first.findtext("별표번호") is not None + assert first.findtext("별표가지번호") is not None + + +def test_lawservice_sanab_basic_info(): + root = ET.fromstring(_read("lawservice_sanab.xml.gz")) + assert root.findtext(".//법령ID") == "001766" + assert len(root.findall(".//조문단위")) >= 200 + # 별표 없는 법령 = 별표단위 0 (스냅샷 의미론의 반대쪽 케이스) + assert len(root.findall(".//별표단위")) == 0