브레인 일감. 원문 `(산림청고시 제2025-82호) 산림사업 표준품셈.md` 를 **빌더 코드 없이 따로 읽어**
`pum_forest_2026.json`·`work_item_master_2026-01-01.json` 과 맞댐. 어긋나면 표 번호·줄·칸까지 찍음.
- 0 이어야 하는 것 — 표 수·차례(476) · 셀 값 글자까지(원본 헤더·줄 · 마스터 condition_note·raw_row). 지금 0
- 알려진 어긋남 목록(`fixtures/work_item_master_source_known.json`) 밖에서 새로 어긋나면 빨강
· 표 자리 3 — 〔부록 1~3〕 표 F0452·F0453·F0454 가 14-2 근주이식에 붙음
· 밑수 26 — 원문 괄호가 있는데 마스터 비음 13(4-4 「(인/100㎡)」 · 12-19 「(단위: 공㎥당)」 …) ·
마스터에만 있음 13(3-2 「인/1일 1km당」 분모 둘인데 km · 사람 판정 머리 소실 표 · 「(단위 : ㎥)」 …)
· [주] 51 — 마스터가 40줄 넘는 표의 [주]를 놓침(1-2-2) · `*` 줄에서 끊음(4-2-1 ③~⑧ 할증 빠짐) ·
본문 소제목 `- 2. …` 을 [주]로 실음 · 다음 표의 [주]를 끌어옴
· 표 성격 11 — 품 표 표지(직종·소요인력·수량 단위 + 숫자)가 있는데 reference·coefficient
- 목록에 있는데 고쳐졌거나 모습이 바뀐 것도 빨강 — `--prune` 은 지우기만 함(새 것은 안 보탬)
- 안티그래비티 1바퀴 96건과 겹침 — 밑수 4 · 표 성격 5. 나머지는 이미 반영됐거나(데스크탑 서브 16) 보고 표 번호가 한 칸 밀려 있었음
- ponytail: 품 표 알아보기는 317 중 276 — 값이 범위거나 머리가 여러 줄로 갈린 표의 성격 뒤집힘은 못 잡음
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
373 lines
17 KiB
Python
373 lines
17 KiB
Python
"""원문 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")
|