Files
Aislo/resources/knowledge/original/_pipeline/gen_md.py
T

518 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""참조 법령/기준 목록 md — §5 '원문 md 색인' 절만 갱신 (AUTO:INDEX 마커 스플라이스).
⚠ 잔여 코드 안내 (2026-08-15): 아래에는 구 버전(문서 전체 생성)의 §1~§4 등록표·KS 표
조립 코드가 그대로 남아 실행되지만, 저장 단계에서 §5 블록만 추출하므로 산출물에는
쓰이지 않는다 (등록표는 04 문서에서 수동 관리로 전환됨). 기능 문제 없음 — 정리는 선택.
"""
import re, json
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m:
return m.group(1)
return ""
from collections import defaultdict, OrderedDict
BASE = Path(str(ROOT_DIR / "임도기술교본"))
# 운영본 위치 = knowledge 루트 (구경로 original/0. 참조 법령·기준 목록.md — 2026-08-15 정정)
OUTMD = Path(str(ROOT_DIR.parent / "04_참조_법령기준_목록.md"))
SC = Path(str(DATA_DIR))
def norm(s):
s = s.replace("<br>", "").replace("ㆍ", "·")
return re.sub(r"[\s\u00b7\u2027]", "", s)
from gen_md_entries import ENTRIES
files = [p for p in sorted(BASE.rglob("*.md")) if not str(p.relative_to(BASE)).startswith("원문")]
# ── KS 규격은 코드 패턴으로 자동 전수 추출 ──
KSPAT = re.compile(r"KS\s*([A-Z])\s*(\d{4})(.*)$")
BADTOK = (
"규정",
"적합",
"따른",
"따라",
"사용",
"한다",
"표준에",
"부속서",
"규격",
"품질",
"또는",
"이상",
"맞는",
"준하",
)
ks_hits = defaultdict(lambda: OrderedDict())
ks_title = {}
for p in files:
rel = str(p.relative_to(BASE)).replace("\\", "/")
for i, ln in enumerate(p.read_text(encoding="utf-8").split("\n"), 1):
for m in re.finditer(r"KS\s*([A-Z])\s*(\d{4})", ln):
code = f"KS {m.group(1)} {m.group(2)}"
if i not in ks_hits[code].setdefault(rel, []):
ks_hits[code][rel].append(i)
rest = ln[m.end() :].strip()
rest = re.split(r"\s*[|,]", rest)[0].strip()
if rest.startswith("(") and ")" in rest[:60]:
cand = rest[1 : rest.find(")")].strip()
else:
t = re.match(r"^([가-힣][^|\n]{2,58})$", rest)
if not t:
continue
cand = t.group(1).rstrip(" .").rstrip("(")
if not cand or cand[0] in "에의를을은는과와도만":
continue
if any(b in cand for b in BADTOK):
continue
if cand.count("(") > cand.count(")"):
cand += ")"
if len(cand) > len(ks_title.get(code, "")):
ks_title[code] = cand
hits = defaultdict(lambda: OrderedDict())
for p in files:
rel = str(p.relative_to(BASE)).replace("\\", "/")
lines = p.read_text(encoding="utf-8").split("\n")
for i, ln in enumerate(lines, 1):
n = norm(re.sub(r"^#{1,6}\s+", "", ln))
if not n:
continue
for cat, org, name, note, pats in ENTRIES:
if any(re.search(pt, n) for pt in pats):
hits[name].setdefault(rel, []).append(i)
NUM = re.compile(r"^([0-9][0-9\-]*)\.?\s")
def fmtloc(d):
out = []
for rel, ii in d.items():
chap, fn = rel.split("/", 1)
cno = NUM.match(chap).group(1)
fno = NUM.match(fn).group(1)
links = [f"[{cno}/{fno}:{n}](<임도기술교본/{rel}#L{n}>)" for n in ii[:5]]
if len(ii) > 5:
links.append(f"…(+{len(ii) - 5})")
out.append(" ".join(links))
return "<br>".join(out)
def loc(name):
d = hits.get(name)
return fmtloc(d) if d else "—"
def cnt(d):
return sum(len(v) for v in d.values())
CAT_TITLE = OrderedDict(
[
("법률", "1. 법률·시행령·시행규칙"),
("행정규칙", "2. 행정규칙 (훈령·예규·고시·지침)"),
("표준시방서", "3. 표준시방서"),
]
)
SRC = json.load(open(SC / "srcmap.json", encoding="utf-8"))
GOT = json.load(open(SC / "collected_map.json", encoding="utf-8"))
KSMAP = json.load(open(SC / "ks_map.json", encoding="utf-8"))
KCSC = json.load(open(SC / "kcsc_map.json", encoding="utf-8"))
def ks_code_of(nm):
m = re.match(r"^(KS [A-Z] \d{4})", nm)
return m.group(1) if m else None
def _entry_target(folder_rel):
"""폴더의 _index.md(개별 리스트)로 연결. 없으면 현행_*.md, 그것도 없으면 폴더."""
d = ROOT_DIR / folder_rel
if (d / "_index.md").exists():
return f"{folder_rel}/_index.md"
cur = sorted(d.glob("현행_*.md"))
if cur:
return f"{folder_rel}/{cur[-1].name}"
return f"{folder_rel}/"
def linked(name):
g = GOT.get(name)
if g:
return f"[{name}](<{_entry_target(g['path'])}>)"
if name in KCSC:
return f"[{name}](<{_entry_target('표준시방서/' + KCSC[name][0])}>)"
if name in HWP_ONLY:
return f"[{name}](<{_entry_target('표준시방서/' + HWP_ONLY[name])}>)"
return name
HWP_ONLY = {"임도시설공사 표준시방서": "임도시설공사 표준시방서"}
def status(name):
if GOT.get(name) or name in KCSC or name in HWP_ONLY:
return "■"
return "□"
def org_cell(name, org):
# 실제로 내려받아 확인된 출처만 표기. 미수집 건은 출처를 적지 않는다.
if GOT.get(name):
src = "국가법령정보센터" + ("·국가건설기준센터" if name in KCSC else "")
return f"{org}<br>{src}"
if name in KCSC:
return f"{org}<br>국가건설기준센터"
return org
def got_note(name):
g = GOT.get(name)
if not g:
if name in KCSC:
return f"KCS본문 {KCSC[name][1]}건 (국가건설기준센터)"
if name in HWP_ONLY:
return "원본 HWP(2016.12) 확보 · 본문은 교본 15.4와 동일"
return ""
if g.get("별표참조"):
return f"시행규칙 {g['별표참조']} PDF로 확보"
v = [x.split(":")[0] for x in g.get("판본", [])]
bits = []
if "교본시점" in v:
bits.append("현행+교본시점")
else:
bits.append("현행만")
if g.get("별표"):
bits.append(f"별표{g['별표']}")
if g.get("개정"):
bits.append(f"교본후 개정{g['개정']}회")
if name in KCSC:
bits.append(f"KCS본문 {KCSC[name][1]}건")
return " · ".join(bits)
DL = {
"법률": "**다운로드**: [국가법령정보센터](https://www.law.go.kr) — 36건 전건 수집 완료.",
"행정규칙": "**다운로드**: [국가법령정보센터](https://www.law.go.kr) — 21건 중 15건 수집 완료. 나머지 6건은 법령정보센터 검색 결과 없음(다른 확보처 미확인).",
"표준시방서": "**다운로드**: 고시문 [국가법령정보센터](https://www.law.go.kr) + KCS 본문 [국가건설기준센터](https://kcsc.re.kr) OpenApi. KCS 4종(콘크리트·도로·공통·비탈면) 본문 83개 코드 수집. 임도시설공사(산림청) 미수집.",
}
def dates(name):
d = SRC.get(name)
if not d:
return "—", "—"
return (d.get("시행") or "—"), (d.get("개정") or "—")
def note_of(name, base):
d = SRC.get(name) or {}
parts = [x for x in (base, d.get("추가", "")) if x]
return " / ".join(parts)
import unicodedata
LINKRE = re.compile(r"\[([^\]]*)\]\(<[^>]*>\)")
def _w(t):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in t)
def dw(t):
t = LINKRE.sub(lambda m: m.group(1), t)
return max(_w(x) for x in t.split("<br>")) if t else 0
def pad(t, w, align="l"):
n = max(0, w - dw(t))
if align == "r":
return " " * n + t
if align == "c":
return " " * (n // 2) + t + " " * (n - n // 2)
return t + " " * n
COLS = [
("No.", 3, "r"),
("명칭", 56, "l"),
("시행일", 10, "c"),
("개정일", 10, "c"),
("소관", 20, "l"),
("비고", 56, "l"),
("인용", 4, "r"),
("확보", 4, "c"),
("교본 내 위치", 0, "l"),
]
def row(vals):
out = []
for (h, w, a), v in zip(COLS, vals):
v = str(v)
out.append(pad(v, w, a) if w else v)
return "| " + " | ".join(out) + " |"
HDR = row([c[0] for c in COLS])
SEP = (
"|"
+ "|".join(
(":" + "-" * max(1, w) + ":")
if a == "c"
else ("-" * max(1, w + 1) + ":")
if a == "r"
else ("-" * max(1, w + 2))
if w
else "---"
for h, w, a in COLS
)
+ "|"
)
L = []
L.append("# 0. 참조 법령·기준 목록")
L.append("")
L.append(f"> 출처: `임도기술교본/` 전 문서({len(files)}개 md, `원문/` 제외) 전수 스캔")
L.append(
"> 위치 표기: `폴더no/파일no:줄번호` 링크 — 클릭 시 교본 해당 줄로 이동 (파일당 5개까지, 초과분은 `…(+n)`)"
)
L.append("> `인용` 열은 교본 내 언급 횟수(줄 단위 집계)")
L.append("> `소관` 열 둘째 줄은 실제로 내려받은 출처 (미수집 건은 출처 미표기 — 확인되지 않음)")
L.append(
"> `확보` ■ = 원문 수집 완료 (명칭 클릭 시 폴더로 이동) / ◐ = 메타데이터만(원문 DRM) / □ = 미수집"
)
L.append(
"> `시행일`/`개정일`은 **현행본** 기준 (국가법령정보센터 조회, 2026-08 확인). 교본이 인용한 2019년 판본과 다르면 `비고`에 표기"
)
L.append("> `확보` 대상은 현행본. 교본 대조가 필요하면 연혁본을 함께 받을 것")
L.append(
"> 4개 표 모두 동일한 9열 구조 · 동일 열 폭 (`No. / 명칭 / 시행일 / 개정일 / 소관 / 비고 / 인용 / 확보 / 교본 내 위치`)"
)
L.append("")
total = 0
for cat, title in CAT_TITLE.items():
rows = [e for e in ENTRIES if e[0] == cat and e[2] in hits]
L.append(f"## {title}")
L.append("")
L.append(DL[cat])
L.append("")
L.append(HDR)
L.append(SEP)
for k, (c, org, name, note, pats) in enumerate(rows, 1):
total += 1
ef, am = dates(name)
nt = " / ".join(x for x in (note_of(name, note), got_note(name)) if x)
L.append(
row(
[
k,
linked(name),
ef,
am,
org_cell(name, org),
nt,
cnt(hits[name]),
status(name),
loc(name),
]
)
)
L.append("")
n_ks_active = sum(1 for c in ks_hits if KSMAP.get(c, {}).get("상태") != "폐지")
n_ks_repeal = sum(1 for c in ks_hits if KSMAP.get(c, {}).get("상태") == "폐지")
L.append("## 4. 한국산업표준 (KS)")
L.append("")
L.append(
f"**메타데이터**: [e나라 표준인증](https://www.standard.go.kr) — 45건 전건 조회 완료(표준명·제·개정일·적용범위·개정이력). 명칭 클릭 시 `KS/` 폴더로 이동."
)
L.append("**원문**: MarkAny DRM 뷰어 열람 전용이라 파일 다운로드 경로 없음. **전건 원문 미수집.**")
L.append(
f"**상태**: 현행 {n_ks_active} · 폐지 {n_ks_repeal} (교본 인용 규격 중 폐지분은 교본 갱신 시 반영 필요)."
)
L.append("")
L.append(
"> `확보` ◐ = 메타데이터만 수집(원문 DRM). `시행일`=제정일 / `개정일`=최종개정확인일. 규격명은 e나라 공식 표준명."
)
L.append("")
L.append(HDR)
L.append(SEP)
for k, code in enumerate(sorted(ks_hits, key=lambda c: (c.split()[1], c.split()[2])), 1):
total += 1
km = KSMAP.get(code, {})
t = km.get("표준명") or ks_title.get(code, "")
nm = f"{code} {t}".strip()
linked_nm = f"[{nm}](<KS/{code}.md>)" if km else nm
ef = re.sub(r"-", ".", km.get("제정일", "")) or "—"
am = re.sub(r"-", ".", km.get("최종개정확인일", "")) or "—"
st = km.get("상태", "")
note = "**폐지**" if st == "폐지" else ("" if t else "교본은 규격번호만 인용")
mark = "◐" if km else "□"
L.append(
row(
[
k,
linked_nm,
ef,
am,
"산업통상자원부",
note,
cnt(ks_hits[code]),
mark,
fmtloc(ks_hits[code]),
]
)
)
L.append("")
miss = [e[2] for e in ENTRIES if e[2] not in hits]
if miss:
L.append("## 미확인 (스캔 무매칭)")
L.append("")
for m in miss:
L.append(f"- {m}")
L.append("")
# ── 5. 원문 md 색인 (법령 본문 + 별표 전체) ──
# ORIG = 원문 코퍼스 루트(…/original). ⚠ OUTMD.parent 사용 금지 — OUTMD가 knowledge
# 루트로 이동(2026-08-15)한 뒤 OUTMD.parent는 원문 폴더가 아니다 (빈 색인 생성 버그 원인).
ORIG = ROOT_DIR
CATDIR = [
("법률", "법률"),
("행정규칙", "행정규칙"),
("표준시방서", "표준시방서"),
("원가계산", "원가계산"), # 품셈·노임·단가 원문 (2026-09-18 산림 표준품셈 이관)
]
L.append("## 5. 원문 md 색인")
L.append("")
L.append("> 수집한 원문 전체의 마크다운 변환본. 법령 본문(현행·교본시점) + 별표/서식 전건.")
L.append("> 별표 md는 같은 폴더의 PDF를 변환한 것으로, 표는 마크다운 표로 복원됨.")
L.append("")
n_law = n_byl = n_att = n_kcs = 0
n_zip = [0]
for cat, d in CATDIR:
base = ORIG / d
if not base.is_dir():
continue
L.append(f"### {cat}")
L.append("")
for folder in sorted([x for x in base.iterdir() if x.is_dir()], key=lambda x: x.name):
if not any(folder.rglob("*.md")): # 그림만 든 폴더(원가계산/pic) 제외
continue
rel = f"original/{d}/{folder.name}" # 링크 기준 = knowledge 루트(OUTMD 위치)
docs = []
for f in sorted(folder.glob("*.md")):
label = {"_meta": "메타", "CHANGELOG": "변경이력"}.get(f.stem, f.stem.replace("_", " "))
docs.append(f"[{label}](<{rel}/{f.name}>)")
if f.stem not in ("_meta", "CHANGELOG"):
n_law += 1
idx_link = f"[📑 개별 목록](<{rel}/_index.md>) " if (folder / "_index.md").exists() else ""
L.append(
f"- **{folder.name}** — {idx_link}"
+ (" · ".join(docs) if docs else "_(원문 폴더 없음, 하위 자료만)_")
)
byl = sorted((folder / "별표").glob("*.md")) if (folder / "별표").is_dir() else []
if byl:
n_byl += len(byl)
links = []
for f in byl:
nm = f.stem
num, _, rest = nm.partition("_")
links.append(f"[{num}](<{rel}/별표/{f.name}>)")
L.append(f" - 별표·서식 {len(byl)}건: " + " · ".join(links))
att = sorted((folder / "첨부").glob("*.md")) if (folder / "첨부").is_dir() else []
if att:
n_att += len(att)
L.append(
f" - 첨부 본문 {len(att)}건: "
+ " · ".join(f"[{f.stem[:24]}](<{rel}/첨부/{f.name}>)" for f in att)
)
# 첨부 zip 해제 폴더([압축] …)
zdirs = sorted((folder / "첨부").glob("[[]압축[]]*")) if (folder / "첨부").is_dir() else []
for zd in zdirs:
zmds = [p for p in zd.rglob("*.md") if p.name != "_목록.md"]
n_zip[0] += len(zmds)
idx = zd / "_목록.md"
link = f"[_목록](<{rel}/첨부/{zd.name}/_목록.md>)" if idx.exists() else zd.name
L.append(f" - 압축해제 「{zd.name[4:].strip()}」 {len(zmds)}건: {link}")
kcs = sorted((folder / "KCS").glob("*.md")) if (folder / "KCS").is_dir() else []
kcs = [f for f in kcs if f.stem != "_목록"]
if kcs:
n_kcs += len(kcs)
L.append(
f" - KCS 본문 {len(kcs)}건: [_목록](<{rel}/KCS/_목록.md>) · "
+ " · ".join(f"[{f.stem.split('_')[0]}](<{rel}/KCS/{f.name}>)" for f in kcs[:8])
+ (f" …외 {len(kcs) - 8}건" if len(kcs) > 8 else "")
)
L.append("")
L.append("### KS (메타데이터)")
L.append("")
ksdir = sorted((ORIG / "KS").glob("*.md")) if (ORIG / "KS").is_dir() else []
if ksdir:
L.append("- " + " · ".join(f"[{f.stem}](<original/KS/{f.name}>)" for f in ksdir))
L.append("")
L.append(
f"> 법령 본문 md {n_law} · 별표/서식 md {n_byl} · 첨부 본문 md {n_att} · 압축해제 md {n_zip[0]} · KCS 본문 md {n_kcs} · KS 메타 md {len(ksdir)}"
)
L.append("")
L.append("---")
L.append("")
L.append(f"- 총 {total}건 (법률 36 / 행정규칙 21 / 표준시방서 6 / KS 45)")
L.append(f"- 스캔 대상: `임도기술교본/` 하위 md {len(files)}개 (`원문/` 제외)")
L.append(
"- 스캔 기준: 정식명칭 및 약칭(산림자원법, 국가계약법, 지방계약법, 임도규정 등) 정규식 매칭, 공백·`ㆍ`·`<br>` 정규화 후 대조"
)
L.append("- 교본 기준 시점: 2019년 (고시·요율은 당시 개정본 기준. 원문 수집 시 현행본 병기 필요)")
L.append("- 시행일/개정일 조회: 국가법령정보센터 Open API (`law.go.kr/DRF`), 2026-08 기준 현행본")
# ── 쓰기: 전체 덮어쓰기 금지 — '원문 md 색인' 절만 마커 사이에 스플라이스 ──
# 배경(2026-08-15): 04 목록의 등록표(§1~§4)는 수기 추가 행(사방 고시·조달수수료 등)이
# 축적된 수동 관리 문서가 됐다. 전체 재생성하면 수기 행이 소실되므로, 이 스크립트는
# 자동화 가치가 큰 '원문 md 색인' 절만 AUTO 마커 블록 안에서 재생성한다.
MARK_BEGIN = "<!-- AUTO:INDEX:BEGIN — gen_md.py가 재생성하는 블록. 수동 편집 금지 -->"
MARK_END = "<!-- AUTO:INDEX:END -->"
idx_start = L.index("## 5. 원문 md 색인")
idx_end = next(i for i, ln in enumerate(L) if ln.startswith("> 법령 본문 md "))
auto_block = "\n".join([MARK_BEGIN] + L[idx_start : idx_end + 1] + [MARK_END])
if not OUTMD.exists():
raise SystemExit(f"오류: {OUTMD} 없음 — 이 스크립트는 색인 절만 갱신하며 신규 생성하지 않는다.")
cur = OUTMD.read_text(encoding="utf-8")
if MARK_BEGIN not in cur or MARK_END not in cur:
raise SystemExit(
"오류: AUTO:INDEX 마커 없음 — 04 문서에 마커 블록을 먼저 넣어야 한다 "
"('## 5. 원문 md 색인' 절을 MARK_BEGIN/END로 감싼다)."
)
pre = cur.split(MARK_BEGIN)[0]
post = cur.split(MARK_END, 1)[1]
OUTMD.write_text(pre + auto_block + post, encoding="utf-8")
print(f"색인 절 갱신 완료: {OUTMD.name} (등록표 §1~§4·큐레이션 절은 수동 관리 — 보존됨)")