feat: 법령 모니터링 대폭 개선 — 장 단위 MD 분할 + 크로스 링크 + Tier 분리
- law_parser.py 신규: XML→MD 장 단위 분할, 조문 앵커 링크, 부칙 분리 - 장/절/편 자동 식별 (<조문여부>=전문), 장 없는 법령 fallback - DEVONthink wiki-link 크로스 링크 (같은 법률 내 + 다른 법률 간) - MST 자동 조회 + 7일 TTL 캐시 + 원자적 파일 쓰기 - 법령 약칭 매핑 (산안법→산업안전보건법 등) - law_monitor.py 리팩터링: - MONITORED_LAWS → Tier 1(15개 필수) / Tier 2(8개 참고, 비활성) - law_id → MST 방식 (현행 법령 자동 조회) - XML 통짜 저장 → 장별 Markdown 분할 저장 - DEVONthink 3단계 교체 (이동→생성→삭제, wiki-link 보존) - 에러 핸들링: 재시도 3회/백오프 + 부분 실패 허용 + 법령명 검증 - 실행 결과 law_last_run.json 기록 테스트: 15개 법령 전체 성공 (148개 MD 파일 생성) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,18 +17,50 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from pkm_utils import setup_logger, load_credentials, run_applescript_inline, llm_generate, PROJECT_ROOT, DATA_DIR
|
||||
from law_parser import (
|
||||
parse_law_xml, save_law_as_markdown, build_article_chapter_map,
|
||||
add_cross_law_links, lookup_current_mst, atomic_write_json,
|
||||
)
|
||||
|
||||
logger = setup_logger("law_monitor")
|
||||
|
||||
# 모니터링 대상 법령
|
||||
MONITORED_LAWS = [
|
||||
{"name": "산업안전보건법", "law_id": "001789", "category": "법률"},
|
||||
{"name": "산업안전보건법 시행령", "law_id": "001790", "category": "대통령령"},
|
||||
{"name": "산업안전보건법 시행규칙", "law_id": "001791", "category": "부령"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률", "law_id": "019005", "category": "법률"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률 시행령", "law_id": "019006", "category": "대통령령"},
|
||||
{"name": "화학물질관리법", "law_id": "012354", "category": "법률"},
|
||||
{"name": "위험물안전관리법", "law_id": "001478", "category": "법률"},
|
||||
MST_CACHE_FILE = DATA_DIR / "law_mst_cache.json"
|
||||
MD_OUTPUT_DIR = DATA_DIR / "laws" / "md"
|
||||
MD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Tier 1 — 필수 모니터링 (업무 직접 관련, 매일 확인)
|
||||
TIER1_LAWS = [
|
||||
# 산업안전 핵심
|
||||
{"name": "산업안전보건법", "category": "법률"},
|
||||
{"name": "산업안전보건법 시행령", "category": "대통령령"},
|
||||
{"name": "산업안전보건법 시행규칙", "category": "부령"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률", "category": "법률"},
|
||||
{"name": "중대재해 처벌 등에 관한 법률 시행령", "category": "대통령령"},
|
||||
# 화학/위험물
|
||||
{"name": "화학물질관리법", "category": "법률"},
|
||||
{"name": "위험물안전관리법", "category": "법률"},
|
||||
{"name": "고압가스 안전관리법", "category": "법률"},
|
||||
# 전기/소방/건설
|
||||
{"name": "전기안전관리법", "category": "법률"},
|
||||
{"name": "소방시설 설치 및 관리에 관한 법률", "category": "법률"},
|
||||
{"name": "건설기술 진흥법", "category": "법률"},
|
||||
# 시설물/노동
|
||||
{"name": "시설물의 안전 및 유지관리에 관한 특별법", "category": "법률"},
|
||||
{"name": "근로기준법", "category": "법률"},
|
||||
{"name": "산업재해보상보험법", "category": "법률"},
|
||||
{"name": "근로자참여 및 협력증진에 관한 법률", "category": "법률"},
|
||||
]
|
||||
|
||||
# Tier 2 — 참고 (기본 비활성, --include-tier2 또는 설정으로 활성화)
|
||||
TIER2_LAWS = [
|
||||
{"name": "원자력안전법", "category": "법률"},
|
||||
{"name": "방사선안전관리법", "category": "법률"},
|
||||
{"name": "환경영향평가법", "category": "법률"},
|
||||
{"name": "석면안전관리법", "category": "법률"},
|
||||
{"name": "승강기 안전관리법", "category": "법률"},
|
||||
{"name": "연구실 안전환경 조성에 관한 법률", "category": "법률"},
|
||||
{"name": "재난 및 안전관리 기본법", "category": "법률"},
|
||||
{"name": "고용보험법", "category": "법률"},
|
||||
]
|
||||
|
||||
# 마지막 확인 일자 저장 파일
|
||||
@@ -46,37 +78,36 @@ def load_last_check() -> dict:
|
||||
|
||||
|
||||
def save_last_check(data: dict):
|
||||
"""마지막 확인 일자 저장"""
|
||||
with open(LAST_CHECK_FILE, "w") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
"""마지막 확인 일자 저장 (원자적 쓰기)"""
|
||||
atomic_write_json(LAST_CHECK_FILE, data)
|
||||
|
||||
|
||||
def fetch_law_info(law_oc: str, law_id: str) -> dict | None:
|
||||
"""법령 정보 조회 (법령 API)"""
|
||||
url = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
def fetch_law_info(law_oc: str, mst: str) -> dict | None:
|
||||
"""법령 정보 조회 — lawService.do로 MST 직접 조회 (XML → 기본정보 추출)"""
|
||||
url = "https://www.law.go.kr/DRF/lawService.do"
|
||||
params = {
|
||||
"OC": law_oc,
|
||||
"target": "law",
|
||||
"type": "JSON",
|
||||
"MST": law_id,
|
||||
"type": "XML",
|
||||
"MST": mst,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# API 에러 응답 감지
|
||||
if "result" in data and "실패" in str(data.get("result", "")):
|
||||
logger.error(f"법령 API 에러 [{law_id}]: {data.get('result')} — {data.get('msg')}")
|
||||
root = ET.fromstring(resp.content)
|
||||
info_el = root.find(".//기본정보")
|
||||
if info_el is None:
|
||||
logger.warning(f"기본정보 없음 [MST={mst}]")
|
||||
return None
|
||||
if "LawSearch" in data and "law" in data["LawSearch"]:
|
||||
laws = data["LawSearch"]["law"]
|
||||
if isinstance(laws, list):
|
||||
return laws[0] if laws else None
|
||||
return laws
|
||||
logger.warning(f"법령 응답에 데이터 없음 [{law_id}]: {list(data.keys())}")
|
||||
return None
|
||||
return {
|
||||
"법령명한글": (info_el.findtext("법령명_한글", "") or "").strip(),
|
||||
"공포일자": (info_el.findtext("공포일자", "") or "").strip(),
|
||||
"시행일자": (info_el.findtext("시행일자", "") or "").strip(),
|
||||
"법령ID": (info_el.findtext("법령ID", "") or "").strip(),
|
||||
"소관부처": (info_el.findtext("소관부처", "") or "").strip(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"법령 조회 실패 [{law_id}]: {e}")
|
||||
logger.error(f"법령 조회 실패 [MST={mst}]: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -109,32 +140,91 @@ def save_law_file(law_name: str, content: str) -> Path:
|
||||
return filepath
|
||||
|
||||
|
||||
def import_to_devonthink(filepath: Path, law_name: str, category: str):
|
||||
"""DEVONthink 04_Industrial Safety로 임포트 — 변수 방식"""
|
||||
fp = str(filepath)
|
||||
script = f'set fp to "{fp}"\n'
|
||||
script += 'tell application id "DNtp"\n'
|
||||
script += ' repeat with db in databases\n'
|
||||
script += ' if name of db is "04_Industrial safety" then\n'
|
||||
script += ' set targetGroup to create location "/10_Legislation/Law" in db\n'
|
||||
script += ' set theRecord to import fp to targetGroup\n'
|
||||
script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{category}"}}\n'
|
||||
script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n'
|
||||
script += ' add custom meta data "external" for "dataOrigin" to theRecord\n'
|
||||
script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n'
|
||||
script += ' exit repeat\n'
|
||||
script += ' end if\n'
|
||||
script += ' end repeat\n'
|
||||
script += 'end tell'
|
||||
def import_law_to_devonthink(law_name: str, md_files: list[Path], category: str):
|
||||
"""DEVONthink 04_Industrial Safety로 장별 MD 파일 임포트
|
||||
3단계 교체: 기존 폴더 이동 → 신규 생성 → 구 폴더 삭제 (wiki-link 끊김 최소화)
|
||||
"""
|
||||
safe_name = law_name.replace(" ", "_")
|
||||
group_path = f"/10_Legislation/{safe_name}"
|
||||
|
||||
# 1단계: 기존 폴더 이동 (있으면)
|
||||
rename_script = (
|
||||
'tell application id "DNtp"\n'
|
||||
' repeat with db in databases\n'
|
||||
' if name of db is "04_Industrial safety" then\n'
|
||||
f' set oldGroup to get record at "{group_path}" in db\n'
|
||||
' if oldGroup is not missing value then\n'
|
||||
f' set name of oldGroup to "{safe_name}_old"\n'
|
||||
' end if\n'
|
||||
' exit repeat\n'
|
||||
' end if\n'
|
||||
' end repeat\n'
|
||||
'end tell'
|
||||
)
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
logger.info(f"DEVONthink 임포트 완료: {law_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 임포트 실패 [{law_name}]: {e}")
|
||||
run_applescript_inline(rename_script)
|
||||
except Exception:
|
||||
pass # 기존 폴더 없으면 무시
|
||||
|
||||
# 2단계: 신규 폴더 생성 + 파일 임포트
|
||||
for filepath in md_files:
|
||||
fp = str(filepath)
|
||||
script = f'set fp to "{fp}"\n'
|
||||
script += 'tell application id "DNtp"\n'
|
||||
script += ' repeat with db in databases\n'
|
||||
script += ' if name of db is "04_Industrial safety" then\n'
|
||||
script += f' set targetGroup to create location "{group_path}" in db\n'
|
||||
script += ' set theRecord to import fp to targetGroup\n'
|
||||
script += f' set tags of theRecord to {{"#주제/산업안전/법령", "$유형/법령", "{category}"}}\n'
|
||||
script += ' add custom meta data "law_monitor" for "sourceChannel" to theRecord\n'
|
||||
script += ' add custom meta data "external" for "dataOrigin" to theRecord\n'
|
||||
script += ' add custom meta data (current date) for "lastAIProcess" to theRecord\n'
|
||||
script += ' exit repeat\n'
|
||||
script += ' end if\n'
|
||||
script += ' end repeat\n'
|
||||
script += 'end tell'
|
||||
try:
|
||||
run_applescript_inline(script)
|
||||
except Exception as e:
|
||||
logger.error(f"DEVONthink 임포트 실패 [{filepath.name}]: {e}")
|
||||
|
||||
# 3단계: 구 폴더 삭제
|
||||
delete_script = (
|
||||
'tell application id "DNtp"\n'
|
||||
' repeat with db in databases\n'
|
||||
' if name of db is "04_Industrial safety" then\n'
|
||||
f' set oldGroup to get record at "/10_Legislation/{safe_name}_old" in db\n'
|
||||
' if oldGroup is not missing value then\n'
|
||||
' delete record oldGroup\n'
|
||||
' end if\n'
|
||||
' exit repeat\n'
|
||||
' end if\n'
|
||||
' end repeat\n'
|
||||
'end tell'
|
||||
)
|
||||
try:
|
||||
run_applescript_inline(delete_script)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"DEVONthink 임포트 완료: {law_name} ({len(md_files)}개 파일)")
|
||||
|
||||
|
||||
def run():
|
||||
"""메인 실행"""
|
||||
def _fetch_with_retry(func, *args, retries=3, backoff=(5, 15, 30)):
|
||||
"""API 호출 재시도 래퍼"""
|
||||
import time
|
||||
for i in range(retries):
|
||||
result = func(*args)
|
||||
if result is not None:
|
||||
return result
|
||||
if i < retries - 1:
|
||||
logger.warning(f"재시도 {i+2}/{retries} ({backoff[i]}초 후)")
|
||||
time.sleep(backoff[i])
|
||||
return None
|
||||
|
||||
|
||||
def run(include_tier2: bool = False):
|
||||
"""메인 실행 — MST 자동 조회 + 장 단위 MD 분할 + DEVONthink 임포트"""
|
||||
logger.info("=== 법령 모니터링 시작 ===")
|
||||
|
||||
creds = load_credentials()
|
||||
@@ -143,41 +233,82 @@ def run():
|
||||
logger.error("LAW_OC 인증키가 설정되지 않았습니다. credentials.env를 확인하세요.")
|
||||
sys.exit(1)
|
||||
|
||||
laws = TIER1_LAWS + (TIER2_LAWS if include_tier2 else [])
|
||||
last_check = load_last_check()
|
||||
changes_found = 0
|
||||
failures = []
|
||||
|
||||
for law in MONITORED_LAWS:
|
||||
for law in laws:
|
||||
law_name = law["name"]
|
||||
law_id = law["law_id"]
|
||||
category = law["category"]
|
||||
|
||||
logger.info(f"확인 중: {law_name} ({law_id})")
|
||||
|
||||
info = fetch_law_info(law_oc, law_id)
|
||||
if not info:
|
||||
# MST 자동 조회 (캐시 TTL 7일)
|
||||
mst = lookup_current_mst(law_oc, law_name, category, cache_path=MST_CACHE_FILE)
|
||||
if not mst:
|
||||
failures.append({"name": law_name, "error": "MST 조회 실패"})
|
||||
continue
|
||||
|
||||
# 시행일자 또는 공포일자로 변경 감지
|
||||
announce_date = info.get("공포일자", info.get("시행일자", ""))
|
||||
prev_date = last_check.get(law_id, "")
|
||||
logger.info(f"확인 중: {law_name} (MST={mst})")
|
||||
|
||||
# XML 한 번에 다운로드 (정보 추출 + 파싱 겸용)
|
||||
xml_text = _fetch_with_retry(fetch_law_text, law_oc, mst)
|
||||
if not xml_text:
|
||||
failures.append({"name": law_name, "error": "XML 다운로드 실패"})
|
||||
continue
|
||||
|
||||
# XML에서 기본정보 추출
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
info_el = root.find(".//기본정보")
|
||||
returned_name = (info_el.findtext("법령명_한글", "") or "").strip() if info_el else ""
|
||||
except Exception:
|
||||
failures.append({"name": law_name, "error": "XML 파싱 실패"})
|
||||
continue
|
||||
|
||||
# 법령명 검증
|
||||
if law_name not in returned_name and returned_name not in law_name:
|
||||
logger.warning(f"법령명 불일치: 요청='{law_name}' 응답='{returned_name}' — 스킵")
|
||||
failures.append({"name": law_name, "error": f"법령명 불일치: {returned_name}"})
|
||||
continue
|
||||
|
||||
# 공포일자로 변경 감지
|
||||
announce_date = (info_el.findtext("공포일자", "") or "").strip() if info_el else ""
|
||||
prev_date = last_check.get(law_name, "")
|
||||
|
||||
if announce_date and announce_date != prev_date:
|
||||
logger.info(f"변경 감지: {law_name} — 공포일자 {announce_date} (이전: {prev_date or '없음'})")
|
||||
|
||||
# 법령 본문 다운로드
|
||||
law_mst = info.get("법령MST", law_id)
|
||||
text = fetch_law_text(law_oc, law_mst)
|
||||
if text:
|
||||
filepath = save_law_file(law_name, text)
|
||||
import_to_devonthink(filepath, law_name, category)
|
||||
changes_found += 1
|
||||
# XML 저장
|
||||
xml_path = save_law_file(law_name, xml_text)
|
||||
|
||||
last_check[law_id] = announce_date
|
||||
# XML → MD 장 분할
|
||||
try:
|
||||
parsed = parse_law_xml(str(xml_path))
|
||||
md_files = save_law_as_markdown(law_name, parsed, MD_OUTPUT_DIR)
|
||||
import_law_to_devonthink(law_name, md_files, category)
|
||||
changes_found += 1
|
||||
except Exception as e:
|
||||
logger.error(f"법령 파싱/임포트 실패 [{law_name}]: {e}", exc_info=True)
|
||||
failures.append({"name": law_name, "error": str(e)})
|
||||
continue
|
||||
|
||||
last_check[law_name] = announce_date
|
||||
else:
|
||||
logger.debug(f"변경 없음: {law_name}")
|
||||
|
||||
save_last_check(last_check)
|
||||
|
||||
# 실행 결과 기록
|
||||
run_result = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"total": len(laws),
|
||||
"changes": changes_found,
|
||||
"failures": failures,
|
||||
}
|
||||
atomic_write_json(DATA_DIR / "law_last_run.json", run_result)
|
||||
if failures:
|
||||
logger.warning(f"실패 {len(failures)}건: {[f['name'] for f in failures]}")
|
||||
|
||||
# ─── 외국 법령 (빈도 체크 후 실행) ───
|
||||
us_count = fetch_us_osha(last_check)
|
||||
jp_count = fetch_jp_mhlw(last_check)
|
||||
@@ -395,4 +526,5 @@ def fetch_eu_osha(last_check: dict) -> int:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
tier2 = "--include-tier2" in sys.argv
|
||||
run(include_tier2=tier2)
|
||||
|
||||
Reference in New Issue
Block a user