feat(safety): B-1 PR① — law_monitor 스케줄 제거 + statute KR poll_changes + fixture 박제 (mig 356)

plan safety-library-1 B-1 PR① (fixture-first):
- law.go.kr 라이브 fixture 5종 박제 (OC 새니타이즈 검증 — 응답 법령상세링크에 키 포함 함정)
- R7-M3 판정: 전문 1콜 XML = 조문 853+별표 23 전체 스냅샷(부분 실패 개념 없음)
  + 별표번호/가지번호 = 구조화 필드 — 조문 취득 = 전문 1콜+로컬 파싱 확정(R2-m1)
- legal_acts KR 시드 26행(법령ID 라이브 실측, watch=26 전부, FK 계열 9그룹)
  ★ '유해ㆍ위험작업...' 정식명 = 가운뎃점 — law_monitor 하드코딩(점 없음)은 영구 미매칭 잠복
- statute_adapters/kr.py: poll_changes(lawSearch MST diff) — 순수 파서 분리, fixture 테스트 8/8
- statute_collector.py: 관찰 전용 코어(워터마크 영속 0 — ingest=PR②), 스케줄 미등록(R8-B1)
- main.py: law_monitor 스케줄 제거 — 버전 체인 밖 레거시 매일 증식의 유일 경로 차단

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-13 09:01:21 +09:00
parent 0c8fb41366
commit a28f12b12e
12 changed files with 431 additions and 2 deletions
+3 -2
View File
@@ -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")
+25
View File
@@ -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 # 제개정구분명
+121
View File
@@ -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
+76
View File
@@ -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())
+41
View File
@@ -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);
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<법령 법령키="0017662026021921374">
<기본정보>
<법령ID>001766</법령ID>
<공포일자>20260219</공포일자>
<공포번호>21374</공포번호>
<언어>한글</언어>
<법종구분 법종구분코드="A0002">법률</법종구분>
<법령명_한글><![CDATA[산업안전보건법]]></법령명_한글>
<법령명_한자><![CDATA[산업안전보건법]]></법령명_한자>
<제명변경여부>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</조문번호>
<조문여부>조문</조문여부>
<조문제목><![CDATA[목적]]></조문제목>
<조문시행일자>20260601</조문시행일자>
<조문이동이전></조문이동이전>
<조문이동이후></조문이동이후>
<조문변경여부>N</조문변경여부>
<조문내용>
<![CDATA[제1조(목적) 이 법은 산업 안전 및 보건에 관한 기준을 확립하고 그 책임의 소재를 명확하게 하여 산업재해를 예방하고 쾌적한 작업환경을 조성함으로써 노무를 제공하는 사람의 안전 및 보건을 유지ㆍ증진함을 목적으로 한다. <개정 2020.5.26>]]>
</조문내용>
</조문단위>
</조문>
</법령>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?><LawSearch><target>law</target><키워드>산업안전보건기준에 관한 규칙</키워드><section>lawNm</section><totalCnt>1</totalCnt><page>1</page><numOfRows>1</numOfRows><resultCode>00</resultCode><resultMsg>success</resultMsg><law id="1"><법령일련번호>273603</법령일련번호><현행연혁코드>현행</현행연혁코드><법령명한글><![CDATA[산업안전보건기준에 관한 규칙]]></법령명한글><법령약칭명><![CDATA[안전보건규칙]]></법령약칭명><법령ID>007363</법령ID><공포일자>20250901</공포일자><공포번호>00450</공포번호><제개정구분명>일부개정</제개정구분명><소관부처코드>1492000</소관부처코드><소관부처명>고용노동부</소관부처명><법령구분명>고용노동부령</법령구분명><공동부령정보></공동부령정보><시행일자>20260302</시행일자><자법타법여부></자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&amp;target=law&amp;MST=273603&amp;type=HTML&amp;mobileYn=&amp;efYd=20260302</법령상세링크></law></LawSearch>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?><LawSearch><target>law</target><키워드>산업안전보건법</키워드><section>lawNm</section><totalCnt>3</totalCnt><page>1</page><numOfRows>3</numOfRows><resultCode>00</resultCode><resultMsg>success</resultMsg><law id="1"><법령일련번호>283449</법령일련번호><현행연혁코드>현행</현행연혁코드><법령명한글><![CDATA[산업안전보건법]]></법령명한글><법령약칭명><![CDATA[]]></법령약칭명><법령ID>001766</법령ID><공포일자>20260219</공포일자><공포번호>21374</공포번호><제개정구분명>일부개정</제개정구분명><소관부처코드>1492000</소관부처코드><소관부처명>고용노동부</소관부처명><법령구분명>법률</법령구분명><공동부령정보></공동부령정보><시행일자>20260601</시행일자><자법타법여부></자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&amp;target=law&amp;MST=283449&amp;type=HTML&amp;mobileYn=&amp;efYd=20260601</법령상세링크></law><law id="2"><법령일련번호>284771</법령일련번호><현행연혁코드>현행</현행연혁코드><법령명한글><![CDATA[산업안전보건법 시행령]]></법령명한글><법령약칭명><![CDATA[]]></법령약칭명><법령ID>003786</법령ID><공포일자>20260324</공포일자><공포번호>36220</공포번호><제개정구분명>타법개정</제개정구분명><소관부처코드>1492000</소관부처코드><소관부처명>고용노동부</소관부처명><법령구분명>대통령령</법령구분명><공동부령정보></공동부령정보><시행일자>20260324</시행일자><자법타법여부></자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&amp;target=law&amp;MST=284771&amp;type=HTML&amp;mobileYn=&amp;efYd=20260324</법령상세링크></law><law id="3"><법령일련번호>286657</법령일련번호><현행연혁코드>현행</현행연혁코드><법령명한글><![CDATA[산업안전보건법 시행규칙]]></법령명한글><법령약칭명><![CDATA[]]></법령약칭명><법령ID>007364</법령ID><공포일자>20260529</공포일자><공포번호>00470</공포번호><제개정구분명>일부개정</제개정구분명><소관부처코드>1492000</소관부처코드><소관부처명>고용노동부</소관부처명><법령구분명>고용노동부령</법령구분명><공동부령정보></공동부령정보><시행일자>20260601</시행일자><자법타법여부></자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&amp;target=law&amp;MST=286657&amp;type=HTML&amp;mobileYn=&amp;efYd=20260601</법령상세링크></law></LawSearch>
Binary file not shown.
Binary file not shown.
+26
View File
@@ -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 법률
1 산업안전보건법 001766 283449 20260219 20260601 법률
2 산업안전보건법 시행령 003786 284771 20260324 20260324 대통령령
3 산업안전보건법 시행규칙 007364 286657 20260529 20260601 고용노동부령
4 산업안전보건기준에 관한 규칙 007363 273603 20250901 20260302 고용노동부령
5 유해위험작업의 취업 제한에 관한 규칙 MISS
6 중대재해 처벌 등에 관한 법률 013993 228817 20210126 20220127 법률
7 중대재해 처벌 등에 관한 법률 시행령 014159 277417 20251001 20251001 대통령령
8 건설기술 진흥법 001807 276921 20251001 20251001 법률
9 건설기술 진흥법 시행령 002111 286847 20260609 20260609 대통령령
10 건설기술 진흥법 시행규칙 006175 286885 20260611 20260611 국토교통부령
11 시설물의 안전 및 유지관리에 관한 특별법 000237 266683 20241203 20251204 법률
12 위험물안전관리법 009502 259933 20240206 20250807 법률
13 위험물안전관리법 시행령 009707 273077 20250805 20250807 대통령령
14 위험물안전관리법 시행규칙 009732 262765 20240520 20250521 행정안전부령
15 화학물질관리법 000162 276815 20251001 20251001 법률
16 화학물질관리법 시행령 004390 280507 20251223 20251223 대통령령
17 화학물질의 등록 및 평가 등에 관한 법률 011857 279805 20251111 20260512 법률
18 소방시설 설치 및 관리에 관한 법률 009503 236977 20211130 20241201 법률
19 소방시설 설치 및 관리에 관한 법률 시행령 009694 284781 20260324 20260324 대통령령
20 전기사업법 001854 283981 20260310 20260310 법률
21 전기안전관리법 013718 268805 20250131 20260201 법률
22 고압가스 안전관리법 001850 283919 20260310 20260310 법률
23 고압가스 안전관리법 시행령 002246 286839 20260609 20260609 대통령령
24 액화석유가스의 안전관리 및 사업법 001849 276549 20251001 20251128 법률
25 근로기준법 001872 265959 20241022 20251023 법률
26 환경영향평가법 002016 276833 20251001 20251023 법률
+89
View File
@@ -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