feat(712-3): 표 기준 → 기준배 · 기준단위 읽을 때 파생 — 표 기준 글 · 빈 기준은 원문 절 머리 · 정본 표 안 씀 · API 표 밖 칸 기준파생 · 판정규(판 = 읽을 때 파생) · check_master 「배」 대조 7 건 · 지킴 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 14:50:06 +09:00
co-authored by Claude Opus 5.5
parent 9827926a20
commit a36b1051d4
4 changed files with 248 additions and 3 deletions
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""표 기준 → 「기준배 · 기준단위」 — **읽을 때 파생**(정본 표 파일에 쓰지 않음 · PLAN 712-3).
- 표 `기준` 글(「10㎡당」 · 「인/100본당」 · 「ton 당」)을 배 · 단위로 읽음.
- `기준` 이 비었으면 원문 절 머리(「(단위: ㎥당)」 · 「(일당)」)에서
— 절과 아래 절 머리가 한 꼴일 때만.
- 단위가 아닌 기준(「인력품」 · 「할증」 · 「정의」)은 `갈래` 「비단위」 · 못 읽음은 「모름」.
- 배 대조(`check_times`) = 로직 결과단위 배(원/10㎡ → 10) ↔ 그 로직이 부르는 표 기준배
(`check_master` 「배」).
"""
from __future__ import annotations
import re
from decimal import Decimal
from functools import lru_cache
from check_master_body import section_of
# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움
_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
_UNITS = sorted(
(
"㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 "
"일 시간 hr 분 min 초 매 주 도엽 도곽 필지 점 발파 인 노선 공 경간 련 식 "
"개월 월 년 가닥 면 장 측점 세트 set 호 층 단계 ea"
).split(),
key=len,
reverse=True,
)
_UNIT_AT_END = re.compile(
rf"(?<![\d:,.A-Za-z])(\d[\d,]*(?:\.\d+)?)?\s*({'|'.join(map(re.escape, _UNITS))})$"
)
_HANGUL = re.compile(r"[가-힣]")
_HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」
_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」
def unit(text: str) -> str:
return _ALIAS.get(text, text)
def _times(text: str | None) -> Decimal:
return Decimal(text.replace(",", "")) if text else Decimal(1)
def parse(text) -> dict | None:
"""기준 글 → `{배, 단위}` · 단위 아닌 글이면 None."""
core = re.split(r"\s+[—-]\s+", str(text or "").strip())[0]
core = re.sub(r"\([^()]*\)", "", core).strip()
if not core or core.startswith("단위"): # 「단위: 인」 = 값 칸 단위(기준 아님)
return None
if re.search(
r",\s|[∼~]", core
): # 「구하는점 5점, 주어진점 3점」 · 「400필지∼450필지」 = 조건 글
return None
core = core.rsplit("/", 1)[-1].strip() # 「인/100본당」 → 「100본당」(자원/기준)
if "당" in core: # 「1필지당 1,500㎡」 · 「1대당 높이 2m 기준」 → 「당」 앞까지
core = core[: core.rfind("당")].strip()
m = _UNIT_AT_END.search(core)
if not m:
return None
one, at = m.group(2), m.start(2)
if len(one) == 1 and _HANGUL.match(one) and _HANGUL.match(core[at - 1 : at] or " "):
return None # 「참조」 의 「조」 — 한 글자 한글 단위가 낱말 끝이면 단위 아님
return {"배": _times(m.group(1)), "단위": unit(m.group(2))}
@lru_cache(maxsize=None)
def _heads(source: str) -> tuple[str, ...]:
"""원문 절(과 아래 절) 머리 줄 글 — 차례대로 · 겹침 뺌."""
_, _, text = section_of(source)
found = (_HEAD.match(line.strip()) for line in text.split("\n"))
return tuple(dict.fromkeys(m.group(1).strip() for m in found if m))
def basis(table: dict) -> dict:
"""표 하나의 파생 칸 — `{기준배, 기준단위, 기준근거, 갈래}`(갈래 = 단위 · 비단위 · 모름)."""
said = str(table.get("기준") or "").strip()
if said:
got = parse(said)
if got:
return {"기준배": got["배"], "기준단위": got["단위"], "기준근거": "표", "갈래": "단위"}
return {"기준배": None, "기준단위": None, "기준근거": "표", "갈래": "비단위"}
heads = [h for h in _heads(str(table.get("출처") or "")) if parse(h)]
picks = {(parse(h)["배"], parse(h)["단위"]) for h in heads}
if len(picks) == 1: # 절 머리가 한 꼴일 때만 — 둘 넘으면 손 확인
times, one = picks.pop()
return {
"기준배": times,
"기준단위": one,
"기준근거": f"원문 「{heads[0]}」",
"갈래": "단위",
}
return {"기준배": None, "기준단위": None, "기준근거": None, "갈래": "모름"}
def result_per(result_unit) -> list[tuple[Decimal, str]]:
"""로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿."""
return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))]
def _formula_text(row: dict) -> str:
return str([row.get(k) for k in ("중간", "호표", "덧줄")])
def _divides(text: str, times: Decimal) -> bool:
"""식이 그 배로 나누거나 곱하는가 — 「/ 10」 · 「÷10」 · 「* 0.1」."""
n = format(times.normalize(), "f")
inverse = format((1 / times).normalize(), "f")
pattern = rf"(?:[/÷]\s*{re.escape(n)}|\*\s*{re.escape(inverse)})(?![\d.])"
return re.search(pattern, text) is not None
def check_times(files: dict[str, dict]) -> list[str]:
"""배 어긋남 — 로직이 부르는 표(`용도.로직키`)의 기준배 ≠ 1 인데 결과단위가 같은 배가 아니고
식에서도 그 배로 나누지 않는 짝(시스템 관리자 원문 확인 거리)."""
logic_rows = {
str(r.get("키")): r
for d in files.values()
if d.get("그룹") == "로직"
for r in d.get("줄") or []
}
out = []
for name, data in files.items():
for table in data.get("표") or []:
got = basis(table)
if got["갈래"] != "단위" or got["기준배"] == 1:
continue
for key in (table.get("용도") or {}).get("로직키") or []:
row = logic_rows.get(str(key))
if row is None:
continue
per = result_per(row.get("결과단위"))
shown = format(got["기준배"].normalize(), "f")
said = str(row.get("결과단위") or "") # 「원/보양길이 100m」
if (
(got["기준배"], got["기준단위"]) in per
or f"{shown}{got['기준단위']}" in said
or _divides(_formula_text(row), got["기준배"])
):
continue
out.append(
f"{key} · 결과단위 「{row.get('결과단위')}」 ↔ 표 {table.get('키')}"
f" 기준 「{shown}{got['기준단위']}」 — 배 어긋남"
)
return out
def edition(text) -> str:
"""파일 머리 판 → 해(「2026-01-01」 · 「2026」 → 「2026」) · 해 없으면 그대로(파일 안 씀)."""
m = re.match(r"\s*(\d{4})(?:\D|$)", str(text or ""))
return m.group(1) if m else str(text or "")