브레인 지시(2026-09-18)대로 랩탑 메인이 한 번에 풂. 자료 파일은 손으로 안 합치고 **추출기로 다시 뽑음**. - 손으로 푼 것 셋 · `_Table.py` 임포트 — 양쪽 뜻 다 살림(서브 `judged_absent` + 내 `judged_const_form`) · 원문 md 해상 9050 손료표 — 서브가 줄마다 푼 판을 취하되 **HEAD 에만 있던 「(9060) 토운선」 제목 줄을 살림** · 열쇠 지문 — 합친 판으로 다시 잼(옛 837eb8aa… · 서브 6f223c69… → 46c0d413…) - 시험 장부 둘은 **양쪽을 다 읽어 합침** — 서브가 붙인 칸(주인·갈래·할 일 아님)을 바탕에 두고 내 값으로 덮음 · 서브 것만 있던 줄 22 를 살림 · 양쪽이 고쳐 저절로 풀린 줄 10 은 `--prune` 으로 뺌 · ⚠ 「할 일 아님」 칸으로 옮긴 줄이 갈래에도 남아 두 번 세지 않게 뺌(33) · F0257 은 밑수 읽기가 넓어져 풀려 칸에서 나옴(34 → 33) - 자료는 다시 뽑음 — `_build_pum.py` · `_build_mach.py` · 공종 마스터 빌더 · 건설 품셈 표 2,202 → 2,270(다른 창이 뭉친 줄·표를 더 풂) · 연료 274 · 손료 611 · 가격 613 · 산림 미판정 0 · 산림 밑수 251(서브의 F0046·F0068 되돌림이 살아 있음) · 건설 미판정 277 · 밑수 확보 674 · 미확보 435 · 미판독 92 - 이름표 줄 수·못박은 셈 갱신 — 건설 표 2,270 · 산림 밑수 미확보 80 · 건설 미판정 277 · 건설 밑수 미확보 435 · 건설 갈래 195 · 건설 공종 축 997/1,334 → 1,044/1,381(원문이 더 읽힌 것이지 값이 바뀐 것 아님) - ⭐ 금액 불변 — 직접공사비 5,983,724 · 합계 10,124,000 그대로 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
504 lines
24 KiB
Python
504 lines
24 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〕"
|
||
#: 알려진 목록 갈래. `form_blind`·`cost_rule` 은 어긋남이 아니라 **시험이 못 가리는 자리**를 적어 둔 것.
|
||
KIND_NAMES = {
|
||
"place": "표 자리",
|
||
"basis": "밑수",
|
||
"notes": "[주]",
|
||
"form": "표 성격",
|
||
"form_blind": "표 성격 — 품 표지를 못 알아봄",
|
||
"cost_rule": "비목 구성표 — 사람이 봐야 함",
|
||
}
|
||
|
||
# ── 원문 읽기 ────────────────────────────────────────────────────────────
|
||
_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 원문 대조)
|
||
"""
|
||
bare = re.match(r"^\s*단\s*위\s*[::]\s*([\d.,]*)\s*(\S+)\s*$", text)
|
||
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)
|
||
# ⚠ 한 괄호에 밑수가 둘이면 못 고름 — `(1ha당, 100본당)` 에서 한쪽만 집으면 조용히 틀림.
|
||
if len(re.findall(rf"[\d,]*\.?\d*\s*(?:{_UNIT})\s*당", t)) > 1:
|
||
return "둘"
|
||
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()
|
||
elif bare: # `(단위 : ㎥)` — 「당」 없이 단위만 적은 자리(7-2 수라집재 · 7-10 우드그랩)
|
||
den = t
|
||
else:
|
||
return None
|
||
m = _ONE.match(den)
|
||
if m is None and "," in den or "," in den:
|
||
# `덩굴 약제처리, ha당` — 앞이 **숫자 없는 설명 글**일 때만 뒤를 밑수로 봄.
|
||
head, _, tail = den.replace(",", ",").rpartition(",")
|
||
if not re.search(r"\d", head):
|
||
m = _ONE.match(tail.strip())
|
||
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)
|
||
# 첫 두 줄의 칸 — **괄호 안이 「…당」일 때만** 봄(8-6-1 「소형헬기 (160ha당)」).
|
||
# ⚠ 넓히면 값 칸의 몫(`5인/km` · `Q=3.5㎥/hr`)이 밑수로 둔갑함.
|
||
if not found:
|
||
for cell in [c for row in table["rows"][:2] for c in row]:
|
||
for m in _PAREN_ANY.finditer(cell):
|
||
if m.group(1).strip().endswith("당") and isinstance(hit := parse_basis(m.group(1)), tuple): # fmt: skip
|
||
found.setdefault(hit, cell)
|
||
if len(found) == 1:
|
||
((hit, head),) = found.items()
|
||
return hit, f"표·머리 {head}"
|
||
# 표 아래 산출식 글에만 적힌 밑수 — `∙ 드론운반공 / 1,000본 = ④ × X ÷ 360 ÷ 5`(10-13)
|
||
for k in range(table["end"], min(len(lines), table["end"] + 12)):
|
||
text = lines[k].strip()
|
||
if text.startswith(("|", "#")):
|
||
break
|
||
if m := _PER_IN_FORMULA.search(text):
|
||
hit = parse_basis(f"{m.group(1)}{m.group(2)}당")
|
||
if isinstance(hit, tuple):
|
||
return hit, text
|
||
return None, ""
|
||
|
||
|
||
#: 표 아래 산출식에 「/ N단위 =」 꼴로만 적힌 밑수(10-13 드론·인력 운반).
|
||
_PER_IN_FORMULA = re.compile(rf"/\s*([\d,]+)\s*({_UNIT})\s*=")
|
||
_NOTE_CONT = re.compile(r"^(-\s*[①-⑳]|-\s*-|[※*·])")
|
||
#: `- 4. 고철공제 : A=1㎥×0.008×…` — 번호 목록이지만 **[주] 안쪽 규칙**. 콜론·등호로 알맹이가 이어짐.
|
||
#: ⚠ `- 3. 노상 및 노반재료` 처럼 알맹이 없는 짧은 줄은 **새 절 제목**이라 [주]가 아님
|
||
#: (2026-09-18 데스크탑 서브 제안 · 랩탑 서브가 원문 전수로 「1.」「가.」 꼴 [주] 0건 확인).
|
||
_NOTE_NUMBERED = re.compile(r"^-\s*(?:\d+|[가-하])\.\s*[^:=]*[:=]")
|
||
|
||
|
||
def md_notes(lines: list[str], table: dict) -> list[str]:
|
||
"""표 바로 아래 `[주]` 덩어리. 첫 글줄이 `[주` 가 아니면 없음.
|
||
|
||
이어지는 줄 = `- ②` 꼴 · `- -` 꼴 · `※`·`*`·`·` 로 시작하는 줄. 빈 줄은 건너뜀. 그 밖의 줄에서 끝.
|
||
⚠ `- 2. 소제목` 같은 본문 목록은 [주]가 아님.
|
||
⚠ [주] 사이에 낀 `> 【산출 예시】` 덩어리는 건너뛰고 이어 봄 — 그 뒤에 `- ⑥ … 100% 가산` 이 옴(4-3).
|
||
"""
|
||
out: list[str] = []
|
||
for k in range(table["end"], len(lines)):
|
||
s = lines[k].strip()
|
||
if not s or (out and s.startswith(">")):
|
||
continue
|
||
if not out and s.startswith("[주"):
|
||
out.append(s)
|
||
elif out and (_NOTE_CONT.match(s) or _NOTE_NUMBERED.match(s) or s.startswith("[주")):
|
||
# 번호 항목 안에 다시 `[주]①` 이 붙는 자리(9-8-2 집토) — 끊지 않고 이어 봄
|
||
out.append(s)
|
||
else:
|
||
break
|
||
return out
|
||
|
||
|
||
_WORKER = re.compile(
|
||
r"인\s*부|기능공|운전|목\s*공|석\s*공|철근공|콘크리트공|기\s*사|벌목부|작업반장|기술자|"
|
||
r"용접공|미장공|방수공|배관공|비계공"
|
||
)
|
||
#: 품 낱말 — 공백을 지운 칸에서 찾음(`주 재 료` → `주재료`).
|
||
_WORK_MARK = re.compile(
|
||
r"\(인\)|소요인력|소요량|주입량|작업능력|작업량|수집량|집재량|공정량|시공량|살포량|처리량|"
|
||
r"인력구분|인력구성|적용인부|인원\(명\)|^주재료$|인/|/인|\(시간\)|\(hr\)|[가-힣]\((kg|㎏|g|㎥|ℓ)\)"
|
||
)
|
||
_QTY_UNIT = {"인", "매", "개", "본", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "대", "톤", "hr", "시간"}
|
||
#: 숫자 칸 — `0.16` · 범위 `3.3∼5.9` · 단위 붙음 `1.5인`·`10ℓ`·`5인/km`·`2.18Roll` ·
|
||
#: 한 칸에 여러 값 `0.04 0.06 0.10`.
|
||
_NUMBER = re.compile(
|
||
r"^\d[\d,]*(\.\d+)?(\s*[~∼~-]\s*\d[\d,]*(\.\d+)?)?"
|
||
r"(\s*(인|ℓ|시간|㎥|㎡|개|본|매|kg|m|%|Roll)(/[a-z㎞㏊]+)?)?(\s+[-\d,.]+)*$"
|
||
)
|
||
|
||
|
||
def md_work_mark(table: dict) -> str | None:
|
||
"""품 표 표지 칸 — 있으면 그 칸 글, 없으면 `None`. 표에 숫자 칸이 있을 때만 봄.
|
||
|
||
① **짧은 칸(20자 이하)** 에 직종 이름·품 낱말(`소요인력`·`인력구분`·`(인)`·`시멘트(kg)` …)
|
||
② 한 줄에 수량 단위 칸(`인`·`매`·`㎥` …)과 숫자 칸이 함께 있음
|
||
③ 단위만 늘어선 줄(`| | ㎥ | ㎥ | ㎥ |`) — 값은 아래 줄에 있음(7-1-1 수확)
|
||
긴 설명 글 속 「보통인부」는 이름이 아님(손료계수표 적용기준 칸).
|
||
⚠ 못 알아보는 품 표는 알려진 목록 `form_blind` 에 까닭과 함께 둠 — 그 표는 성격이 뒤집혀도 못 잡음.
|
||
"""
|
||
rows = [table["headers"], *table["rows"]]
|
||
cells = [c for row in rows for c in row]
|
||
if not any(_NUMBER.match(c) for c in cells):
|
||
return None
|
||
for c in cells:
|
||
if len(c) <= 20 and (_WORKER.search(c) or _WORK_MARK.search(c.replace(" ", ""))):
|
||
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
|
||
filled = {c for c in row[1:] if c}
|
||
if len(filled) == 1 and filled <= _QTY_UNIT:
|
||
return next(iter(filled))
|
||
return None
|
||
|
||
|
||
#: 비목 이름 — 이것만으로 첫 칸이 채워진 표가 비목 구성표(`재료비 | 설치비 재료비의 5%`).
|
||
_COST_ITEMS = {"재료비", "설치비", "노무비", "경비", "제작비", "운송비", "자재비", "주재료비"}
|
||
|
||
|
||
def md_cost_rows(table: dict) -> list[list[str]] | None:
|
||
"""비목 구성표면 그 줄들(뒤 빈 칸 뗌), 아니면 `None`.
|
||
|
||
⚠ 이 꼴은 **모양은 잡히나 성격은 원문으로 못 가림** — 「설치비 = 재료비의 5%」 가 품(자원 줄)인지
|
||
참조(비율 지시)인지는 설계 방식의 결정이라 사람이 봐야 함(알려진 목록 `cost_rule`).
|
||
"""
|
||
rows = [r for r in table["rows"] if r and r[0].strip()]
|
||
if not rows or any(r[0].replace(" ", "") not in _COST_ITEMS for r in rows):
|
||
return None
|
||
out = []
|
||
for r in rows:
|
||
cut = list(r)
|
||
while cut and not cut[-1]:
|
||
cut.pop()
|
||
out.append(cut)
|
||
return out
|
||
|
||
|
||
# ── 맞대기 ──────────────────────────────────────────────────────────────
|
||
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] = {kind: {} for kind in KIND_NAMES}
|
||
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],
|
||
}
|
||
|
||
form = entry["pum_form"]
|
||
if cost_rows := md_cost_rows(table):
|
||
out["cost_rule"][table["id"]] = {
|
||
"section": section,
|
||
"master": form,
|
||
"md_rows": cost_rows,
|
||
}
|
||
continue # 성격은 사람 몫 — 품 표지로 재지 않음
|
||
mark = md_work_mark(table)
|
||
if form in ("reference", "coefficient") and mark:
|
||
out["form"][table["id"]] = {"section": section, "master": form, "md_mark": mark}
|
||
elif form in ("requirement", "productivity") and not mark:
|
||
out["form_blind"][table["id"]] = {"section": section, "master": form}
|
||
return out
|
||
|
||
|
||
def load_known() -> dict[str, dict]:
|
||
"""알려진 어긋남 목록. **「할 일 아님」 칸(`_할일_아님`)도 함께 읽는다.**
|
||
|
||
⚠ 왜 합쳐 읽나 — ㉮(원문에 없음·거절 자리·판정으로 닫힘)는 **바퀴마다 다시 세어지지 않게**
|
||
갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인). 그렇다고 비교에서까지 빠지면 그 줄이 다시
|
||
「새로 어긋남」으로 빨개진다 — 그래서 **세는 곳에서만 빼고 비교에는 넣는다.**
|
||
"""
|
||
data = json.loads(KNOWN.read_text(encoding="utf-8"))
|
||
for kind, rows in ((data.get("_할일_아님") or {}).get("줄") or {}).items():
|
||
data.setdefault(kind, {}).update(rows)
|
||
return data
|
||
|
||
|
||
#: 비교에서 뺄 칸 — **사람이 적은 메모**다. 자료가 아니라 설명이라 어긋남 판정에 안 쓴다.
|
||
#: `class`·`class_why` 는 2026-09-18 에 붙인 갈래(㉮ 영영 아님 · ㉯ 남의 몫 · ㉰ 사용자 판단).
|
||
_MEMO_FIELDS = ("why", "class", "class_why", "was", "owner", "owner_ask", "갈래")
|
||
|
||
|
||
def _without_why(entry: dict) -> dict:
|
||
return {k: v for k, v in entry.items() if k not in _MEMO_FIELDS}
|
||
|
||
|
||
def test_할일_아님_칸이_갈려_있다() -> None:
|
||
"""㉮ — **영영 할 일이 아닌 것**은 갈래에서 빼 별도 칸에 둔다(2026-09-18 브레인).
|
||
|
||
⚠ 왜 못박나 — 섞여 있으면 바퀴마다 「어긋남」으로 다시 세어져 **없는 할 일이 생긴다.**
|
||
⚠ 지우지는 않는다 — 나중에 「이거 왜 안 했지」 할 때 사유를 보아야 한다.
|
||
"""
|
||
block = json.loads(KNOWN.read_text(encoding="utf-8")).get("_할일_아님")
|
||
assert block, "「할 일 아님」 칸이 없다"
|
||
rows = block["줄"]
|
||
total = sum(len(v) for v in rows.values())
|
||
# 2026-09-18 합침 — 34 → 33. F0257(9-11-1 「(단위: 일당)」)은 밑수 읽기를 넓히자 **풀려서** 뺐다
|
||
# (거절 자리 11 → 10). 남은 33 은 그대로 「영영 할 일 아님」이다.
|
||
assert total == 33
|
||
assert block["갈래별"] == {"거절 자리": 10, "그 밖": 18, "부록 예시": 1, "원문 부재": 4}
|
||
for kind, got in rows.items():
|
||
for tid, v in got.items():
|
||
assert str(v.get("class", "")).startswith("㉮"), f"{kind}/{tid}"
|
||
assert v.get("class_why"), f"{kind}/{tid} 사유 없음" # 왜 할 일이 아닌지
|
||
assert v.get("갈래") in ("원문 부재", "부록 예시", "거절 자리", "그 밖"), (
|
||
f"{kind}/{tid}"
|
||
)
|
||
# 세는 칸에서는 빠지고 비교에는 들어간다 — 그래서 빨개지지 않는다
|
||
merged = load_known()
|
||
for kind, got in rows.items():
|
||
assert set(got) <= set(merged[kind])
|
||
|
||
|
||
# ── 시험 ────────────────────────────────────────────────────────────────
|
||
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])
|
||
|
||
|
||
@pytest.mark.parametrize("kind", list(KIND_NAMES))
|
||
def test_알려진_어긋남_밖은_없음(src: dict, kind: str) -> None:
|
||
"""알려진 목록과 똑같아야 함 — 새로 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강.
|
||
|
||
목록 칸 `why` 는 사람이 적은 까닭 — 맞대기에서 뺌.
|
||
"""
|
||
now = current_mismatches(src)[kind]
|
||
known = {k: _without_why(v) for k, v in load_known()[kind].items()}
|
||
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) == _without_why(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")
|