- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보) - Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음 - 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미 - 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
248 lines
9.9 KiB
Python
248 lines
9.9 KiB
Python
"""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
|