"""근거 제안 2 — 산근 블록 글 → 상수 지문(PLAN 793-3a · 분석 648-D 1-2 뽑기) · 읽기만. 뽑기 = 엑셀꼴(EST · STmate 보이는 글): 콜론 앞 이름 · 단가 앞 규격 · 쉼표 금액 · 항목 단가 · 「=」 뒤 결과 (앞 마디가 셈일 때) · 소계 줄 · 절 제목 줄 · 품셈 근거 줄 · 품셈 번호 · 연도 뺌 · 정수 끝 점 지움 / STmate 숨은 G 식: 따옴표 글 뺌 · 수 바로 뒤 짧은 따옴표 = 단위 · `&…&` 참조 뺌. N9 일반 수(1 · 100 · 1000 · 60 분 · 8 시간 = 「일반」 표지) · N10 입력(L · 거리 · km) 뺌 · 인력 줄 몫(「a 인 / b」 · 「a / Q」 = 블록 안 대입)에 직종 표지. """ from __future__ import annotations import re import unicodedata from functools import lru_cache from typing import Any from M02_MasterTemplete import M02_Hopyo_Formula as formula from M02_MasterTemplete import M02_Hopyo_ReadEst as est from M02_MasterTemplete import M02_San_Basis_Index as bi fam = bi.fam UNIT = ( r"(㎡|㎥|㎏|kg|Kg|KG|ton|Ton|TON|톤|m3|M3|m2|M2|㎝|cm|㎜|mm|km|Km|KM|ℓ|hr|Hr|HR" r"|시간|분|초|sec|min|일|인|회|본|개|매|조|대|%|EA|ea|t(?![a-zA-Z])|m(?![a-zA-Z²³])|M(?![a-zA-Z]))" ) NUMTOK = re.compile( r"(? str: s = unicodedata.normalize("NFKC", str(s or "")) return s.replace("×", "*").replace("÷", "/").replace("=", "=").replace(":", ":") def _f(s: Any) -> float | None: try: return float(str(s).replace(",", "").rstrip(".")) except ValueError: return None def g_text(g: str) -> tuple[str, str]: """STmate G 식 → (절 나누기 글, 수 뽑기 글) — 따옴표 글은 이름 · 수 뒤 짧은 따옴표 = 단위.""" parts = g.split("'") out: list[str] = [] for i, p in enumerate(parts): if i % 2 == 0: out.append(p) elif re.search(r"\d\s*$", parts[i - 1]) and len(p) <= 8 and not re.search(r"\d", p): out[-1] = parts[i - 1].rstrip() out.append(f"⟨{p}⟩") else: out.append(" ") s = re.sub(r"&[^&]*&", " ¤ ", "".join(out)) # 단가 · 금액 범위 참조 s = re.sub(r":=\s*A\d+|:\s*T\d+|->|\(-?=+\)|\[[A-Za-z]\w*\]", " ", s) return g.replace("'", ""), s def result_spans(s: str) -> list[tuple[int, int]]: """「=」 마디 중 바로 앞 마디가 셈인 마디의 첫 수 = 결과 — 여러 대입 줄(「q = 0.7 K = 0.9」)은 결과 아님. """ out, at = [], 0 segs = s.split("=") for i, seg in enumerate(segs): last = i == len(segs) - 1 if ( i and OPER.search(segs[i - 1].split(",")[-1]) and (last or not OPER.search(seg.split(",")[0])) ): m = re.search(r"\d[\d,]*(?:\.\d+)?", seg) if m and not seg[: m.start()].strip(" ({"): out.append((at + m.start(), at + m.end())) at += len(seg) + 1 return out def extract(text: str, prices: set, results: set, g: bool) -> tuple[list[dict], list[dict], str]: """줄 하나 → (상수, 입력, 수 글) — 결과 · 단가 · 금액 · 이름 · 번호 뺌.""" s = nk(text) if not g and "=" not in s and (formula._title(s.strip()) or re.search(r"품\s*셈", s)): return [], [], "" # 절 제목 · 품셈 근거 줄 — 규격 · 번호뿐 for r in DROP: s = r.sub(" ", s) s = HEADNO.sub(" ", s) s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 엑셀꼴 정수 끝 점(「5. 인 / 45. ㎡」) if not g: if ":" in s and "=" not in s.split(":")[0]: s = s.split(":", 1)[1] else: # 콜론 없는 재료 · 인력 줄 — 단가 앞(이름 · 규격) 자름 for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s): x = _f(m.group()) if ( x is not None and ("," in m.group() or x in prices) and "=" not in s[: m.start()] ): s = s[m.start() :] break skip = [] if g else result_spans(s) consts, inputs = [], [] for m in NUMTOK.finditer(s): if any(a <= m.start(1) < z for a, z in skip): continue # 셈 결과 raw = m.group(1) if "," in raw.strip(","): continue # 금액 · 단가 raw = raw.rstrip(".,") x = _f(raw) if x is None or x == 0: continue unit = m.group(2) if m.group(2) is not None else (m.group(3) or "") if not unit and re.match(r"[가-힣]", s[m.end() : m.end() + 1]): continue # 「2차선」 · 「3층」 같은 낱말 속 수 sym_hit = SYM.search(s[: m.start()]) sym = sym_hit.group(1) if sym_hit else "" prev = s[: m.start()].rstrip()[-1:] one = { "v": x, "d": len(raw.split(".")[1]) if "." in raw else 0, "단위": unit, "기호": sym, "글": text.strip()[:90], } if x in prices or (not g and x in results): continue speed = re.match(r"\s*/\s*h", s[m.end() : m.end() + 4], re.I) if ( (sym and INPUT_SYM.match(sym)) or fam(unit) == "길이" and unit.lower() in ("km",) and not speed ): inputs.append(one) continue if ( (x in (1, 100, 1000) and unit != "%") or (x == 60 and fam(unit) in (None, "시간")) or ( x == 8 and (prev == "/" or fam(unit) == "시간" or re.search(r"hr|시간", s, re.I)) and fam(unit) in (None, "시간") ) ): one["일반"] = True consts.append(one) return consts, inputs, s def quotients(body: str, labor: bool, syms: dict) -> list[dict]: """인력 줄 「3 / 4.5」 · 「5인 / 45」 · 「2인 * 1일 / 50」 · 「5인 / Q」 → 몫 상수(인/양).""" out = [] for m in QUO.finditer(body): a, bt = _f(m.group(1)), m.group(2) b = syms.get(bt) if re.match(r"[A-Za-z]", bt) else _f(bt) unit_p = bool(re.search(r"⟨인⟩|인|명", body[m.start() : m.start(1) + len(m.group(1)) + 4])) if not a or not b or not (labor or unit_p) or b in (8, 60, 100): continue out.append( { "v": a / b, "d": 9, "단위": "인", "기호": "", "몫": f"{a:g}/{b:g}", "글": body.strip()[:90], } ) return out def trade_of(text: str) -> str | None: """인력 줄 이름 → 우리 인력 이름(모르면 None).""" m = est._COLON.match(nk(text)) if not m: return None name = re.sub(r"^[0-9가-힣]\.|^[○◇□※·\-*ㅇ\s]+", "", re.sub(r"\s+", "", m.group("name"))) return bi.trade(name) # ── 인력 줄 가름(엑셀꼴) ───────────────────────────────────────────── _SWAP = [ ("m3", "㎥"), ("M3", "㎥"), ("m2", "㎡"), ("M2", "㎡"), ("m", "m"), ("Ton", "톤"), ("TON", "톤"), ("ton", "톤"), ("굴착기", "굴삭기"), ("백호", "굴삭기"), ("B/H", "굴삭기"), ("BACK-HOE", "굴삭기"), ("브레카", "브레이커"), ("브레이카", "브레이커"), ("뒤채움", "뒷채움"), ("Ⅰ", "I"), ("Ⅱ", "II"), ("ll", "II"), ("=", "="), (":", ":"), ] _TOTAL = re.compile(r"^(소계|계|합계|총계|인부소계|자재소계|주자재소계|인건비소계)[:]?#?$") def _fk(s: Any) -> str: s = unicodedata.normalize("NFKC", str(s or "")) for a, b in _SWAP: s = s.replace(a, b) return s def _skel(line: str) -> str: s = re.sub(r"(?<=[\d\s)])[xX×](?=[\s\d(])", "*", _fk(line)).replace("×", "*").replace("÷", "/") s = re.sub(r"(?<=\d)\.(?=[*/=%+\-)\s]|$)", "", re.sub(r"\s+", "", s)) return re.sub(r"\d[\d,]*(?:\.\d+)?", "#", s) @lru_cache(maxsize=1) def _names() -> dict: return est.trade_names() def labor(line: str) -> bool: """인력 줄(「보통인부 : …」) — 소계 · 품셈 근거 줄 뺌.""" s = _fk(line).strip() f, sk = re.sub(r"\s+", "", s), _skel(s) if ( not f or _TOTAL.match(sk) or re.match(r"^(\d+\.)?(합계|소계|계)[:]?", f) and "*" not in sk and "/" not in sk ): return False if re.search(r"품[셈줌]", s) and "=" not in s: return False cm = est._COLON.match(s) nm = ( re.sub( r"^[○◇□※·\-*]+", "", re.sub(r"^[0-9가-힣]\.", "", re.sub(r"\s+", "", cm.group("name"))) ) if cm else "" ) return bool(cm and (_names().get(nm) or bi.trade(nm))) # ── 블록 → 줄 · 절 · 상수 ──────────────────────────────────────────── def results_of(lines: list[str], items: list[dict]) -> tuple[set, set]: prices, results = set(), set() for it in items or []: prices |= { float(v) for v in (it.get("단가") or {}).values() if isinstance(v, (int, float)) and v } if isinstance(it.get("금액"), (int, float)): results.add(float(it["금액"])) if isinstance(it.get("기준값"), (int, float)): results.add(round(float(it["기준값"]), 1)) for t in lines: s = nk(t) results |= {_f(s[a:z]) for a, z in result_spans(s)} if re.search(r"(소\s*계|합\s*계|총\s*계|^\s*계\s*:)", s): results |= {_f(m.group()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s)} results.discard(None) return prices, results def block_lines(blk: dict, kind: str, g: list[str] | None) -> list[dict]: """[{글(절 나누기), 수글, g}] — STmate + 숨은 G 식이 있으면 G · 그 밖 보이는 글.""" out = [] if kind == "STmate" and g: for gl in g: tree, s = g_text(gl) if tree.strip() and not re.fullmatch(r"\s*\(-?=+\)\s*", tree): out.append({"글": tree, "수글": s, "g": True}) else: out = [{"글": t, "수글": t, "g": False} for t in blk.get("글") or [] if t and t.strip()] return out def sections(lines: list[dict]) -> list[tuple[str, list[int]]]: texts = [ln["글"] for ln in lines] works = formula.works(formula.tree(texts, list(range(len(texts))))) if not works: return [("(블록 통째)", list(range(len(texts))))] return [ (f"{w['번']}. {w['제목']}"[:40], list(range(w["글"][0], w["글"][1] + 1))) for w in works ] def prep(blk: dict, kind: str, g: list[str] | None = None) -> dict: """블록 → 줄 · 줄별 (상수, 입력) · 절.""" lines = block_lines(blk, kind, g) prices, results = results_of(blk.get("글") or [], blk.get("항목") or []) per_line, bodies = [], [] for ln in lines: cs, ins, body = extract(ln["수글"], prices, results, ln["g"]) per_line.append([cs, ins]) bodies.append(body) syms: dict = {} for cs, _ in per_line: for c in cs: if c["기호"] and c["기호"] not in syms: syms[c["기호"]] = c["v"] for i, ln in enumerate(lines): lab = re.search(r"&L\d", ln["글"]) if ln["g"] else labor(ln["글"]) tr = trade_of(ln["글"]) if lab else None qs = quotients(bodies[i], bool(lab), syms) if tr: tops = {q["몫"].split("/")[0] for q in qs} for c in per_line[i][0]: if fam(c["단위"]) == "사람" or f"{c['v']:g}" in tops: c["직종"] = tr for q in qs: q["직종"] = tr per_line[i][0] += qs in_vals = {round(c["v"], 9) for _, ins in per_line for c in ins if c.get("기호")} for cs, _ in per_line: cs[:] = [c for c in cs if round(c["v"], 9) not in in_vals or c.get("몫")] return {"lines": lines, "per_line": per_line, "secs": sections(lines) if lines else []} def gather(per_line: list, ids: Any) -> tuple[list[dict], list[dict]]: """줄 묶음 → (상수 K, 입력) — 같은 수 · 율과 소수 쌍(2 % · 0.02)은 하나(단위 있는 쪽).""" K: list[dict] = [] seen, ins, at = set(), [], {} for i in ids: cs, ii = per_line[i] ins += ii for c in cs: vk = (round(c["v"], 9), c.get("직종"), bool(c.get("몫"))) if vk in at and fam(c["단위"]) != "율" and fam(K[at[vk]]["단위"]) != "율": if not K[at[vk]]["단위"] and c["단위"]: K[at[vk]] = {**c, "줄": i} continue key = (round(c["v"], 9), fam(c["단위"]), c.get("직종")) twin = ( (round(c["v"] * 100, 9), "율", None) if fam(c["단위"]) is None and c["v"] < 1 else ((round(c["v"] / 100, 9), None, None) if fam(c["단위"]) == "율" else None) ) if key in seen or twin in seen: continue seen.add(key) at.setdefault(vk, len(K)) K.append({**c, "줄": i}) return K, ins def corpus(preps: list[dict]) -> list[list]: """보정 자료 — 블록들 상수(일반 뺌 · 같은 꼴 하나) [[v, d, 단위, 직종, 몫]].""" keys: dict = {} for pr in preps: for c in gather(pr["per_line"], range(len(pr["lines"])))[0]: if not c.get("일반"): keys[bi.kkey(c)] = None return [list(k) for k in keys]