Files
Aislo/resources/knowledge/original/_pipeline/split_cost_docs.py
T
eomsangdon 06640d04ea fix(pipeline): 법령 md 부칙 절단·목 미출력 결함 수정 + 보정 도구 신설
- collect_law.py: 부칙단위[:20] 상한 제거(법제처 XML은 오래된 순 → 최신 부칙
  절단됨), 목이 항의 직속 자식으로 오는 구조 처리(문서 순서 순회), _meta에
  수집일 필드 추가
- split_cost_docs.py: 노임 표 신뢰도 플래그(*/**) 캡처 → 별도 열로 복원
- patch_law_md.py 신설: 보관 XML 기준 누락분만 보충(전량 재생성은 후처리
  파괴하므로 금지). README에 사유·절차 명문화
2026-08-14 20:37:39 +09:00

191 lines
9.4 KiB
Python

# -*- coding: utf-8 -*-
"""원가계산 원문(PDF→md) 후처리: 노임 표 정밀 파싱 + 챕터 분할 (반기·연간 갱신 시 재사용)
전제: `collect_cost_sources.py docs` 로 PDF 수집 → `pdf2md.py <PDF>` 로 md 변환 완료.
사용법:
python split_cost_docs.py cak # 건협 노임: 132직종 표 정밀 파싱 + 4챕터 분할
python split_cost_docs.py kbiz # 중기중앙회 노임: 4챕터 분할 (표는 pdf2md가 정상 변환)
python split_cost_docs.py pumsem # 건설공사 표준품셈: 5부문/45장 분할
주의(갱신 시 점검):
- 건협: 표 페이지 범위(PAGE_TABLE)·챕터 원문 페이지(CAK_CH)가 판마다 다를 수 있음
- 품셈: 부문 표제 라인·목차 구역은 자동 탐지하지만 결과 요약(장 수)이 목차와 일치하는지 확인
- 공통: 원문 PDF 프로즈는 어절 공백이 붙는 특성 있음(값·표는 정상) — W5 공백 기준 예외로 기록
"""
import re
import sys
from pathlib import Path
import pymupdf as fitz
BASE = Path(__file__).resolve().parent.parent / "원가계산"
# ────────────────────────── 건협 노임 ──────────────────────────
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 |"
def parse_cak_table():
"""PDF 텍스트 스트림을 직종코드 앵커로 분할해 132개 직종 표 재구성."""
doc = fitz.open(BASE / CAK_DIR / f"{CAK_STEM}.pdf")
t = "".join(doc[p].get_text() + "\n" for p in range(*PAGE_TABLE))
doc.close()
parts = re.compile(r"(?<![\d,.])([1-5]\d{3})(?![\d,.])").split(t)
result = {}
for i in range(1, len(parts) - 1, 2):
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})+|(?<![\d,])-(?![\d,])", seg)[:4]
first = re.search(r"\d{1,3}(?:,\d{3})+|(?<![\d,])-(?![\d,])", seg)
name = "".join(re.findall(r"[가-힣A-Za-z/]", seg[: first.start()] if first else seg))
if code not in result and len(slots) == 4 and name:
result[code] = (name, slots, flag)
rows = [CAK_COLS, "|---|---|---|---|---|---|---|"]
rows += [f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |"
for c in sorted(result)]
return len(result), "\n".join(rows) + "\n"
def _page_idx(lines, n):
for i, l in enumerate(lines):
if re.fullmatch(rf"-?\s*-\s*{n}\s*-.*", l.strip()) and len(l.strip()) < 40:
return i
return None
def split_cak():
src = BASE / CAK_DIR / f"{CAK_STEM}.md"
lines = src.read_text(encoding="utf-8").splitlines()
cnt, table = parse_cak_table()
print("parsed trades:", cnt)
starts = [(f, _page_idx(lines, p)) for f, p in CAK_CH]
if starts[0][1] is None:
starts[0] = (starts[0][0], 0)
assert all(s[1] is not None for s in starts), starts
hdr = (f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n"
"> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n")
for i, (fname, st) in enumerate(starts):
en = starts[i + 1][1] if i + 1 < len(starts) else len(lines)
body = "\n".join(lines[st:en]).strip()
if "개별직종" in fname:
body = ("## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n"
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 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)
# ────────────────────────── 중기중앙회 노임 ──────────────────────────
KBIZ_DIR = "노임단가_제조업_중소기업중앙회"
KBIZ_STEM = "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회"
KBIZ_CH = [("1. 조사개요", 1), ("2. 조사결과 요약", 12), ("3. 직종별 조사노임", 18),
("4. 직종코드 및 직종명 해설", 30)]
def split_kbiz():
src = BASE / KBIZ_DIR / f"{KBIZ_STEM}.md"
lines = src.read_text(encoding="utf-8").splitlines()
starts = [(f, _page_idx(lines, p)) for f, p in KBIZ_CH]
if starts[0][1] is None:
starts[0] = (starts[0][0], 0)
assert all(s[1] is not None for s in starts), starts
hdr = (f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n"
"> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n")
for i, (fname, st) in enumerate(starts):
en = starts[i + 1][1] if i + 1 < len(starts) else len(lines)
body = "\n".join(lines[st:en]).strip()
(BASE / KBIZ_DIR / f"{fname}.md").write_text(
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8")
print("wrote", fname)
# ────────────────────────── 건설공사 표준품셈 ──────────────────────────
PUM_DIR = "건설공사_표준품셈"
PUM_STEM = "2026년_건설공사_표준품셈"
def split_pumsem():
lines = (BASE / PUM_DIR / f"{PUM_STEM}.md").read_text(encoding="utf-8").splitlines()
# 부문 표제 위치 자동 탐지 (짧은 단독 라인)
sec_names = [("01_공통부문", "공통부문"), ("02_토목부문", "토목부문"), ("03_건축부문", "건축부문"),
("04_기계설비부문", "기계설비부문"), ("05_유지관리부문", "유지관리부문")]
hits = {}
for i, l in enumerate(lines):
s = re.sub(r"[\s#>\-·ㆍ]", "", l)
for sec, kr in sec_names:
if s == kr:
hits.setdefault(sec, []).append(i)
toc_marks = [hits[s][0] for s, _ in sec_names if s in hits]
body_marks = [hits[s][-1] for s, _ in sec_names if s in hits]
toc_zone_end = body_marks[0]
# 목차에서 장 이름 추출
chapter_names = {}
for k, (sec, kr) in enumerate(sec_names):
a = toc_marks[k]
b = toc_marks[k + 1] if k + 1 < len(toc_marks) else toc_zone_end
names = {}
for i in range(a, b):
s = re.sub(r"\s", "", lines[i])
for m in re.finditer(r"제(\d{1,2})장([가-힣·()]+?)(?=\d)", s):
names.setdefault(int(m.group(1)), m.group(2))
chapter_names[sec] = names
# 본문 분할: 장 표제(정방향/역방향 병기) 탐색
for k, (sec, kr) in enumerate(sec_names):
a = body_marks[k]
b = body_marks[k + 1] if k + 1 < len(body_marks) else len(lines)
names = chapter_names[sec]
starts, prev = [], a
for n in sorted(names):
nm = names[n]
li = a if n == 1 else None
if li is None:
keys = [f"제{n}{nm[:4]}", f"{nm[:4]}{n}장"]
for i in range(prev + 1, b):
s = re.sub(r"\s", "", lines[i])
if any(kx in s for kx in keys) and len(re.findall(r"제\d{1,2}장", s)) == 1:
li = i
break
if li is None:
print(f"⚠ {sec}{n}{nm} 경계 미검출 — 수동 확인 필요")
continue
starts.append((n, nm, li))
prev = li
outdir = BASE / PUM_DIR / sec
outdir.mkdir(exist_ok=True)
for j, (n, nm, st) in enumerate(starts):
en = starts[j + 1][2] if j + 1 < len(starts) else b
hdr = (f"# {sec[3:]}{n}{nm}\n\n"
f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n"
"> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n")
(outdir / f"제{n}장_{nm}.md").write_text(
hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8")
print(sec, f"{len(starts)}/{len(names)} 장")
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else ""
if mode == "cak":
split_cak()
elif mode == "kbiz":
split_kbiz()
elif mode == "pumsem":
split_pumsem()
else:
sys.exit("mode: cak | kbiz | pumsem")