Files
Aislo/M02_MasterTemplete/M02_Hopyo_Words.py
T
eomsangdonandClaude Opus 5.5 e9e949112c feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — 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
2026-10-15 23:36:23 +09:00

248 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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