브레인 일감 ①②. 시험이 못 보는 자리를 좁히고, 못 좁히는 자리는 까닭과 함께 남김.
- ① 표 성격 — 품 표 317 중 못 알아보던 41 의 원인을 가름
· 넓힘: 단위 붙은 수(`10ℓ`·`5인/km`) · 범위 값(`3.3∼5.9`) · 「인력구분」「인원(명)」「시멘트(kg)」 표지 ·
단위만 늘어선 줄(`| | ㎥ | ㎥ |`) → 305 알아봄(성격이 뒤집히면 빨강 — 10가지 뒤집어 확인)
· 못 봄 12 → 목록 `form_blind` 에 까닭: 머리 소실 4 · 값 칸 비어 있음 4 · 뭉친 칸·식 글 2 ·
연료표 1(F0046 — 같은 모양 셋은 참조인데 이것만 품) · 증가율표 1(13-4-1)
· ⚠ 12-1-3 한중콘크리트 표(F0333)는 머리가 「온도 품종 0℃때…」인데 몸 줄이 앞 시멘트 표와 똑같음 — 원문 md 의심
· 표지를 넓혀 새로 걸린 참조표 둘(8-6-1 F0227 · 13-4-4 F0410)은 사람 판정 참조라 까닭 달아 목록에
- ② 「재료비의 5%」 꼴 — 원문에 표 전체가 비목 구성(`재료비 | 설치비 재료비의 5%`)인 것은 7표(12장)
· 모양은 시험으로 잡힘(첫 칸이 모두 비목 이름) · 참조냐 품이냐는 원문으로 못 가림 → `cost_rule` 「사람이 봐야 함」
· 같은 꼴인데 12-29 만 requirement, 나머지 6 은 reference — 목록에 드러남
· 「…비의 N%」 가 든 나머지 15표는 품 표 안의 한 칸(잡품 「주재료비의 5%」 · 감률 「굴취품의 20%」)이라 따로 안 셈
- 목록 칸 `why`(사람이 적은 까닭)는 맞대기에서 뺌 · --prune 도 까닭을 지킴
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
429 lines
19 KiB
Python
429 lines
19 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 원문 대조)
|
||
"""
|
||
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.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]:
|
||
return json.loads(KNOWN.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _without_why(entry: dict) -> dict:
|
||
return {k: v for k, v in entry.items() if k != "why"}
|
||
|
||
|
||
# ── 시험 ────────────────────────────────────────────────────────────────
|
||
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")
|