"""근거 제안 1 — M01 값 역색인 · 숫자 정규화 한 벌(PLAN 793-3a · 요구 648 · 분석 648-D 1 장) · 읽기만. 칸(`cells`) = 표 칸 하나 {v, 표, 줄, 칸, 단위, 종류 val(값칸) | cond(조건 수) | ratio(「a 인 / b」 몫) | 기준(「10㎡」 당) | eq(기계 요소 값)} · 쓰임(`uses`) = 표 → [(로직, 부름, 칸 | None, 글자 거름, 입력 묶음)] (하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` · 직종 표지). 정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 · N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 = `M02_San_Basis_Const`). 우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 · 포아송 이항 꼬리 −log10. 짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시). """ from __future__ import annotations import ast import bisect import glob import json import math import re import threading import unicodedata from collections import defaultdict from functools import lru_cache from pathlib import Path from typing import Any from common_util import common_util_derived_store as derived from M02_MasterTemplete import M02_Hopyo_Bind as hb from M02_MasterTemplete import M02_Hopyo_ReadEst as est mf, ma = hb.mf, hb.ma CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json") INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈 PERSON = ("인",) # ── 단위 성격(N8) ───────────────────────────────────────────────────── FAM = [ x.split(":") for x in ( "인:사람 ㎡:넓이 m2:넓이 ㎥:부피 m3:부피 ℓ:부피 km:길이 ㎝:길이 cm:길이 ㎜:길이 mm:길이 " "m:길이 ㎏:무게 kg:무게 ton:무게 톤:무게 t:무게 g:무게 %:율 hr:시간 h:시간 시간:시간 " "분:시간 min:시간 초:시간 sec:시간 일:시간 회:수 본:수 개:수 매:수 조:수 대:수 ea:수" ).split() ] def fam(unit: Any) -> str | None: u = unicodedata.normalize("NFKC", str(unit or "")).strip().lower().split("/")[0].strip() if "%" in u: return "율" for a, f in FAM: if u == a or u.startswith(a) and a not in ("m", "t", "h", "g"): return f return None def unit_ok(tu: Any, cu: Any) -> bool: a, b = fam(tu), fam(cu) return a is None or b is None or a == b _OPS = { ast.Add: float.__add__, ast.Sub: float.__sub__, ast.Mult: float.__mul__, ast.Div: float.__truediv__, } def _calc(node: ast.AST) -> float: """수 · 사칙 · 부호만(그 밖 = ValueError) — eval 없음.""" if isinstance(node, ast.Expression): return _calc(node.body) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value) if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): x = _calc(node.operand) return -x if isinstance(node.op, ast.USub) else x if isinstance(node, ast.BinOp) and type(node.op) in _OPS: return _OPS[type(node.op)](_calc(node.left), _calc(node.right)) raise ValueError(node) def _arith(s: str) -> float | None: """「480 - 30」 · 「1/1.30」 → 수 · 못 셈 None.""" if not re.fullmatch(r"[\d.\s+\-*/()]+", s) or not re.search(r"\d", s): return None try: return _calc(ast.parse(s.strip(), mode="eval")) except (SyntaxError, ValueError, ZeroDivisionError): return None def nums_of(v: Any) -> list[float]: """칸 값 → [수] — 수 · [아래, 위] · 식 글자(셈 값 + 낱 수).""" if isinstance(v, bool) or v is None: return [] if isinstance(v, (int, float)): return [float(v)] if isinstance(v, list): return [float(x) for x in v if isinstance(x, (int, float)) and not isinstance(x, bool)] if isinstance(v, str): got = _arith(v) if got is None: return [] parts = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", v)] return [got] + [p for p in parts if p != got] return [] # ── 직종(N5) ───────────────────────────────────────────────────────── @lru_cache(maxsize=None) def trade(name: str) -> str | None: return est.trade(name) def _flat(t: Any) -> str: return re.sub(r"[\s()()_\-·.]", "", re.sub(r"\(.*?\)|_.*$", "", str(t or ""))) def canon(t: str) -> str: return trade(t) or trade(_flat(t)) or _flat(t) def same_trade(a: str, b: str) -> bool: x, y = canon(a), canon(b) return bool(x and y) and (x == y or len(y) >= 2 and len(x) >= 2 and (x in y or y in x)) # ── M01 읽기 · 로직 훑기 ───────────────────────────────────────────── def folder() -> Path: return Path(hb.m01.FOLDER) def stamp() -> str: """M01 지문 — 첫 층 파일 내용(`M02_Hopyo_Judge._stamp` 와 같은 몫) + 보정 자료.""" files = { p.name: derived.file_digest(p) for p in sorted(folder().glob("*.json")) if not p.name.startswith("_") } return derived.derived_key("san-basis-index", files, derived.file_digest(CORPUS)) def load(root: Path) -> tuple[dict, dict, dict, dict]: tables, logic, eq = {}, {}, {} for f in glob.glob(str(root / "소요량_*.json")) + glob.glob(str(root / "계수_*.json")): for t in json.loads(Path(f).read_text(encoding="utf-8")).get("표", []): tables[t["키"]] = t for f in glob.glob(str(root / "로직_*.json")): for it in json.loads(Path(f).read_text(encoding="utf-8"))["줄"]: logic[it["키"]] = it for r in json.loads((root / "기계.json").read_text(encoding="utf-8"))["줄"]: eq[r["키"]] = r by_no: dict = {} for k, t in tables.items(): by_no.setdefault((k[:2], str(t.get("원문번호"))), []).append(k) return tables, logic, eq, by_no def _resolve(ref: str, tables: dict, by_no: dict) -> str | None: if "{" in ref: return None if ref in tables: return ref tid, _, no = ref.partition(":") hit = by_no.get((tid, no)) or [] return hit[0] if len(hit) == 1 else None def scan(item: dict, tables: dict, by_no: dict) -> tuple: """로직 하나 → (찾기 부름, 식 속 수, 하위 로직 부름, 기계 부름, 호표 수량 속 수).""" names = {s["이름"] for s in item.get("입력", [])} finds, nums, calls, eqs = [], [], [], [] def arg_of(node): if node[0] == "str": return ("lit", node[1]) if node[0] == "num": return ("lit", float(node[1])) if node[0] == "name": return ("name", node[1]) if node[1] in names else ("expr", node[1]) return ("expr", str(node)[:80]) def visit(node, field=None): kind = node[0] if kind == "field": visit(node[1], node[2]) elif kind in ("find", "logic"): if kind == "find" and (ref := _resolve(node[1], tables, by_no)): finds.append((ref, field, {c: arg_of(a) for c, a in node[2]})) if kind == "logic" and isinstance(node[1], str) and "{" not in node[1]: calls.append((node[1], {c: arg_of(a) for c, a in node[2]})) for _, a in node[2]: if a[0] not in ("str", "num", "name"): visit(a) elif kind == "ref": if node[1].startswith("EQ") and field: eqs.append((node[1], field)) elif kind == "num": nums.append(float(node[1])) elif kind == "bin": for side in (node[2], node[3]): # 비교 글자(「SCH == 20」)는 셈 수가 아님 if node[1] in mf._OPS and side[0] == "num": continue visit(side) elif kind == "neg": visit(node[1]) elif kind == "call": for a in node[2]: visit(a) for text in ma._texts(item): if any(text is h["수량"] for h in item.get("호표", [])): continue try: visit(mf.parse(text)) except mf.FormulaError: pass trade_nums = [] # (호표 이름, 수량 식 속 수) for h in item.get("호표", []): before = len(nums) try: visit(mf.parse(h["수량"])) except mf.FormulaError: continue trade_nums += [(h.get("이름") or "", x) for x in nums[before:]] return finds, nums, calls, eqs, trade_nums def _cells(tables: dict, eq: dict) -> list[dict]: cells: list[dict] = [] for tk, t in tables.items(): vcols = t.get("값칸", {}) or {} base = re.match(r"\s*(\d+(?:\.\d+)?)\s*(\S*)", str(t.get("기준") or "")) if base and float(base.group(1)) not in (0, 1): # 「10㎡」 · 「100m」 당 — 표 기준 수(N6) cells.append( { "v": float(base.group(1)), "표": tk, "줄": 0, "칸": "기준", "단위": base.group(2), "종류": "기준", } ) for ri, row in enumerate(t.get("줄", [])): got = {} for col, unit in vcols.items(): for x in nums_of(row.get(col)): cells.append( { "v": x, "표": tk, "줄": ri, "칸": col, "단위": row.get("단위") or unit, "종류": "val", } ) got.setdefault(col, x) for col, kind in (t.get("조건", {}) or {}).items(): x = row.get(col) if kind == "수" and isinstance(x, (int, float)) and not isinstance(x, bool): cells.append( {"v": float(x), "표": tk, "줄": ri, "칸": col, "단위": "", "종류": "cond"} ) for a, ua in vcols.items(): # 인/양 몫(N4 · 「5인 / 45㎡」 = 0.111) if not str(row.get("단위") or ua).startswith(PERSON) or a not in got: continue for b, ub in vcols.items(): ub = str(row.get("단위") or ub) if b == a or b not in got or ub.startswith(PERSON) or "%" in ub or not got[b]: continue cells.append( { "v": got[a] / got[b], "표": tk, "줄": ri, "칸": f"{a}/{b}", "단위": "인", "종류": "ratio", } ) for ek, r in eq.items(): # 기계 요소 수(가격 · 글 뺌) for col, x in (r.get("값") or {}).items(): if col in ("가격", "주연료종류", "조종원직종") or col.endswith("가격"): continue unit = (r.get("단위") or {}).get(col, "") if isinstance(r.get("단위"), dict) else "" cells += [ {"v": y, "표": ek, "줄": 0, "칸": col, "단위": unit or "", "종류": "eq"} for y in nums_of(x) ] return cells # ── 역색인 ─────────────────────────────────────────────────────────── class Index: def __init__(self, root: Path | None = None): self.tables, self.logic, self.eq, by_no = load(root or folder()) self.cells = _cells(self.tables, self.eq) self.scans = {k: scan(it, self.tables, by_no) for k, it in self.logic.items()} order = sorted(range(len(self.cells)), key=lambda i: self.cells[i]["v"]) self.vals = [self.cells[i]["v"] for i in order] self.ids = order self.uses = defaultdict(list) # 표 → [(로직, 부름id, 칸 | None, 거름, 묶음)] self.eq_uses = defaultdict(list) # 기계 칸 → [(로직, 부름id, (고정 | 입력, 값))] self.inline = defaultdict(set) self.inline_trade = defaultdict(set) self.eq_no = {str(r.get("원문번호")): k for k, r in self.eq.items()} | { k: k for k in self.eq } self.callers = defaultdict(set) # 하위 → 부르는 위 로직(어느 깊이든) for k in self.logic: self._expand(k, k, {}, {}, 0, k) self.inl_index = defaultdict(set) for k, xs in self.inline.items(): for x in xs: self.inl_index[round(x, 9)].add(k) self.trade_index = defaultdict(set) for k, xs in self.inline_trade.items(): for _, x in xs: self.trade_index[round(x, 9)].add(k) self.cells_of_table = defaultdict(list) for c in self.cells: self.cells_of_table[c["표"]].append(c) self.item_tables = defaultdict(set) for t, us in self.uses.items(): for u in us: self.item_tables[u[0]].add(t) self._hits: dict = {} self._look: dict = {} self._df: dict = {} self.prior() def _expand(self, top, key, rename, lit, depth, path): finds, nums, calls, eqs, tnums = self.scans[key] for name, x in tnums: self.inline_trade[top].add((name, x)) for ref, field, args in finds: filt, bind = {}, {} for col, (kind, val) in args.items(): if kind == "lit": filt[col] = val elif kind == "name" and depth == 0: bind[col] = val elif kind == "name" and val in rename: bind[col] = rename[val] elif kind == "name" and val in lit: filt[col] = lit[val] else: # 중간 · 식 · 안 넘긴 입력 — 같은 인자를 쓰는 부름끼리 같은 줄(가짜 입력 §) bind[col] = f"§{path}:{val}" if not filt and not bind: # 조건 없는 부름 — 같은 표 부름끼리 같은 줄 bind["#row"] = f"§{path}:{ref}" gid = f"{path}|{ref}|{sorted(filt.items(), key=str)}|{sorted(bind.items())}" self.uses[ref].append((top, gid, field, filt, bind)) self.inline[top].update(nums) for ref, field in eqs: m = re.match(r"EQ:\{(\w+)\}", ref) if m: arg = m.group(1) inputs = {sp["이름"] for sp in self.logic[key].get("입력", [])} if depth == 0 and arg in inputs: use = ("입력", arg) elif depth > 0 and arg in rename: use = ("입력", rename[arg]) elif depth > 0 and arg in lit and self.eq_no.get(str(lit[arg])): use = ("고정", self.eq_no[str(lit[arg])]) else: use = ("입력", f"§{path}:EQ:{arg}") self.eq_uses[field].append((top, f"{path}@{ref}", use)) elif ref.startswith("EQ0"): self.eq_uses[field].append((top, f"{path}@{ref}", ("고정", ref))) if depth >= 3: return for j, (sub, args) in enumerate(calls): if sub not in self.logic: continue self.callers[sub].add(top) ren, lt = {}, {} for name, (kind, val) in args.items(): if kind == "lit": lt[name] = val elif kind == "name": if depth == 0: ren[name] = val elif val in rename: ren[name] = rename[val] elif val in lit: lt[name] = lit[val] self._expand(top, sub, ren, lt, depth + 1, f"{path}>{sub}.{j}") @staticmethod def _pass(row: dict, filt: dict) -> bool: """칸 하나가 줄 거름을 통과?""" for col, want in filt.items(): have = row.get(col) if have is None: continue if isinstance(have, list): if isinstance(want, float) and len(have) == 2: lo, hi = have if (lo is not None and want < lo) or (hi is not None and want > hi): return False continue if isinstance(want, float): try: if float(have) != want: return False except (TypeError, ValueError): return False elif str(have) != str(want): return False return True @staticmethod def _trade_ok(name: str, c: dict) -> bool: if c["종류"] == "eq": return False if c["종류"] != "ratio" and fam(c["단위"]) not in (None, "사람"): return False return same_trade(name, c["칸"].split("/")[0]) def lookup(self, k: dict) -> list[tuple[int, str]]: """상수 → [(칸 id, 꼴 eq | pct | kgt)] — N1 · N2 · N3 · N4 · N5 · N8(캐시).""" key = kkey(k) if key not in self._look: self._look[key] = self._lookup(k) return self._look[key] def _lookup(self, k: dict) -> list[tuple[int, str]]: v, d, unit = k["v"], k["d"], k["단위"] sig = len(re.sub(r"^0+|\.", "", f"{v:.{d}f}").lstrip("0")) tol = 0.5 * 10 ** (-d) if d >= 1 and sig >= 2 else 1e-9 if k.get("몫"): tol = abs(v) * 1e-6 forms = [(v, tol, "eq")] tf = fam(unit) if tf in (None, "율"): forms += [(v * 100, tol * 100, "pct"), (v / 100, tol / 100, "pct")] if tf == "무게": forms += [(v * 1000, tol * 1000, "kgt"), (v / 1000, tol / 1000, "kgt")] out = [] for x, t, how in forms: t = max(t, abs(x) * 1e-9) for j in range( bisect.bisect_left(self.vals, x - t), bisect.bisect_right(self.vals, x + t) ): cid = self.ids[j] c = self.cells[cid] if c["종류"] == "cond": continue # 조건 칸(관경 · SCH)은 옵션 값 — 상수 설명에 안 씀 if k.get("직종") and not self._trade_ok(k["직종"], c): continue if c["종류"] == "ratio" and how != "eq": continue if how == "pct" and not (tf == "율" or "%" in str(c["단위"])): continue if how == "kgt" and not (tf == "무게" or fam(c["단위"]) == "무게"): continue if ( how == "eq" and abs(c["v"] - v) > 1e-9 and c["종류"] != "ratio" and tol < 1e-6 and not k.get("몫") ): continue if not unit_ok(unit, c["단위"]) and how == "eq": continue out.append((cid, how)) return out def hits_of(self, cid: int) -> list[tuple]: """칸 → [(로직, 부름id, 줄, 묶음)](캐시).""" got = self._hits.get(cid) if got is None: got = self._hits[cid] = self._hits_of(cid) return got def _hits_of(self, cid: int) -> list[tuple]: c = self.cells[cid] out = [] if c["종류"] == "기준": return [(u[0], f"{u[1]}|기준", 0, {}) for u in self.uses.get(c["표"], [])] if c["종류"] == "eq": for item, callid, (kind, val) in self.eq_uses.get(c["칸"], []): if kind == "고정" and val != c["표"]: continue out.append( ( item, callid, c["표"], {val: json.dumps(c["표"])} if kind == "입력" and val else {}, ) ) return out row = self.tables[c["표"]]["줄"][c["줄"]] cols = c["칸"].split("/") if c["종류"] == "ratio" else [c["칸"]] for item, callid, field, filt, bind in self.uses.get(c["표"], []): if field is not None and field not in cols and c["종류"] != "cond": continue if not self._pass(row, filt): continue b = {} for col, name in bind.items(): if col == "#row": b[name] = json.dumps(c["줄"]) elif row.get(col) is not None: b[name] = json.dumps(row[col], ensure_ascii=False) out.append((item, callid, c["줄"], b)) return out def explain(self, k: dict) -> list[tuple]: """상수 하나 → [(로직, 부름id | None, 줄, 묶음, 꼴, 닻)] · 닻 = 표 칸 · 직종 식 수로 맞음.""" out = [] for cid, how in self.lookup(k): if self.cells[cid]["종류"] == "eq": continue # 기계 요소 값(상각비계수 · 잡재료율 …)은 산근 글에 거의 안 나옴 out += [(item, call, row, bd, how, True) for item, call, row, bd in self.hits_of(cid)] x = round(k["v"], 9) if k.get("직종"): for item in self.trade_index.get(x, ()): if any( same_trade(k["직종"], n) for n, y in self.inline_trade[item] if round(y, 9) == x ): out.append((item, None, None, {}, "식", True)) else: forms = ( (x, round(k["v"] * 100, 9), round(k["v"] / 100, 9)) if fam(k["단위"]) in (None, "율") else (x,) ) for form in forms: for item in self.inl_index.get(form, ()): if len(self.inline[item]) <= INLINE_MAX: out.append((item, None, None, {}, "식", False)) return out # ── 우연 보정 ──────────────────────────────────────────────────── def df(self, k: dict) -> int: key = kkey(k) if key not in self._df: self._df[key] = max(1, len({x[0] for x in self.explain(k)})) return self._df[key] def prior(self) -> None: """보정 자료 상수마다 설명할 수 있는 로직 → 로직 몫 p · 평균 df.""" keys = [ dict(zip(("v", "d", "단위", "직종", "몫"), row)) for row in json.loads(CORPUS.read_text(encoding="utf-8")) ] cnt: dict = defaultdict(int) for k in keys: for it in {x[0] for x in self.explain(k)}: cnt[it] += 1 self.prom = {it: (cnt[it] + 1) / (len(keys) + 2) for it in self.logic} self.mean_df = sum(self.df(k) for k in keys) / max(len(keys), 1) def q_of(self, item: str, k: dict) -> float: """로직 item 이 상수 k 를 우연히 설명할 확률 ≈ df_k · p_item / 평균 df.""" p = self.prom.get(item, 1 / max(len(self.prom), 1)) return min(0.95, max(1e-6, self.df(k) * p / self.mean_df)) def kkey(k: dict) -> tuple: return (k["v"], k["d"], k["단위"], k.get("직종"), bool(k.get("몫"))) def pb_sig(qs: list[float], c: int) -> float: """포아송 이항 꼬리 P(X ≥ c) 의 -log10.""" if c <= 0: return 0.0 dp = [1.0] + [0.0] * len(qs) for q in qs: for j in range(len(dp) - 1, 0, -1): dp[j] = dp[j] * (1 - q) + dp[j - 1] * q dp[0] *= 1 - q return round(-math.log10(max(sum(dp[c:]), 1e-300)), 2) _LOCK = threading.Lock() _CACHE: dict[str, Index] = {} def get() -> Index: """M01 지문마다 한 번 지은 역색인(프로세스 안 · 지문 바뀌면 다시).""" key = stamp() with _LOCK: got = _CACHE.get(key) if got is None: trade.cache_clear() _CACHE.clear() got = _CACHE[key] = Index() return got def warm() -> None: """서버 시작 때 한 번(뒤에서) — 실패 = 다음 부름에서 다시.""" try: get() except Exception: # noqa: BLE001 pass