"""M02 일위대가 맞춤 낱말 · 식 도우미(PLAN 338-1 · 설계 2-2) — 엑셀 지문 · 우리 지문이 같이 씀. 장비 갈래(`mtypes`) · 규격 수(`specnums`) · 조건 낱말(`conds` · `cond_score`) · 식 풀기(`parse_rhs` — 「단가 × 식 = 금액」 → 품 · 맨 앞 단가는 뗌 · 금액 안 씀) · 닮음(`close` · `cosine` · `jacc`). 값 · 문턱은 336 실험(C4 `fp_common`) 그대로. """ from __future__ import annotations import math import re import unicodedata from typing import Any # ── 장비 갈래(엑셀 글 · 우리 EQ 상세구분 공통) ───────────────────────────── MACH = ( ("굴착기", ("굴착기", "굴삭기", "백호", "b/h", "backhoe", "back-hoe", "포크레인")), ("덤프트럭", ("덤프",)), ("크레인", ("크레인",)), ("브레이커", ("브레이커", "브레이카")), ("콘크리트펌프", ("펌프차", "펌프카", "콘크리트펌프", "콘크리트 펌프")), ("롤러", ("롤러", "로울러")), ("불도저", ("불도저", "불도우저")), ("집게", ("집게",)), ("살수차", ("살수", "물탱크")), ("모터그레이더", ("그레이더",)), ("래머", ("래머", "램머", "래 머")), ("콤팩터", ("콤팩터", "컴팩터")), ("진동기", ("진동기",)), ("발전기", ("발전기",)), ("공기압축기", ("공기압축기", "컴프레셔", "콤프레샤")), ("드릴", ("드릴", "천공기")), ("커터", ("커터",)), ("화물차", ("화물자동차", "화물차", "카고")), ("로더", ("로더",)), ("지게차", ("지게차",)), ("체인톱", ("체인톱",)), ("트랙터", ("트랙터",)), ("믹서", ("믹서",)), ("분무기", ("분무기",)), ) _SPECNUM = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|m\b|㎾|kw|ℓ|mm|hp)", re.I) def nfkc(text: Any) -> str: return unicodedata.normalize("NFKC", str(text or "")) def mtypes(text: Any) -> set[str]: t = nfkc(text).lower() return {k for k, ws in MACH if any(w in t for w in ws)} def specnums(text: Any) -> set[float]: return {float(a) for a, _ in _SPECNUM.findall(nfkc(text))} # ── 조건 낱말(축) ────────────────────────────────────────────────────── SOIL = ("보통토사", "경질토사", "혼합토사", "자갈섞인토사", "사질토", "점질토", "토사") ROCK = ("리핑암", "발파암", "풍화암", "연암", "보통암", "경암", "극경암") AXES = { "토질": SOIL + ROCK, "메찰": ("메쌓기", "찰쌓기", "메붙임", "찰붙임", "메붙이기", "찰붙이기"), "형": ("원형", "사각", "타원형"), "근": ("무근", "철근"), "난이": ("제물치장", "매우복잡", "복잡", "보통", "간단"), "규모": ("소량", "소형", "소규모", "중규모", "대규모", "대형"), "방법": ("인력운반", "인력", "기계", "장비", "펌프"), } CLASH = ("토질", "메찰", "형") # 이름에 박힌 값이 엑셀과 어긋나면 깎는 축 _ROCKISH = re.compile(r"(\(암\)|암\)|[^가-힣]암$|^암$|암절취|\b암\b)") def soil_family(word: str) -> str | None: if word in ROCK or word == "암": return "암" return "토사" if word in SOIL else None def conds(text: Any) -> dict[str, set[str]]: """{축: {낱말}} + 횟수 · 개월 · Type.""" t = re.sub(r"\s+", "", nfkc(text)) out: dict[str, set[str]] = {} for ax, ws in AXES.items(): hit, rest = set(), t for w in sorted(ws, key=len, reverse=True): if w in rest: hit.add(w) rest = rest.replace(w, " ") if ax == "토질" and _ROCKISH.search(t): hit.add("암") if hit: out[ax] = hit if n := re.findall(r"(\d)회", t): out["횟수"] = {f"{x}회" for x in n} if m := re.findall(r"(\d+)개월", t): out["개월"] = {f"{x}개월" for x in m} if ty := re.findall(r"type[-\s]*(ⅰ|ⅱ|ii|i)\b", t.lower()): out["type"] = {"Ⅱ" if x in ("ⅱ", "ii") else "Ⅰ" for x in ty} return out def cond_score(excel: dict, name_c: dict, opt_c: dict) -> tuple[float, int]: """(맞음 비율, 어긋남) — 엑셀 조건 낱말이 우리 이름 · 고르기 글에 있나 · 이름 축 어긋남.""" words = [(ax, w) for ax, ws in excel.items() for w in ws] if not words: return 0.0, 0 hit = 0 for ax, w in words: pool = name_c.get(ax, set()) | opt_c.get(ax, set()) fams = {soil_family(x) for x in pool} if w in pool or (ax == "토질" and soil_family(w) in fams): hit += 1 clash = 0 for ax in CLASH: if ax in excel and ax in name_c: if ax == "토질": ef = {soil_family(w) for w in excel[ax]} of = {soil_family(w) for w in name_c[ax]} clash |= bool(ef and of and not ef & of) elif not excel[ax] & name_c[ax]: clash = 1 return hit / len(words), clash # ── 식 풀기 — 「단가 × 식 = 금액」 → 품(단가로 나눈 몫 · 단가는 버림) ───────── _UNITFRAC = re.compile( r"(인|개|본|공|EA|ea|hr|ton|Ton|TON|kg|㎥|㎡|m3|m2|m|M|조|회)\s*/\s*" r"(EA|ea|㎥|㎡|m3|m2|m|M|ton|Ton|TON|t|hr|일|개|본|조|공㎥|㎥)" ) _UNITTAIL = re.compile( r"(?<=[\d.)])\s*(hr|HR|EA|ea|kg|km|ton|Ton|TON|sec|m3|m2|mm|cm|M|m|t)(?![A-Za-z])" ) _NOTE = re.compile(r"\(([^()]*[가-힣][^()]*)\)") _LEAD = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([*/])\s*(.+)$") _SAFE = re.compile(r"^[\d.+\-*/() ]+$") def _norm(s: str) -> str: s = nfkc(s).replace("×", "*").replace("✕", "*").replace("÷", "/") s = s.replace("x", "*").replace("X", "*") s = re.sub(r"(?<=\d),(?=\d{3})", "", s) return _UNITFRAC.sub("", s) def _split(rest: str, first_op: str) -> list[tuple[str, str]]: """맨 바깥 * / 로 쪼갬 → [(연산, 조각)].""" out, depth, cur, op = [], 0, "", first_op for ch in rest: depth += (ch == "(") - (ch == ")") if depth == 0 and ch in "*/": out.append((op, cur)) op, cur = ch, "" continue cur += ch out.append((op, cur)) return out def _factor(text: str) -> tuple[float, bool, str] | None: """조각 → (값, 율 표시, 메모) · 변수(P · Q …)면 None.""" notes = _NOTE.findall(text) t = _NOTE.sub("", text) pct = "%" in t if re.search(r"\d\s*회", t): # 「× 2회」 — 횟수 곱(율로 가름) notes.append("회") t = re.sub(r"[가-힣㎥㎡㎝㎜℃˚°'\"]", "", t).replace("%", "") t = _UNITTAIL.sub("", t).strip() if not t or re.search(r"[A-Za-z]", t) or not _SAFE.match(t): return None try: v = eval(t, {"__builtins__": {}}, {}) # noqa: S307 — 숫자 · 연산 글만(_SAFE) except Exception: # noqa: BLE001 — 0 나눔 · 괄호 짝 틀림 return None return float(v) / (100 if pct else 1), pct or bool(notes), " ".join(notes) def _rates_any(parts: list[str]) -> list[tuple[float, str]]: """모든 조각의 율(메모 · %) — 풀 수 있는 조각만.""" out: list[tuple[float, str]] = [] for seg in parts: m = _LEAD.match(seg) if not m: continue for op, piece in _split(m.group(3), m.group(2)): f = _factor(piece) if f and f[1] and f[0] and all(abs(f[0] - v) > 1e-9 for v, _ in out): out.append((f[0] if op == "*" else 1 / f[0], f[2] or "%")) return out def parse_rhs(rhs: str) -> dict[str, Any] | None: """「단가 × 식 [= 바꿔 쓴 식] = 금액」 → {단가, 품, 밑품, 율, 인자} · 못 읽으면 None.""" parts = _norm(rhs).split("=") if len(parts) < 2: return None every = _rates_any(parts[:-1]) for seg in parts[:-1]: m = _LEAD.match(seg) if not m: continue facs = [] for op, piece in _split(m.group(3), m.group(2)): f = _factor(piece) if f is None: break facs.append((op, *f)) else: if not facs or any(v == 0 and op == "/" for op, v, _, _ in facs): continue full = base = 1.0 rates = [] for op, v, rate, note in facs: full = full * v if op == "*" else full / v if rate: rates.append([round(v if op == "*" else 1 / v, 6), note or "%"]) else: base = base * v if op == "*" else base / v if not rates and every: # 바꿔 쓴 식엔 메모가 빠짐 — 앞 조각의 율로 밑품 for v, _ in every: base /= v or 1 rates = [[round(v, 6), note] for v, note in every] plain = [v if op == "*" else 1 / v for op, v, rate, _ in facs if not rate and v] return {"단가": float(m.group(1)), "품": full, "밑품": base, "율": rates, "인자": plain} return None # ── 닮음 ─────────────────────────────────────────────────────────────── def close(a: Any, b: Any, tol: float = 0.15) -> float: """두 양수 가까움 0~1 — ln 비 3% 안 = 1 · 그 밖은 떨어짐.""" if not a or not b or a <= 0 or b <= 0: return 0.0 d = abs(math.log(a / b)) return 1.0 if d <= 0.03 else max(0.0, 1 - (d - 0.03) / (tol * 4)) def cosine(a: dict, b: dict) -> float: num = sum(a.get(k, 0) * b.get(k, 0) for k in set(a) | set(b)) da = math.sqrt(sum(v * v for v in a.values())) db = math.sqrt(sum(v * v for v in b.values())) return num / (da * db) if da and db else 0.0 def jacc(a: Any, b: Any) -> float: a, b = set(a), set(b) return len(a & b) / len(a | b) if a | b else 0.0