"""원문 md ↔ 공종 마스터 맞대기 — 2026-09-17 브레인 일감(랩탑 메인). 안티그래비티가 손으로 하던 대조를 시험으로 옮김. 원문은 `(산림청고시 제2025-82호) 산림사업 표준품셈.md` 한 벌, 마스터는 `pum_forest_2026.json`(표 원본)과 `work_item_master_2026-01-01.json`(공종 축). - **어긋남이 0 이어야 하는 것** — 표 수·차례 · 셀 값(글자까지). 하나라도 다르면 빨강. - **알려진 어긋남이 있는 것** — 표 자리(부록 표가 공종에 붙음) · 밑수 · [주] · 표 성격. 지금 어긋난 것은 `fixtures/work_item_master_source_known.json` 에 적어 두고, **그 목록 밖**에서 새로 어긋나면 빨강. ⚠ 목록에 있는데 **고쳐졌거나 모습이 바뀐 것도 빨강** — 목록이 낡지 않게. 고친 뒤 목록 지우기: `python resources/tester/test_work_item_master_source_match.py --prune` (지우기만 함 · 새 어긋남은 안 보탬 — 새 것은 원인을 보고 사람이 적을 것). ⚠ **빌더 코드를 안 씀** — 원문 읽기를 여기서 따로 함. 같은 코드로 재면 같은 틀림을 못 잡음. """ from __future__ import annotations import json import re import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] MD = ( ROOT / "resources/knowledge/original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/첨부" / "(산림청고시 제2025-82호) 산림사업 표준품셈.md" ) PUM = ROOT / "resources/data_cost_input_value/pum_forest_2026.json" MASTER = ROOT / "resources/data_work_item_master/work_item_master_2026-01-01.json" KNOWN = Path(__file__).resolve().parent / "fixtures/work_item_master_source_known.json" #: 목차 표 — 공종 줄이 아니라 목차를 세우는 데 쓰임. TOC_TABLE = "F0001" #: 이 줄 뒤는 부록(할인·할증 공종표 · 적용 공종 · 단가산출서 예시) — 공종 품이 아니라 `orphan_tables` 몫. APPENDIX = "〔부록 1〕" # ── 원문 읽기 ──────────────────────────────────────────────────────────── _SEP = re.compile(r"\s*\|(\s*:?-+:?\s*\|)+\s*") def _cells(line: str) -> list[str]: s = line.strip() s = s[1:-1] if s.endswith("|") else s[1:] return [c.strip() for c in s.split("|")] def md_tables(lines: list[str]) -> list[dict]: """`|` 표 = 머리 줄 + `|---|` 줄 + 몸 줄. 나온 차례대로 F0001… (줄 번호는 1부터).""" tables: list[dict] = [] i = 0 while i < len(lines): if ( lines[i].lstrip().startswith("|") and i + 1 < len(lines) and _SEP.fullmatch(lines[i + 1]) ): j = i + 2 while j < len(lines) and lines[j].lstrip().startswith("|"): j += 1 tables.append( { "id": f"F{len(tables) + 1:04d}", "line": i + 1, "end": j, # 표 다음 줄의 0부터 번호 "headers": _cells(lines[i]), "rows": [_cells(x) for x in lines[i + 2 : j]], } ) i = j else: i += 1 return tables #: 밑수가 될 수 있는 세는 단위. 「회」「%」「분」은 밑수가 아님(2026-09-17 데스크탑 서브 원문 확인). _UNIT = "㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공㎥|공|kg|㎏|톤|ton|주|식|대|일|기|조|롤|RM|시간|hr" _SAME = {"㏊": "ha", "㎞": "km", "m3": "㎥", "m2": "㎡", "㎏": "kg", "ton": "톤"} _ONE = re.compile(rf"^([\d.]*)\s*({_UNIT})$") _PAREN_END = re.compile(r"[((]([^()()]*)[))]+\s*$") _PAREN_ANY = re.compile(r"[((]([^()()]*)[))]") def parse_basis(text: str) -> tuple[float, str] | str | None: """괄호 속 글 하나 → `(수, 단위)` · 분모가 둘이면 `"둘"` · 밑수 글이 아니면 `None`. - `인/100㎡` · `단위 : 인/ha` → 분모가 밑수 · `100본당` · `일 당` → 「당」 앞이 밑수 - `ha당 소요량` · `1km 소요량기준` → 「소요량」 앞이 밑수 - ⚠ 「인」「공」은 숫자가 붙을 때만(`(단위 : 인)` 은 품의 단위 — 2026-09-09 원문 대조) """ t = re.sub(r"^\s*단\s*위\s*[::]?\s*", "", text).strip() t = re.sub(r"(\d),(\d{3})", r"\1\2", t) t = re.sub(r"\s*당?\s*소요량\s*(기준)?$", "당", t) if "/" in t: denominators = t.split("/")[1:] den = re.sub(r"\s*당$", "", denominators[-1]).strip() if len(denominators) > 1 or re.search(r"[,,·\s]", den): return "둘" elif t.endswith("당"): den = t[:-1].strip() else: return None m = _ONE.match(den) if m is None: return None raw, unit = m.group(1), m.group(2) if unit in ("인", "공") and not raw: return None try: return (float(raw) if raw else 1.0), _SAME.get(unit, unit) except ValueError: return None def md_basis(lines: list[str], table: dict) -> tuple[tuple[float, str] | None, str]: """`(밑수, 근거 글)`. 표 바로 위 본문 → 표머리 차례(본문이 정본). 못 찾으면 `(None, "")`. 본문은 가까운 줄부터 올라가며 줄 끝 괄호를 봄 — 앞 표·`[주]` 를 만나면 멈추고 제목 줄은 보고 멈춤. 표머리는 칸 속 괄호와 칸 전체를 봄 — 서로 다른 밑수가 둘 이상이면 못 정함. """ for k in range(table["line"] - 2, -1, -1): s = lines[k].strip() if s.startswith("|") or s.startswith("[주"): break if (m := _PAREN_END.search(s)) and isinstance(hit := parse_basis(m.group(1)), tuple): return hit, s if s.startswith("#"): break found: dict[tuple[float, str], str] = {} for head in table["headers"]: for text in [m.group(1) for m in _PAREN_ANY.finditer(head)] + [head]: if isinstance(hit := parse_basis(text), tuple): found.setdefault(hit, head) if len(found) == 1: ((hit, head),) = found.items() return hit, f"표머리 {head}" return None, "" _NOTE_CONT = re.compile(r"^(-\s*[①-⑳]|-\s*-|[※*·])") def md_notes(lines: list[str], table: dict) -> list[str]: """표 바로 아래 `[주]` 덩어리. 첫 글줄이 `[주` 가 아니면 없음. 이어지는 줄 = `- ②` 꼴 · `- -` 꼴 · `※`·`*`·`·` 로 시작하는 줄. 빈 줄은 건너뜀. 그 밖의 줄에서 끝. ⚠ `- 2. 소제목` 같은 본문 목록은 [주]가 아님. """ out: list[str] = [] for k in range(table["end"], len(lines)): s = lines[k].strip() if not s: continue if not out and s.startswith("[주"): out.append(s) elif out and _NOTE_CONT.match(s): out.append(s) else: break return out _WORKER = re.compile( r"인\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|" r"용접공|미장공|방수공|배관공|비계공" ) _WORK_MARK = re.compile( r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|인/|/인|\(시간\)|\(hr\)" ) _QTY_UNIT = {"인", "매", "개", "본", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "대", "톤", "hr", "시간"} #: 숫자 칸 — `0.16` · `1.5인` · `0.5ℓ` · 한 칸에 여러 값 `0.04 0.06 0.10`. _NUMBER = re.compile(r"^\d[\d,]*(\.\d+)?(\s*(인|ℓ|시간|㎥|개|본|매|kg|m|%))?(\s+[-\d,.]+)*$") def md_work_mark(table: dict) -> str | None: """품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. ① 표 어딘가에 **짧은 칸(20자 이하)** 으로 직종 이름·품 낱말(`소요인력`·`(인)`·`작업능력` …)이 있고 숫자 칸도 있음 · ② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음. 긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸). ponytail: 품 표 317 중 276 만 알아봄 — 값이 범위(`3.3∼5.9`)거나 머리가 여러 줄로 갈린 표는 못 봄. """ rows = [table["headers"], *table["rows"]] cells = [c for row in rows for c in row] if any(_NUMBER.match(c) for c in cells): for c in cells: if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c)): return c for row in rows: unit = next((c for c in row if c in _QTY_UNIT), None) if unit and any(_NUMBER.match(c) for c in row): return unit return None # ── 맞대기 ────────────────────────────────────────────────────────────── def _norm(text: str) -> str: return " ".join(str(text).split()) @pytest.fixture(scope="module") def src() -> dict: return load_sources() def load_sources() -> dict: lines = MD.read_text(encoding="utf-8").splitlines() pum = json.loads(PUM.read_text(encoding="utf-8"))["variables"]["pum"]["tables"] master = json.loads(MASTER.read_text(encoding="utf-8")) entries = [(w, t) for w in master["work_items"] for t in w.get("tables", [])] return { "lines": lines, "tables": md_tables(lines), "pum": pum, "master": master, "entries": entries, "by_id": {t["pum_table_id"]: (w, t) for w, t in entries}, } def current_mismatches(src: dict) -> dict[str, dict]: """표 자리·밑수·[주]·표 성격 어긋남 — 알려진 목록과 같은 꼴. 밑수·[주]·성격은 공종에 붙은 표만 봄.""" lines = src["lines"] out: dict[str, dict] = {"place": {}, "basis": {}, "notes": {}, "form": {}} appendix = next(i for i, x in enumerate(lines, 1) if x.strip().startswith(APPENDIX)) orphans = {o["pum_table_id"] for o in src["master"]["orphan_tables"]} for table in src["tables"]: in_appendix = table["line"] > appendix if table["id"] in orphans and not in_appendix: out["place"][table["id"]] = { "section": None, "md": "본문 표", "master": "공종에 안 붙음", } if table["id"] not in src["by_id"]: continue if in_appendix: out["place"][table["id"]] = { "section": src["by_id"][table["id"]][0]["number"], "md": "부록 표", "master": "공종에 붙음", } work, entry = src["by_id"][table["id"]] section = work["number"] md_hit, evidence = md_basis(lines, table) md_value = list(md_hit) if md_hit else None unit = entry["basis_unit"] master_value = [entry["basis_quantity"], _SAME.get(unit, unit)] if unit else None if md_value != master_value: out["basis"][table["id"]] = { "section": section, "md": md_value, "md_evidence": evidence, "master": master_value, } md_note = md_notes(lines, table) if md_note != entry["notes"]: out["notes"][table["id"]] = { "section": section, "md_only": [x for x in md_note if x not in entry["notes"]], "master_only": [x for x in entry["notes"] if x not in md_note], } if entry["pum_form"] in ("reference", "coefficient") and (mark := md_work_mark(table)): out["form"][table["id"]] = { "section": section, "master": entry["pum_form"], "md_mark": mark, } return out def load_known() -> dict[str, dict]: return json.loads(KNOWN.read_text(encoding="utf-8")) # ── 시험 ──────────────────────────────────────────────────────────────── def test_표_수와_자리(src: dict) -> None: """원문 표가 원본(`pum_forest`)에 같은 차례·같은 줄로 있고, 마스터에 빠짐없이 한 번씩 있음.""" md_ids = [(t["id"], t["line"]) for t in src["tables"]] pum_ids = [(t["table_id"], t["line"]) for t in src["pum"]] assert len(md_ids) == len(pum_ids), f"표 수 — 원문 {len(md_ids)} · 원본 {len(pum_ids)}" moved = [(a, b) for a, b in zip(md_ids, pum_ids) if a != b] assert not moved, f"표 자리 어긋남 {len(moved)}건 (원문, 원본) — 앞 5: {moved[:5]}" attached = [t["pum_table_id"] for _, t in src["entries"]] doubled = sorted({x for x in attached if attached.count(x) > 1}) assert not doubled, f"두 공종에 붙은 표: {doubled}" orphans = [o["pum_table_id"] for o in src["master"]["orphan_tables"]] all_ids = {i for i, _ in md_ids} missing = sorted(all_ids - set(attached) - set(orphans) - {TOC_TABLE}) assert not missing, f"마스터에 없는 원문 표: {missing}" extra = sorted((set(attached) | set(orphans)) - all_ids) assert not extra, f"원문에 없는 마스터 표: {extra}" def test_셀_값이_글자까지_같음(src: dict) -> None: """표머리·줄 칸을 글자까지 맞댐 — 원문 ↔ 원본(`headers`·`rows`) ↔ 마스터(`condition_note`·`raw_row`).""" diffs: list[str] = [] def compare(tid: str, line: int, what: str, md_rows: list, other: list) -> None: if len(md_rows) != len(other): diffs.append(f"{tid} L{line} {what} 줄 수 — 원문 {len(md_rows)} · {len(other)}") for r, (a, b) in enumerate(zip(md_rows, other)): for c in range(max(len(a), len(b))): va = a[c] if c < len(a) else "(없음)" vb = b[c] if c < len(b) else "(없음)" if va != vb: diffs.append( f"{tid} L{line + 2 + r} {what} {r + 1}행 {c + 1}열 — 원문 {va!r} · {vb!r}" ) pum = {t["table_id"]: t for t in src["pum"]} for table in src["tables"]: tid, line = table["id"], table["line"] p = pum.get(tid) if p is None: continue # 표 수 시험이 잡음 if table["headers"] != p["headers"]: diffs.append(f"{tid} L{line} 원본 표머리 — 원문 {table['headers']} · {p['headers']}") compare(tid, line, "원본", table["rows"], p["rows"]) if tid in src["by_id"]: _, entry = src["by_id"][tid] if entry["source_line"] != line: diffs.append( f"{tid} 마스터 줄 번호 — 원문 L{line} · 마스터 L{entry['source_line']}" ) heads = [_norm(h) for h in table["headers"] if _norm(h)] if heads != entry["condition_note"]: diffs.append( f"{tid} L{line} 마스터 표머리 — 원문 {heads} · {entry['condition_note']}" ) compare(tid, line, "마스터", table["rows"], entry["raw_row"]) assert not diffs, f"셀 어긋남 {len(diffs)}건 — 앞 30:\n" + "\n".join(diffs[:30]) KIND_NAMES = {"place": "표 자리", "basis": "밑수", "notes": "[주]", "form": "표 성격"} @pytest.mark.parametrize("kind", list(KIND_NAMES)) def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None: """표 자리·밑수·[주]·표 성격 — 알려진 목록과 똑같아야 함. 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강.""" now = current_mismatches(src)[kind] known = load_known()[kind] lines: list[str] = [] for tid in sorted(set(now) - set(known)): lines.append(f" 새로 어긋남 {tid}: {json.dumps(now[tid], ensure_ascii=False)}") for tid in sorted(set(now) & set(known)): if now[tid] != known[tid]: lines.append( f" 모습 바뀜 {tid}: 목록 {json.dumps(known[tid], ensure_ascii=False)}" f"\n 지금 {json.dumps(now[tid], ensure_ascii=False)}" ) for tid in sorted(set(known) - set(now)): lines.append(f" 고쳐짐 {tid} — 목록에서 지울 것(--prune)") assert not lines, f"{KIND_NAMES[kind]} {len(lines)}건 — 원문 ↔ 마스터:\n" + "\n".join(lines) if __name__ == "__main__" and "--prune" in sys.argv: # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). current = current_mismatches(load_sources()) data = load_known() for kind in KIND_NAMES: before = len(data[kind]) data[kind] = {k: v for k, v in data[kind].items() if current[kind].get(k) == v} print(f"{KIND_NAMES[kind]}: {before} → {len(data[kind])}") KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")