Files
Aislo/resources/tester/test_work_item_master_source_match.py
T
eomsangdonandClaude Opus 5 71c5dc2583 test(b08): 원문 md ↔ 마스터 맞대기 시험 — 안티그래비티 손 대조를 자동으로
브레인 일감. 원문 `(산림청고시 제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
2026-09-17 23:51:14 +09:00

373 lines
17 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.
"""원문 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")