diff --git a/resources/knowledge/original/_pipeline/README.md b/resources/knowledge/original/_pipeline/README.md index 464f2fb9..37866022 100644 --- a/resources/knowledge/original/_pipeline/README.md +++ b/resources/knowledge/original/_pipeline/README.md @@ -16,6 +16,7 @@ |---|---|---| | `check_law.py` | law.go.kr 현행본 시행일·개정일 조회 | W1 갱신 판별 | | `collect_law.py` | 법령·행정규칙 원문(현행+교본시점) + 별표 PDF 수집 | W2 | +| `patch_law_md.py` | 보관 XML 기준으로 기존 법령 md의 **누락분만 보충**(부칙·목). 네트워크 미사용 | W2 보정 | | `get_attach.py` | 고시 첨부파일·안내문 별표의 HWP 원본 수집 | W3 | | `get_kcsc.py` | 국가건설기준센터 KCS 본문(OpenApi) 수집 | W2 | | `get_ks.py` | e나라 표준인증 KS 메타데이터·이력 수집(원문은 DRM) | W2 | @@ -56,3 +57,20 @@ python gen_md.py # 색인 재생성 ``` 수집은 새로 받은 현행본을 `현행_새시행일.*` 로 **추가**한다(기존 판본 삭제 금지). 상세 규칙은 `docs/raw/law/README.md` W1~W6 참조. + +## 변환 규칙이 바뀐 경우 (2026-08-14 감사 반영) + +기존 법령 md 에는 수집 이후 **후처리**(ASCII 박스표 → 마크다운 표, 수식, 이미지)가 +적용돼 있다. `law_to_md()` 로 전체를 다시 렌더링하면 그 결과가 사라지므로 +**전량 재생성 금지**. 대신 누락분만 채우는 `patch_law_md.py` 를 쓴다. + +```bash +python patch_law_md.py --dry # 보충량 확인 +python patch_law_md.py # 저장 +``` + +과거 결함 2건(이 도구로 보정 완료 — 부칙 2,264건·목 2,114줄): +- `부칙단위[:20]` 상한 → 법제처 XML 은 부칙이 **오래된 순**이라 최신 부칙(연도별 요율 + 특례 등)이 통째로 절단됐다. 상한 제거함. +- `목`(가·나·다…)이 `호`가 아니라 `항`의 **직속 자식**으로 오는 구조를 처리 못 해 누락. + 항의 자식을 문서 순서대로 순회하도록 수정함. diff --git a/resources/knowledge/original/_pipeline/collect_law.py b/resources/knowledge/original/_pipeline/collect_law.py index 1634b3c2..8957adf6 100644 --- a/resources/knowledge/original/_pipeline/collect_law.py +++ b/resources/knowledge/original/_pipeline/collect_law.py @@ -189,12 +189,19 @@ def law_to_md(root, 분류, 출처url, 실명=""): hv = clean(T(h, "항내용")) if hv: out.append(hv) - for ho in h.findall("호"): - ov = clean(T(ho, "호내용")) - if ov: - out.append(f" {ov}") - for mo in ho.findall("목"): - mv = clean(" ".join(x for x in (mo.itertext()) if x)) + # 목은 호의 자식이 아니라 항의 직속 자식으로 오는 경우가 있다 + # (법제처 XML 관행). 문서 순서대로 순회해야 호-목 대응이 유지된다. + for kid in h: + if kid.tag == "호": + ov = clean(T(kid, "호내용")) + if ov: + out.append(f" {ov}") + for mo in kid.findall("목"): + mv = clean(T(mo, "목내용")) or clean(" ".join(x for x in mo.itertext() if x)) + if mv: + out.append(f" {mv}") + elif kid.tag == "목": + mv = clean(T(kid, "목내용")) or clean(" ".join(x for x in kid.itertext() if x)) if mv: out.append(f" {mv}") out.append("") @@ -203,6 +210,10 @@ def law_to_md(root, 분류, 출처url, 실명=""): ov = clean(T(ho, "호내용")) if ov: out.append(f" {ov}") + for mo in ho.findall("목"): + mv = clean(T(mo, "목내용")) or clean(" ".join(x for x in mo.itertext() if x)) + if mv: + out.append(f" {mv}") if j.findall("호") and not j.findall("항"): out.append("") @@ -210,8 +221,10 @@ def law_to_md(root, 분류, 출처url, 실명=""): if 부칙 is not None: units = 부칙.findall("부칙단위") if units: + # 법제처 XML은 부칙을 오래된 순으로 담는다 — 상한을 두면 최신 부칙 + # (연도별 요율 특례 등)이 잘려나가므로 전건 출력한다. out += ["", "## 부칙", ""] - for b in units[:20]: + for b in units: body = clean(T(b, "부칙내용")) # 인용블록: 각 줄 앞에 '> ', 원문 줄바꿈 보존(빈 줄은 '>') for ln in body.split("\n"): @@ -347,6 +360,8 @@ def collect(item): f"- 현행 시행일: {ymd(T(cur,'시행일자'))} / 공포 {ymd(T(cur,'공포일자','발령일자'))} 제{T(cur,'공포번호','발령번호')}호", f"- 교본시점 판본: {ymd(past['시행']) if past else '없음(교본 이후 제정)'}", f"- 출처: {BASEURL}/DRF/lawService.do?target={target}&{'MST' if target=='law' else 'ID'}={cur_key}", + # 수집일이 없으면 갱신 지연 여부를 파일만으로 판정할 수 없다 (2026-08-14 감사). + f"- 수집일: {time.strftime('%Y-%m-%d')}", f"- 수집 파일: {', '.join(saved)}" + (f" / 별표 PDF {n별표}건" if n별표 else ""), "", ] diff --git a/resources/knowledge/original/_pipeline/patch_law_md.py b/resources/knowledge/original/_pipeline/patch_law_md.py new file mode 100644 index 00000000..98fcb6ab --- /dev/null +++ b/resources/knowledge/original/_pipeline/patch_law_md.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +"""보관된 법령 XML 기준으로 기존 md 의 누락분만 보충한다 (네트워크 미사용). + +기존 md 에는 수동·스크립트 후처리(박스표 → 마크다운 표, 수식, 이미지)가 +적용돼 있어 전체 재렌더링은 그 결과를 파괴한다. 따라서 **추가만** 한다. + +보충 대상 (2026-08-14 감사에서 확인된 변환 누락 2종): + 1. 부칙 절단 — 과거 collect_law.py 가 `부칙단위[:20]` 만 출력해 최신 부칙이 + 통째로 빠졌다. md 에 없는 부칙단위를 뒤에 이어붙인다. + 2. 목(가·나·다…) 미출력 — 목이 호가 아니라 항의 직속 자식으로 오는 구조를 + 처리하지 못해 누락됐다. 부모 호 줄 뒤에 들여쓰기 4칸으로 삽입한다. + +사용: + python patch_law_md.py --dry # 변경량만 출력 + python patch_law_md.py # 저장 +""" +import re +import sys +import xml.etree.ElementTree as ET + +from collect_law import ROOT, T, clean + +분류목록 = ("법률", "행정규칙") +STEM = re.compile(r"^(현행|교본시점)_\d{8}$") +부칙헤더 = re.compile(r"부칙\s*<[^>]*>") + + +def 부칙키(text): + """'부칙 <제20903호,2025.4.2>' → 정규화 키.""" + m = 부칙헤더.search(text or "") + return re.sub(r"\s+", "", m.group(0)) if m else None + + +def 목텍스트(mo): + return clean(T(mo, "목내용")) or clean(" ".join(x for x in mo.itertext() if x)) + + +def patch(md_path, xml_path): + """(부칙 추가수, 목 추가수, 목 앵커실패수)""" + root = ET.parse(xml_path).getroot() + text = md_path.read_text(encoding="utf-8") + lines = text.splitlines() + 부칙추가, 목추가, 앵커실패 = 0, 0, 0 + + # ── 1) 목 삽입 (뒤에서부터 삽입해 인덱스 밀림 방지) ── + 조문 = root.find("조문") + 작업 = [] + if 조문 is not None: + for j in 조문.findall("조문단위"): + for h in j.findall("항"): + 앵커 = None # 직전 호 내용 + for kid in h: + if kid.tag == "호": + 앵커 = clean(T(kid, "호내용")) + for mo in kid.findall("목"): + 작업.append((앵커, 목텍스트(mo))) + elif kid.tag == "목": + 작업.append((앵커, 목텍스트(kid))) + for ho in j.findall("호"): + 앵커 = clean(T(ho, "호내용")) + for mo in ho.findall("목"): + 작업.append((앵커, 목텍스트(mo))) + + # 앵커별로 묶어 한 번에 삽입 + 묶음 = {} + for 앵커, mv in 작업: + if not mv or mv in text: # 이미 있으면 건너뜀 + continue + if not 앵커: + 앵커실패 += 1 + continue + 묶음.setdefault(앵커, []).append(mv) + + for 앵커, 목들 in 묶음.items(): + idx = next((i for i, l in enumerate(lines) if l.strip() == 앵커.strip()), None) + if idx is None: + idx = next((i for i, l in enumerate(lines) if 앵커.strip() and 앵커.strip() in l), None) + if idx is None: + 앵커실패 += len(목들) + continue + lines[idx + 1:idx + 1] = [f" {mv}" for mv in 목들] + 목추가 += len(목들) + + # ── 2) 부칙 보충 ── + 부칙 = root.find("부칙") + if 부칙 is not None: + units = 부칙.findall("부칙단위") + 본문 = "\n".join(lines) + 기존키 = {re.sub(r"\s+", "", m) for m in 부칙헤더.findall(본문)} + 누락 = [b for b in units if (부칙키(T(b, "부칙내용")) or "") not in 기존키] + if 누락: + if "## 부칙" not in 본문: + lines += ["", "## 부칙", ""] + add = [] + for b in 누락: + body = clean(T(b, "부칙내용")) + if not body.strip(): + continue + for ln in body.split("\n"): + add.append(f"> {ln}" if ln.strip() else ">") + add.append("") + 부칙추가 += 1 + # 첨부파일 절이 뒤에 있으면 그 앞에, 없으면 끝에 + pos = next((i for i, l in enumerate(lines) if l.startswith("## 첨부파일")), len(lines)) + lines[pos:pos] = add + + if 부칙추가 or 목추가: + md_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return 부칙추가, 목추가, 앵커실패 + + +def main(dry=False): + 합계 = [0, 0, 0] + 파일 = 0 + for 분류 in 분류목록: + base = ROOT / 분류 + if not base.is_dir(): + continue + for folder in sorted(p for p in base.iterdir() if p.is_dir()): + for xml_path in sorted(folder.glob("*.xml")): + if not STEM.match(xml_path.stem): + continue + md_path = xml_path.with_suffix(".md") + if not md_path.exists(): + continue + if dry: + orig = md_path.read_text(encoding="utf-8") + b, m, f = patch(md_path, xml_path) + if dry: + md_path.write_text(orig, encoding="utf-8") + if b or m or f: + 파일 += 1 + print(f" {분류}/{folder.name}/{md_path.name}: 부칙+{b} 목+{m}" + + (f" (앵커실패 {f})" if f else "")) + 합계[0] += b + 합계[1] += m + 합계[2] += f + print(f"\n파일 {파일}건 / 부칙 +{합계[0]} · 목 +{합계[1]}" + + (f" · 앵커실패 {합계[2]}" if 합계[2] else "") + + (" (dry-run — 원복함)" if dry else " 저장 완료")) + + +if __name__ == "__main__": + main(dry="--dry" in sys.argv) diff --git a/resources/knowledge/original/_pipeline/split_cost_docs.py b/resources/knowledge/original/_pipeline/split_cost_docs.py index 1b308c56..2e4d6c9a 100644 --- a/resources/knowledge/original/_pipeline/split_cost_docs.py +++ b/resources/knowledge/original/_pipeline/split_cost_docs.py @@ -27,7 +27,7 @@ CAK_DIR = "노임단가_건설업_대한건설협회" CAK_STEM = "2026상반기_건설업_임금실태조사_대한건설협회" PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표 CAK_CH = [("1. 조사개요", 1), ("2. 임금적용요령", 5), ("3. 개별직종 노임단가", 9), ("4. 직종해설", 13)] -CAK_COLS = "| 직종코드 | 직종명 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |" +CAK_COLS = "| 직종코드 | 직종명 | 신뢰도 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |" def parse_cak_table(): @@ -41,13 +41,18 @@ def parse_cak_table(): code, seg = parts[i], parts[i + 1] if not (1 <= int(code[1:]) <= 99): continue + # 원문은 직종번호 앞에 신뢰도 기호를 붙인다: `*`=조사현장 5개 미만, + # `**`=미조사. 값 채택 시 경고로 남겨야 하므로 반드시 보존한다. + flag_m = re.search(r"(\*{1,2})\s*$", parts[i - 1]) + flag = flag_m.group(1) if flag_m else "" slots = re.findall(r"\d{1,3}(?:,\d{3})+|(? PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n" - "> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n\n" + table) + "> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n" + "> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), " + "`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table) (BASE / CAK_DIR / f"{fname}.md").write_text( f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8") print("wrote", fname)