Files
Aislo/resources/tester/test_work_item_master_source_match.py
T
eomsangdonandClaude Opus 5 d379acc59b chore(tester): ㉮ 36 을 「할 일 아님」 칸으로 빼고 갈래 나눔 · ㉯ 에 주인 다 적음
**① ㉮ 를 갈래에서 아주 뺌** — 장부에 섞여 있어 바퀴마다 「어긋남」으로 다시 세어지던 것.
  `_할일_아님` 칸으로 옮김(공종축 34 + 기초값 2) · **지우지는 않음**(나중에 사유를 보아야 함)
  갈래 — 거절 자리 11 · 그 밖 18 · 원문 부재 4 · 부록 예시 1
  ⚠ **세는 곳에서만 빼고 비교에는 넣음** — `load_known()` 이 그 칸도 읽게 함.
    안 그러면 옮긴 줄이 다시 「새로 어긋남」으로 빨개짐
  시험으로 못박음 — 칸이 있나 · 34줄인가 · 줄마다 **왜 할 일이 아닌지** 사유가 있나 · 갈래가 넷 중 하나인가

**② ㉯ 남의 몫에 주인 다 적음** — 갈래 없던 기계 줄 65 에도 적어 미정 0
  랩탑 메인 87(md 읽기 22 + 기계 원천 벌 재추출 65) · 랩탑 서브 1(F0333 PDF 대조)

**③ 남은 건수 — 실제 할 일만**
  ① 산림 공종 축 **0** (미확보 79 = 원문 부재 69 + 미판독 10, 열은 다 거절 자리) · 미귀속 표 22 는 ㉰ 에
  ② 건설 공종 축 **1,053** (형태 미판정 891 + 밑수 미판독 162) — 뿌리는 「판정이 산림 표로만
     맞춰진 것」이라 랩탑 메인 몫
  ③ 확인 대기 **100** = ㉯ 92(다 남의 몫) + ㉰ 8 · 할 일 아님으로 뺀 것 36
  ④ 사용자 판단 **8** — F0046·F0068 「1인」 밑수 · F0076 반쪽 「1km」 · 부록 14-2 표 셋 ·
     F0405 증가율표 성격 · 이형철근 범위 6~7
  ⇒ **내가 지금 할 수 있는 것은 0** — 다 남의 차례이거나 사용자 판단

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsGw1Dz9HmhuAxGisxmDPF
2026-09-18 06:19:53 +09:00

468 lines
22 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 원문 대조)
"""
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. 소제목` 같은 본문 목록은 [주]가 아님.
⚠ [주] 사이에 낀 `> 【산출 예시】` 덩어리는 건너뛰고 이어 봄 — 그 뒤에 `- ⑥ … 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):
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())
assert total == 34
assert block["갈래별"] == {"거절 자리": 11, "그 밖": 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")