Files
Aislo/resources/master_data/scripts/check_master.py
T
eomsangdonandClaude Opus 5 6a911c04cf feat(master_data): 검사 도구 check_master.py · 틀 판정 반영
- 검사 넷: 틀 · 본문 대조(표 허구 · 절 결손) · 로직 검사(요소·표·칸 · 찾기 조건 · 로직 입력 · 돌고 도는 참조) · 시험 계산(호표 줄 · 비목 합)
- 식 읽기·계산은 master_formula.py(틀 7장 범위 · eval 없음 · Decimal)
- 틀: 번호로 적은 값은 원문 이름을 비고에 · 분수·식 값 · 짝 칸 · 8장 검사
- 기계 아홉 줄 비고에 조종원직종 원문 이름(건설기계운전사)
- 시험 test_masterdata_check.py: 견본 셋 손 계산 그대로 · 견본 파일 검사 통과

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

306 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""마스터 데이터 검사 도구 (`resources/master_data/_틀.md`).
돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …]
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 "산림품셈:13-4-1 메쌓기" 뒷길이=35 돌=견치돌 …
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양
(2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조
(4) 계산 — 로직 열쇠와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
"""
from __future__ import annotations
import json
import re
import sys
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve().parent
MASTER = HERE.parent
ROOT = MASTER.parents[1]
COST = ROOT / "resources/knowledge/original/원가계산"
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
import master_formula as mf # noqa: E402
from pum_md_tool import _NUM, md_lines # noqa: E402
BOOKS = (
"산림품셈",
"건설품셈",
"건설노임",
"제조노임",
"나라장터자재",
"오피넷유가",
"한국은행환율",
"조달청제비율",
"자체",
)
DIVISIONS = {
"공통": "01_공통부문",
"토목": "02_토목부문",
"건축": "03_건축부문",
"기계설비": "04_기계설비부문",
"유지관리": "05_유지관리부문",
}
_FILE = re.compile(rf"^({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d+장|[^_]+))?\.json$")
_SOURCE = re.compile(
rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$"
)
_IDENT = re.compile(r"\d+-\d+(?:-\d+)*")
ROW_KEYS = {
"요소": ("열쇠", "이름", "값", "출처"),
"표": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "줄", "주"),
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
}
def load(names: list[str] | None = None) -> dict[str, dict]:
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal."""
out = {}
for path in sorted(MASTER.glob("*.json")):
if path.name.startswith("_") or (
names and path.name not in names and path.stem not in names
):
continue
out[path.name] = json.loads(
path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal
)
return out
def master() -> mf.Master:
return mf.Master(load())
# ── (1) 틀 ────────────────────────────────────────────────────────────
def _source_ok(source) -> bool:
if isinstance(source, dict):
return all(_source_ok(v) for v in source.values())
return isinstance(source, str) and bool(_SOURCE.match(source))
def check_form(name: str, data: dict) -> list[str]:
out = []
m = _FILE.match(name)
if not m:
out.append(f"{name} · 파일 이름이 「그룹_원문[_장].json」 아님")
elif (data.get("그룹"), data.get("원문")) != (m.group(1), m.group(2)):
out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름")
for head in ("그룹", "원문", "판"):
if head not in data:
out.append(f"{name} · 머리 「{head}」 없음")
table = data.get("그룹") in mf.TABLE_GROUPS
kind = "표" if table else "로직" if data.get("그룹") == "로직" else "요소"
rows = data.get("표" if table else "줄")
if not isinstance(rows, list):
return out + [f"{name} · 머리 「{'표' if table else '줄'}」 목록 없음"]
seen = set()
for row in rows:
key = str(row.get("열쇠"))
where = f"{name} · {key}"
if key in seen:
out.append(f"{where} · 열쇠 겹침")
seen.add(key)
for need in ROW_KEYS[kind]:
if need not in row:
out.append(f"{where} · 칸 「{need}」 없음")
if "출처" in row and not _source_ok(row["출처"]):
out.append(f"{where} · 출처 모양 「{row['출처']}」")
if kind == "요소" and isinstance(row.get("값"), dict):
for bundle in ("단위", "출처"):
if isinstance(row.get(bundle), dict) and set(row[bundle]) != set(row["값"]):
out.append(f"{where} · 「{bundle}」 묶음 칸이 값 묶음과 다름")
if kind == "표":
out += _check_table(where, row)
if kind == "로직" and "결과" not in row and "호표" not in row:
out.append(f"{where} · 「호표」 도 「결과」 도 없음")
return out
def _check_table(where: str, table: dict) -> list[str]:
out, conds, cols = [], table.get("조건", {}), table.get("값칸", {})
allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols}
for kind in conds.values():
if kind not in ("수", "고르기", "범위"):
out.append(f"{where} · 조건 종류 「{kind}」")
if "범위" in conds.values() and "범위규칙" not in table:
out.append(f"{where} · 범위 조건에 「범위규칙」 없음")
for number, row in enumerate(table.get("줄", []), start=1):
extra = set(row) - allowed
if extra:
out.append(f"{where}{number} · 모르는 칸 {sorted(extra)}")
if not set(row) & set(cols):
out.append(f"{where}{number} · 값 칸 없음")
for cond, kind in conds.items():
if cond not in row:
continue
value = row[cond]
if kind == "범위" and not (isinstance(value, list) and len(value) == 2):
out.append(f"{where}{number} · 범위 조건 「{cond}」 이 [아래, 위] 아님")
if kind == "수" and not isinstance(value, Decimal):
out.append(f"{where}{number} · 수 조건 「{cond}」 이 수 아님")
for col in cols:
value = row.get(col)
if isinstance(value, list) and len(value) != 2:
out.append(f"{where}{number} · 값 칸 「{col}」 범위가 [아래, 위] 아님")
return out
# ── (2) 본문 ──────────────────────────────────────────────────────────
def _key(parts: str) -> tuple[int, ...]:
return tuple(int(p) for p in parts.split("-"))
def section_of(source: str) -> tuple[Path | None, str, str]:
"""(본문 md, 절 번호, 절 글) — 못 찾으면 md 나 글이 빔."""
ident = _IDENT.search(source)
if not ident:
return None, "", ""
ident = ident.group()
parts = _key(ident)
if source.startswith("산림품셈"):
chapters = COST / "산림_표준품셈/본문"
elif source.startswith("건설품셈"):
chapters = COST / "건설공사_표준품셈/본문" / DIVISIONS.get(source.split()[1], "")
else:
return None, ident, ""
best = None
for md in chapters.glob(f"제{parts[0]:02d}장_*/*.md"):
head = md.name.split("_", 1)[0]
if not _IDENT.fullmatch(head):
continue
own = _key(head)
if (
own[0]
and own == parts[: len(own)]
and (best is None or len(own) > len(_key(best.name.split("_", 1)[0])))
):
best = md
if best is None:
return None, ident, ""
lines = best.read_text(encoding="utf-8").split("\n")
heading = re.compile(rf"^(#+)\s*{re.escape(ident)}\.?(\s|$)")
for at, line in enumerate(lines):
m = heading.match(line)
if m:
level, end = len(m.group(1)), len(lines)
for later in range(at + 1, len(lines)):
h = re.match(r"^(#+)\s", lines[later])
if h and len(h.group(1)) <= level:
end = later
break
return best, ident, "\n".join(lines[at:end])
return best, ident, ""
def _canon(token: str) -> str:
return format(Decimal(token.replace(",", "")).normalize(), "f")
def numbers_in(text: str) -> set[str]:
return {_canon(t) for _, line in md_lines(text) for t in _NUM.findall(line)}
def _flat(value) -> set[str]:
if isinstance(value, Decimal):
return {_canon(str(abs(value)))}
if isinstance(value, str):
return {_canon(t) for t in _NUM.findall(value)}
if isinstance(value, list):
return set().union(*map(_flat, value)) if value else set()
if isinstance(value, dict):
return set().union(*map(_flat, value.values())) if value else set()
return set()
def check_body(files: dict[str, dict]) -> list[str]:
"""표마다 허구 · 절마다 결손."""
out, by_section = [], {}
for name, data in files.items():
for table in (data.get("표") or []) if data.get("그룹") in mf.TABLE_GROUPS else []:
where = f"{name} · {table.get('열쇠')}"
md, ident, text = section_of(str(table.get("출처", "")))
if not text:
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}」")
continue
mine = _flat(table.get("줄")) | _flat(table.get("기준")) | _flat(table.get("주"))
fake = mine - numbers_in(text)
if fake:
out.append(f"{where} · 허구 {len(fake)} {sorted(fake, key=Decimal)}")
slot = by_section.setdefault((md, ident), [text, set(), []])
slot[1] |= mine
slot[2].append(where)
for (md, ident), (text, mine, tables) in by_section.items():
table_text = "\n".join(line for line in text.split("\n") if line.lstrip().startswith("|"))
lack = numbers_in(table_text) - mine
if lack:
out.append(
f"{md.name} {ident} · 결손 {len(lack)} {sorted(lack, key=Decimal)}{' · '.join(tables)}"
)
return out
# ── (3) 로직 ──────────────────────────────────────────────────────────
def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]:
out, graph = [], {}
for name, data in files.items():
if data.get("그룹") != "로직":
continue
for row in data.get("줄", []):
found, calls = mf.check_logic(whole, data.get("원문"), row)
out += [f"{name} · {x}" for x in found]
graph[f"{data.get('원문')}:{row.get('열쇠')}"] = calls
out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)]
return out
# ── (4) 계산 ──────────────────────────────────────────────────────────
def show(result: dict) -> str:
if "결과" in result:
return f"결과 {result['결과']}"
rows = [
f" {r['이름']} | {r['단위']} | 수량 {r['수량']:.6f} | 단가 {r['단가']} | 금액 {r['금액']:.2f}"
for r in result["줄"]
]
rows += [f" {k} {result[k]:.2f}" for k in (*mf.COST_ITEMS, "계")]
return "\n".join(rows)
def _value(text: str):
try:
return Decimal(text)
except ArithmeticError:
return text
def main(argv: list[str]) -> int:
mode = argv[0] if argv else "전부"
if mode == "계산":
given = dict(arg.split("=", 1) for arg in argv[2:])
print(show(mf.run(master(), argv[1], {k: _value(v) for k, v in given.items()})))
return 0
names = argv[1:] or None
files, whole = load(names), master()
report = {}
if mode in ("틀", "전부"):
report["틀"] = [x for name, data in files.items() for x in check_form(name, data)]
if mode in ("본문", "전부"):
report["본문"] = check_body(files)
if mode in ("로직", "전부"):
report["로직"] = check_logics(files, whole)
for title, found in report.items():
print(f"({title}) {len(found)}건")
for line in found:
print(" " + line)
return 1 if any(report.values()) else 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main(sys.argv[1:]))