"""기초데이터 열(품셈 근거) ↔ 원문 맞대기 — 2026-09-18 브레인 일감(랩탑 메인). 기초값 열다섯 중 **품셈 원문에서 옮겨 적은 열**을 원문 표와 칸 단위로 맞댐. 나머지 다섯 (노임·기계·자재·유가·요율)은 `test_base_prices_source_match.py` 몫. - 원문 = 산림 품셈 md(`test_work_item_master_source_match.MD`) · 건설 품셈 제8장 md(불도저 제원) - 어긋남은 `fixtures/base_data_source_known.json` 에 — 목록 밖 새 어긋남 · 모습 바뀜 · 고쳐짐 셋 다 빨강 (고친 뒤 `python resources/tester/test_base_data_source_match.py --prune`, 지우기만 함) - ⚠ **못 재는 칸은 못 잰다고 적음** — `NOT_CHECKED` 에 까닭과 함께. 자료에 새 칸이 생기면 빨강 (재든지 못 잰다고 적든지 하게). - 표 읽기는 옆 시험의 `md_tables` 한 벌 — 빌더 코드는 안 씀. """ from __future__ import annotations import json import re import sys from pathlib import Path import pytest from test_work_item_master_source_match import MD, ROOT, _without_why, md_tables KNOWN = Path(__file__).resolve().parent / "fixtures/base_data_source_known.json" CONST_MACHINE_MD = ( ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md" ) FILES = { "coef": "data_cost_input_value/coef_2026.json", "material_surcharge": "data_material_surcharge/material_surcharge_2026-01-01.json", "formwork_reuse": "data_formwork/formwork_reuse_2026-01-01.json", "rebar_complexity": "data_rebar/rebar_complexity_2026-01-01.json", "timber_structure_class": "data_timber/timber_structure_class_2026-01-01.json", "masonry_slope": "data_masonry/masonry_slope_2026-01-01.json", "masonry_back_length": "data_masonry/masonry_back_length_2026-01-01.json", "stone_kind": "data_masonry/stone_kind_2026-01-01.json", "masonry_class": "data_masonry/masonry_class_2026-01-01.json", "machine_productivity": "data_machine_productivity/machine_productivity_2026-01-01.json", } #: 값이 아니라 글·방침·출처 칸 — 맞댈 값이 없음. TEXT_FIELDS = { "schema_version", "dataset_id", "effective_date", "generated_at", "note", "why", "source", "sources", "policy", "not_here", "no_folding", "option_key", "option_note", "original_tables", "not_found", "candidates_pending_user", "observed_practice", "pending_user", } # fmt: skip #: 재는 칸. CHECKED = { "coef": {"variables"}, "material_surcharge": {"rates_pct"}, "formwork_reuse": {"reuse_by_class", "reuse_ratio_pct", "euroform_type"}, "rebar_complexity": {"classes"}, "timber_structure_class": {"classes"}, "masonry_slope": {"steps_m", "table"}, "masonry_back_length": {"steps_m", "table_cm"}, "stone_kind": {"kinds", "wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2", "back_lengths_cm"}, "masonry_class": {"back_length", "boulder_diameter", "bond"}, "machine_productivity": {"variables"}, } #: ⚠ 못 재는 칸 — 까닭. NOT_CHECKED = { "formwork_reuse": { "type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", "shoring": "강관동바리 대상 메모 — 값 없음", }, "rebar_complexity": { "form_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", "price_hint_krw_per_ton": "B09 가 자재 단가로 셈한 참고값 — 원문에 없음", }, "timber_structure_class": { "basis_unit": "밑수 풀이 글(「목재 채적 ㎥」) — 원문은 「㎥당」만 적음", "type_map": "프로그램 구조물 → 갈래 이음(원문 값 아님)", }, "stone_kind": { "backfill_ratio_of_back_length": "교본 7-3 값(1/3·1/2) — 품셈 원문이 아님(교본 원본 대조 몫)", "fallback": "건설품셈 [참고자료] 돌쌓기 규격별 소요량 — 원문 md 가 이 저장소에 없음", }, "masonry_class": { "face_slope": "교본 7-3 기준(지식DB 돌쌓기.md 요약) — 품셈 원문이 아님", }, } # ── 원문 읽기 ──────────────────────────────────────────────────────────── def _sq(text: str) -> str: return re.sub(r"\s+", "", str(text)) def _num(text: str) -> float | None: """`0.30` · `1:0.30` → 수. `-`·빈칸 → `None`.""" t = _sq(text).split(":")[-1].replace(",", "") return float(t) if re.fullmatch(r"\d+(\.\d+)?", t) else None def _range(text: str) -> tuple[float | None, float | None]: """`1.70∼2.00` · `25~35` · `6-7` · `75이상` → (아래, 위).""" t = _sq(text) if m := re.fullmatch(r"(\d+(?:\.\d+)?)이상", t): return float(m.group(1)), None parts = re.split(r"[∼~~-]", t) if len(parts) == 2 and all(re.fullmatch(r"\d+(\.\d+)?", p) for p in parts): return float(parts[0]), float(parts[1]) return None, None def _numbers(text: str) -> set[float]: return {float(x) for x in re.findall(r"\d+(?:\.\d+)?", text)} def _section(lines: list[str], heading: str) -> str: """`### {heading}` 부터 다음 같은 급 제목 앞까지 글.""" start = next(i for i, x in enumerate(lines) if x.startswith(f"### {heading}")) end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("### ")), len(lines)) return "\n".join(lines[start:end]) def _f(value) -> float | None: return None if value is None else float(value) # ── 열마다 맞대기 — {열쇠: {"md": 원문, "data": 자료}} ───────────────────── def _coef(doc: dict, md: dict) -> dict: out = {} rows = md["tables"]["F0004"]["rows"] for col, var in ((1, "coef_soil_L"), (2, "coef_soil_C")): records = doc["variables"][var]["records"] if len(records) != len(rows): out[f"{var}/줄 수"] = {"md": len(rows), "data": len(records)} for i, (row, rec) in enumerate(zip(rows, records)): name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(row[0])) lo, hi = _range(row[col]) md_val = [name, lo, hi, None if lo is not None else _sq(row[col])] data_name = re.sub(r"\([^)]*[一-鿿][^)]*\)", "", _sq(rec["soil_type"])) data_val = [ data_name, _f(rec.get("min")), _f(rec.get("max")), rec.get("rule") and _sq(rec["rule"]), ] if md_val != data_val: out[f"{var}/{i + 1}"] = {"md": md_val, "data": data_val} # 원문 보관 표 — 원문 표와 글자까지 같아야 함 by_line = {t["line"]: t for t in md["tables"].values()} for var in ("surcharge_labor", "surcharge_mat"): for t in doc["variables"][var]["tables"]: m = by_line.get(t["line"]) if m is None or [m["headers"], m["rows"]] != [t["headers"], t["rows"]]: out[f"{var}/L{t['line']}"] = { "md": m and [m["headers"], m["rows"]], "data": [t["headers"], t["rows"]], } tool = doc["variables"]["rate_tool"] if not {tool["min_percent"], tool["max_percent"]} <= _numbers(_section(md["lines"], "1-2-6.")): out["rate_tool"] = { "md": "1-2-6 글에 없음", "data": [tool["min_percent"], tool["max_percent"]], } return out #: 자재 → 원문 칸 자리 (표, 줄, 칸, 줄에 있어야 할 글). 줄이 밀리면 글 확인에서 걸림. SURCHARGE_CELLS = { "시멘트": [("F0008", 0, 1, "시멘트"), ("F0008", 0, 2, "시멘트")], "잔골재": [("F0008", 1, 1, "잔골재"), ("F0008", 1, 2, "잔골재")], "채움재": [("F0008", 1, 1, "채움재"), ("F0008", 1, 2, "채움재")], "굵은골재": [("F0008", 2, 1, "굵은골재"), ("F0008", 2, 2, "굵은골재")], "모래": [("F0009", 0, 1, "모래")], "부순돌": [("F0009", 1, 1, "부순돌")], "자갈": [("F0009", 1, 1, "자갈")], "점질토": [("F0009", 3, 1, "점질토")], "이형철근": [("F0011", 1, 1, "이형철근"), ("F0011", 2, 1, "주철근")], "원형철근": [("F0011", 0, 1, "원형철근")], "강판": [("F0011", 3, 1, "강판")], "각재": [("F0012", 0, 2, "각재")], "판재": [("F0012", 1, 1, "판재")], "레미콘": [("F0012", 5, 2, "레디믹스트"), ("F0012", 6, 1, "철근구조물")], "흄관": [("F0012", 16, 1, "원심력철근콘크리트관")], "떼": [("F0012", 10, 1, "떼")], "초화류": [("F0012", 10, 1, "초화류")], "사방용 수목": [("F0012", 9, 1, "사방용수목")], "원석": [("F0012", 8, 1, "마름돌용")], } def _material_surcharge(doc: dict, md: dict) -> dict: out = {} for rec in doc["rates_pct"]: cells = SURCHARGE_CELLS.get(rec["material"]) data_val = [rec.get("rate"), rec.get("alt_rate")] if cells is None: out[rec["material"]] = {"md": "원문 자리 모름", "data": data_val} continue md_val = [] for table, r, c, label in cells: row = md["tables"][table]["rows"][r] md_val.append(row[c] if label in _sq("".join(row)) else f"줄 밀림({label})") md_val += [None] * (2 - len(md_val)) norm = [_num(v) if v and _num(v) is not None else v for v in md_val] if norm != [_f(v) for v in data_val]: out[rec["material"]] = {"md": md_val, "data": data_val} return out def _formwork_reuse(doc: dict, md: dict) -> dict: out = {} rows = md["tables"]["F0040"]["rows"] for rec, row in zip(doc["reuse_by_class"], rows): if [int(_num(row[0][:-1])), rec["class"] in row[1]] != [rec["reuse_count"], True]: out[f"reuse_by_class/{rec['class']}"] = { "md": row, "data": [rec["reuse_count"], rec["class"]], } t = md["tables"]["F0336"] heads = t["rows"][0] # 횟수별 | 재료별(%) | 노무비(%) series = {} for row in t["rows"]: label = _sq(row[0]) series[label] = {heads[1]: row[4].split(), heads[2]: row[5].split()} ratio = doc["reuse_ratio_pct"] # ⚠ 원문은 **「합 판」 한 줄에 두 열**(재료별% · 노무비%)을 적고, 「각 재」 줄엔 비율이 없다. # 앞서 각재 자리에 노무비 열이 들어가 있었다(2026-09-18 바로잡음) — 열을 이름으로 맞댄다. 합판 = {h: [float(x) for x in v] for h, v in series.get("합판", {}).items() if v} for key, head in (("plywood_material_pct", "재료별(%)"), ("plywood_labor_pct", "노무비(%)")): data_val = [ratio[key][str(n)] for n in range(1, 7)] if 합판.get(head) != data_val: out[f"reuse_ratio_pct/{key}"] = {"md": 합판.get(head), "data": data_val} # 각재는 **비어 있어야** 한다 — 다른 열을 갖다 쓰면 안 된다 각재 = {h: v for h, v in series.get("각재", {}).items() if v} if 각재 or "timber" in ratio: out["reuse_ratio_pct/timber"] = { "md": 각재 or "비율 칸 비어 있음", "data": ratio.get("timber", "없음(원문에 없어 비워 둠)"), } area = md["tables"]["F0395"]["rows"] md_area = dict(zip([_sq(x) for x in area[0][:3]], [_num(x) for x in area[1][3:6]])) for cls in doc["euroform_type"]["classes"]: if md_area.get(cls["key"]) != cls["daily_area_m2"]: out[f"euroform_type/{cls['key']}"] = { "md": md_area.get(cls["key"]), "data": cls["daily_area_m2"], } return out def _rebar_complexity(doc: dict, md: dict) -> dict: """예시마다 12-3 [주]① 글에 있나. ponytail: 글 포함만 봄 — 원문에 있는 낱말(「교량」)을 엉뚱한 갈래에 보태도 통과. 갈래별 문장 쪼개기는 틀릴 때. """ note = _sq(md["notes_12_3"]) out = {} for cls in doc["classes"]: missing = [ex for ex in cls["examples"] if _sq(ex) not in note] if missing: out[f"classes/{cls['key']}"] = {"md": "12-3 [주]① 에 없는 예시", "data": missing} return out def _timber_structure_class(doc: dict, md: dict) -> dict: rows, found, group = md["tables"]["F0440"]["rows"], {}, "" for row in rows: cells = [_sq(c) for c in row] if cells[0].endswith("구조"): group, cells = cells[0], cells[1:] grade = "" if _num(cells[0]) is not None else cells.pop(0) found[f"{group} {grade}".strip()] = [_num(cells[0]), _num(cells[1])] out = {} data = {c["key"]: [c["carpenter"], c["laborer"]] for c in doc["classes"]} for key in sorted(set(found) | set(data)): if found.get(key) != data.get(key): out[f"classes/{key}"] = {"md": found.get(key), "data": data.get(key)} return out def _steps(cells: list[str]) -> list[float]: return [ float(_sq(c).lstrip("∼~~")) for c in cells if re.fullmatch(r"[∼~~]\d+(\.\d+)?", _sq(c)) ] def _masonry_slope(doc: dict, md: dict) -> dict: t = md["tables"]["F0413"] found, group = {}, "" for row in t["rows"]: cells = [c for c in row] if _sq(cells[0]) in ("메쌓기", "찰쌓기"): group, cells = _sq(cells[0]), cells[1:] found[f"{group}/{_sq(cells[0])}"] = [_num(c) for c in cells[1:] if _sq(c)] data = {f"{g}/{f}": v for g, faces in doc["table"].items() for f, v in faces.items()} out = { k: {"md": found.get(k), "data": data.get(k)} for k in sorted(set(found) | set(data)) if found.get(k) != data.get(k) } if _steps(t["headers"]) != doc["steps_m"]: out["steps_m"] = {"md": _steps(t["headers"]), "data": doc["steps_m"]} return out def _masonry_back_length(doc: dict, md: dict) -> dict: t = md["tables"]["F0412"] labels = [re.sub(r"\(.*", "", x) for x in t["rows"][1][0].split()] # 메쌓기(㎝) 찰쌓기(㎝) found = {label: [] for label in labels} for cell in t["rows"][1][1:6]: for label, part in zip(labels, cell.split()): lo, hi = _range(part) found[label].append([lo, hi]) out = {} for label in sorted(set(found) | set(doc["table_cm"])): data = [[_f(a), _f(b)] for a, b in doc["table_cm"].get(label, [])] if found.get(label) != data: out[f"table_cm/{label}"] = {"md": found.get(label), "data": doc["table_cm"].get(label)} if _steps(t["rows"][0]) != doc["steps_m"]: out["steps_m"] = {"md": _steps(t["rows"][0]), "data": doc["steps_m"]} return out def _stone_grid(table: dict) -> dict[str, dict[str, float | None]]: """뭉친 칸 표(`야면석(㎥) 깬잡석(㎥)` | `0.06 0.09`) → {돌: {뒷길이: 값}}.""" lengths = [re.sub(r"[^\d]", "", h) for h in table["headers"][1:] if re.search(r"\d+㎝", h)] grid = {} for row in table["rows"]: names = [re.sub(r"\(.*", "", n) for n in re.findall(r"[가-힣]+\s*[가-힣]*\(㎥\)", row[0])] names = [_sq(n) for n in names] for name in names: grid[name] = {} for length, cell in zip(lengths, row[1:]): for name, part in zip(names, cell.split()): grid[name][length] = _num(part) return grid def _stone_kind(doc: dict, md: dict) -> dict: out = {} first = {"야면석·호박돌": "야면석", "깬잡석": "깬잡석", "깬돌": "깬돌", "견치돌": "견치돌"} for field, table in (("wedge_stone_m3_per_m2", "F0408"), ("fill_concrete_m3_per_m2", "F0410")): grid = _stone_grid(md["tables"][table]) fill_rows = { "깬돌": "깬돌", "견치돌": "견치돌", "깬잡석": "깬잡석", "야면석·호박돌": "야면석", } for kind in doc["kinds"]: name = first[kind] if field.startswith("wedge") else fill_rows[kind] data = {k: _f(v) for k, v in doc[field][kind].items()} if grid.get(name) != data: out[f"{field}/{kind}"] = {"md": grid.get(name), "data": doc[field][kind]} if field.startswith("fill") and grid.get("야면석") != grid.get("호박돌"): out[f"{field}/호박돌"] = {"md": grid.get("호박돌"), "data": "야면석과 한 줄로 묶음"} lengths = [int(re.sub(r"[^\d]", "", h)) for h in md["tables"]["F0408"]["headers"][1:]] if lengths != doc["back_lengths_cm"]: out["back_lengths_cm"] = {"md": lengths, "data": doc["back_lengths_cm"]} if [first[k] for k in doc["kinds"]] != list(_stone_grid(md["tables"]["F0408"])): out["kinds"] = {"md": list(_stone_grid(md["tables"]["F0408"])), "data": doc["kinds"]} return out def _masonry_class(doc: dict, md: dict) -> dict: out = {} back = [ int(_numbers(c).pop()) for c in md["tables"]["F0407"]["rows"][0] if _sq(c).endswith("이하") ] if back != [c["max_cm"] for c in doc["back_length"]["classes"]]: out["back_length"] = { "md": back, "data": [c["max_cm"] for c in doc["back_length"]["classes"]], } boulder = [] for cell in md["tables"]["F0419"]["rows"][0]: if nums := re.findall(r"\d+", cell): boulder.append(f"{nums[0]}~{nums[1]}") if boulder != doc["boulder_diameter"]["classes"]: out["boulder_diameter"] = {"md": boulder, "data": doc["boulder_diameter"]["classes"]} for name, code in doc["bond"]["codes"].items(): number = code.removeprefix("FP-").replace("-0", "-").lstrip("0") if not any(x.strip() == f"### {number}. {name}" for x in md["lines"]): out[f"bond/{name}"] = {"md": f"### {number}. {name} 제목 없음", "data": code} return out def _machine_productivity(doc: dict, md: dict) -> dict: out, var = {}, doc["variables"] speed = {} # (궤도, 톤, 단) → [전진, 후진] for track, table in zip(("무한궤도", "타이어"), md["dozer_tables"]): gears = [_sq(c) for c in table["rows"][0]] fwd = [i for i, h in enumerate(table["headers"]) if "전진" in h][0] rev = [i for i, h in enumerate(table["headers"]) if "후진" in h][0] for row in table["rows"][1:]: ton = re.match(r"\d+", row[0]).group() for g in range(1, rev - fwd + 1): f, r = ( _num(row[fwd + g - 1]), _num(row[rev + g - 1]) if rev + g - 1 < len(row) else None, ) if f is not None and r is not None: speed[(track, ton, g)] = [f, r] assert gears[fwd] == "1단" or gears[fwd].startswith("1") data = { (r["track"], r["tonnage_ton"], r["gear"]): [ float(r["forward_m_per_min"]), float(r["reverse_m_per_min"]), ] for r in var["dozer_speed"]["records"] } for key in sorted(set(speed) | set(data)): if speed.get(key) != data.get(key): out["dozer_speed/" + "/".join(map(str, key))] = { "md": speed.get(key), "data": data.get(key), } # 삽날 용량 — 원문 md 가 표를 한 줄로 뭉갬(`0.51.11.5…`) · 톤 차례는 속도 표 둘에서 옴 tons = sorted({int(k[1]) for k in speed} | {int(k[1]) for k in data}) line = next(x for x in md["const_lines"] if "q" in x and "무한궤도" in x and "타 이 어" in x) blades = {} for track, part in zip( ("무한궤도", "타이어"), re.split(r"타\s*이\s*어", line.split("무한궤도", 1)[1]) ): for ton, token in zip(tons, re.findall(r"\d\.\d|-", part)): if token != "-": blades[(track, str(ton))] = float(token) data = { (r["track"], r["tonnage_ton"]): float(r["blade_m3"]) for r in var["dozer_blade"]["records"] } for key in sorted(set(blades) | set(data)): if blades.get(key) != data.get(key): out["dozer_blade/" + "/".join(key)] = {"md": blades.get(key), "data": data.get(key)} # 덤프 — 원문이 표가 아니라 식 글(`t3=1.1`). **기호 자리**에 적힌 수와 맞댐 haul = _section(md["lines"], "10-12-1.") for r in var["dump_haul"]["records"]: found = _symbol_values(haul, DUMP_HAUL_SYMBOLS[r["key"]]) if float(r["value"]) not in found: out[f"dump_haul/{r['key']}"] = {"md": sorted(found), "data": r["value"]} for i, r in enumerate(var["dump_material"]["records"], 1): text = _section(md["lines"], f"10-12-{i}.") for field, pattern in DUMP_MATERIAL_SYMBOLS.items(): found = _symbol_values(text, pattern) if float(r[field]) not in found: out[f"dump_material/{r['label']}/{field}"] = {"md": sorted(found), "data": r[field]} return out #: 덤프 밑값 → 10-12-1 글의 기호 자리. DUMP_HAUL_SYMBOLS = { "truck_ton": r"덤프트럭\((\d+)ton\)", "bucket_m3": r"q0=([\d.]+)", "loader_cycle_sec": r"㎝s=(\d+)", "speed_loaded_kmh": r"V1\(적재\)\s*\|\s*([\d.]+)", "speed_empty_kmh": r"V2\(공차\)\s*\|\s*([\d.]+)", "unload_min": r"t3=([\d.]+)", "wait_min": r"t4=([\d.]+)", "cover_min": r"t5=([\d.]+)", "loading_cycle_sec": r"㎝=(\d+)\(180°\)", } #: 재료별 값 → 그 재료 절(10-12-1·2·3) 글의 기호 자리. `E=` 는 `Es=`·`E0=` 와 가름. DUMP_MATERIAL_SYMBOLS = { "unit_weight_ton_per_m3": r"γt=([\d.]+)", "loose_factor": r"f=1/([\d.]+?)=?[,\s]", "bucket_factor": r"(? set[float]: return {float(m.rstrip(".")) for m in re.findall(pattern, text)} CHECKS = { "coef": _coef, "material_surcharge": _material_surcharge, "formwork_reuse": _formwork_reuse, "rebar_complexity": _rebar_complexity, "timber_structure_class": _timber_structure_class, "masonry_slope": _masonry_slope, "masonry_back_length": _masonry_back_length, "stone_kind": _stone_kind, "masonry_class": _masonry_class, "machine_productivity": _machine_productivity, } # ── 맞대기 ────────────────────────────────────────────────────────────── def load_sources() -> dict: lines = MD.read_text(encoding="utf-8").splitlines() tables = {t["id"]: t for t in md_tables(lines)} const_lines = CONST_MACHINE_MD.read_text(encoding="utf-8").splitlines() dozer = [t for t in md_tables(const_lines) if "전진속도" in "".join(t["headers"])][:2] notes_12_3 = [] for x in lines[tables["F0335"]["end"] :]: if x.strip().startswith("[주]"): notes_12_3.append(x) break return { "md": { "lines": lines, "tables": tables, "const_lines": const_lines, "dozer_tables": dozer, "notes_12_3": " ".join(notes_12_3), }, "docs": { k: json.loads((ROOT / "resources" / p).read_text(encoding="utf-8")) for k, p in FILES.items() }, } def current_mismatches(src: dict) -> dict[str, dict]: return {kind: check(src["docs"][kind], src["md"]) for kind, check in CHECKS.items()} 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 @pytest.fixture(scope="module") def src() -> dict: return load_sources() @pytest.mark.parametrize("kind", list(FILES)) def test_칸마다_재거나_못잰다고_적힘(src: dict, kind: str) -> None: """자료의 칸이 셋 중 하나 — 재는 칸 · 못 재는 칸(까닭) · 글 칸. 새 칸이 생기면 빨강.""" fields = set(src["docs"][kind]) - TEXT_FIELDS declared = CHECKED[kind] | set(NOT_CHECKED.get(kind, {})) assert fields == declared, ( f"{kind} — 안 적힌 칸 {sorted(fields - declared)} · 없는 칸 {sorted(declared - fields)}" ) @pytest.mark.parametrize("kind", list(FILES)) 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()} msgs = [ f" 새로 어긋남 {k}: {json.dumps(now[k], ensure_ascii=False)}" for k in sorted(set(now) - set(known)) ] msgs += [ f" 모습 바뀜 {k}: 목록 {json.dumps(known[k], ensure_ascii=False)}\n 지금 {json.dumps(now[k], ensure_ascii=False)}" for k in sorted(set(now) & set(known)) if now[k] != known[k] ] msgs += [f" 고쳐짐 {k} — 목록에서 지울 것(--prune)" for k in sorted(set(known) - set(now))] assert not msgs, f"{kind} {len(msgs)}건 — 원문 ↔ 자료:\n" + "\n".join(msgs) if __name__ == "__main__" and "--prune" in sys.argv: # 고쳐졌거나 모습이 바뀐 줄만 지움 — 새 어긋남은 안 보탬(보태면 빨강이 조용히 묻힘). current = current_mismatches(load_sources()) data = load_known() for kind in FILES: 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}: {before} → {len(data[kind])}") KNOWN.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")