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:
+3
-2
@@ -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")
|
||||
|
||||
@@ -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 # 제개정구분명
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
@@ -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>]]>
|
||||
</조문내용>
|
||||
</조문단위>
|
||||
</조문>
|
||||
</법령>
|
||||
@@ -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__&target=law&MST=273603&type=HTML&mobileYn=&efYd=20260302</법령상세링크></law></LawSearch>
|
||||
|
||||
@@ -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__&target=law&MST=283449&type=HTML&mobileYn=&efYd=20260601</법령상세링크></law><law id="2"><법령일련번호>284771</법령일련번호><현행연혁코드>현행</현행연혁코드><법령명한글><![CDATA[산업안전보건법 시행령]]></법령명한글><법령약칭명><![CDATA[]]></법령약칭명><법령ID>003786</법령ID><공포일자>20260324</공포일자><공포번호>36220</공포번호><제개정구분명>타법개정</제개정구분명><소관부처코드>1492000</소관부처코드><소관부처명>고용노동부</소관부처명><법령구분명>대통령령</법령구분명><공동부령정보></공동부령정보><시행일자>20260324</시행일자><자법타법여부></자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=284771&type=HTML&mobileYn=&efYd=20260324</법령상세링크></law><law id="3"><법령일련번호>286657</법령일련번호><현행연혁코드>현행</현행연혁코드><법령명한글><![CDATA[산업안전보건법 시행규칙]]></법령명한글><법령약칭명><![CDATA[]]></법령약칭명><법령ID>007364</법령ID><공포일자>20260529</공포일자><공포번호>00470</공포번호><제개정구분명>일부개정</제개정구분명><소관부처코드>1492000</소관부처코드><소관부처명>고용노동부</소관부처명><법령구분명>고용노동부령</법령구분명><공동부령정보></공동부령정보><시행일자>20260601</시행일자><자법타법여부></자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=286657&type=HTML&mobileYn=&efYd=20260601</법령상세링크></law></LawSearch>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+26
@@ -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 법률
|
||||
|
@@ -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
|
||||
Reference in New Issue
Block a user