Files
Aislo/resources/tester/test_work_item_master_source_match.py
T
eomsangdonandClaude Opus 5 41ab123b46 Merge: 데스크탑 서브(9f27d619)와 한 점으로 — 자료는 다시 뽑고 시험 장부만 손으로 합침
브레인 지시(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
2026-09-18 09:56:07 +09:00

504 lines
24 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"
#: 알려진 목록 갈래. `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.35.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")