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
This commit is contained in:
2026-10-15 23:36:23 +09:00
co-authored by Claude Opus 5.5
parent 7a8f334520
commit e9e949112c
15 changed files with 6629 additions and 1 deletions
+300
View File
@@ -0,0 +1,300 @@
"""M02 일위대가 맞춤 — 조합 풀이(PLAN 338-1 · 설계 2-7): 블록 몫(절마다 노임 · 장비 벡터)을 우리
로직 단위당 벡터 조합으로 풂 · 섞인 줄은 인력 몫 로직이 1순위(몫 먼저).
맞음 = 차원(직종 · 장비 갈래)마다 작은/큰 가중 평균(보통인부 0.5 · 장비 있음 0.5 + 시간 0.5 ·
크기 다르면 × 0.8 · 갈래 일부만 같으면 × 0.8) · 몫 x(0.1 … 0.9) × 0.95 · 제목 몫 수와 같은 x 면
+0.1.
덧 로직 = 1순위가 못 덮는(< COVER) 절은 절 1위(≥ 0.75) · 아니면 몫마다(≥ 0.6) · 장비만 든 몫 =
「기계 시간당 기계경비」(체크 안 함 · 시간 = 엑셀 값).
순위 덤 = 앞 차례 0.3 · 인용 조항 같음 0.3 · 같은 책 0.1 · 후보 풀 = 앞 차례 15 + 드문 직종 · 장비
색인
(40 개 이하) + 절 제목 이름 후보 8. 우리 벡터 = `M02_Hopyo_Ours` 지문 변형 L · X(따로 안 셈).
규칙 · 문턱은 336 실험(C5 `c03_vec` · `c05_solve` · C7 `solve`) 그대로 · 문턱은 `M02_Hopyo_Fit`
머리.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Ours as ours
COMMON = "보통인부"
MACHINE = {"굴착기": "GC000267"} # 굴착기(무한궤도) 시간당 기계경비 · 그 밖 장비 = GC000268
MACHINE_ANY = "GC000268"
MACHINE_SET = {"GC000267", "GC000268", "GC000269", "GC000275"}
_SHARE = re.compile(r"(\d{1,3})\s*%")
_RATIO = re.compile(r"(\d)\s*:\s*(\d)")
_STATE: dict[str, Any] = {"판": None}
# ── 우리 벡터 · 색인 ───────────────────────────────────────────────────
def _vec(v: dict[str, Any]) -> dict[str, Any]:
eq = [(frozenset(a), frozenset(b), q) for a, b, q in v.get("X") or [] if a and q]
return {"노임": {t: q for t, q in (v.get("L") or {}).items() if q}, "장비": eq}
def _load() -> dict[str, Any]:
"""{변형: {로직: [(입력, 벡터)]}, 색인: {차원: {로직}}} — 우리 지문 판마다 한 번."""
table = ours.table() or []
ver = ours.version()
if _STATE["판"] != (ver, id(table)):
var: dict[str, list] = {}
idx: dict[str, set] = {}
for o in table:
vs = [(x.get("in") or {}, _vec(x)) for x in o.get("변형") or []]
var[o["로직"]] = [(g, v) for g, v in vs if v["노임"] or v["장비"]]
if var[o["로직"]]:
for d in dims(var[o["로직"]][0][1]):
idx.setdefault(d, set()).add(o["로직"])
_STATE.update(판=(ver, id(table)), 변형=var, 색인=idx)
return _STATE
def variants(code: str) -> list[tuple[dict, dict]]:
return _load()["변형"].get(code) or []
def _kinds(key: str) -> tuple[frozenset, str]:
kind, _, size = key.partition("|")
return frozenset(kind.split("+")), size
def dims(E: dict[str, Any]) -> set[str]:
out = {t for t in E.get("노임") or {} if t != COMMON}
eq = E.get("장비") or {}
if isinstance(eq, dict):
return out | {"+".join(sorted(_kinds(k)[0])) for k in eq}
return out | {"+".join(sorted(a)) for a, _, _ in eq}
def sim(E: dict[str, Any], ov: dict[str, Any], x: float = 1.0) -> float:
"""E = 엑셀 몫 {노임, 장비: {「굴착기|0.6」: 시간}} · ov = 우리 벡터 — 0..1."""
num = den = 0.0
et, ot = E.get("노임") or {}, ov.get("노임") or {}
for t in set(et) | set(ot):
w = 0.5 if t == COMMON else 1.0
a, b = et.get(t, 0.0), ot.get(t, 0.0) * x
den += w
num += w * min(a, b) / max(a, b) if a > 0 and b > 0 else 0
oe = ov.get("장비") or []
used = set()
for k, a in (E.get("장비") or {}).items():
kinds, size = _kinds(k)
best, pick = 0.0, None
for at, (kinds2, sizes2, b) in enumerate(oe):
if not kinds & kinds2:
continue
f = 1.0 if (not size or not sizes2 or float(size) in sizes2) else 0.8
f *= 1.0 if kinds == kinds2 else 0.8 # 굴착기 ↔ 굴착기+집게
b *= x
s = f * (0.5 + 0.5 * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0))
if s > best:
best, pick = s, at
den += 1.0
num += best
if pick is not None:
used.add(pick)
den += sum(1.0 for at in range(len(oe)) if at not in used)
return num / den if den else 0.0
def fit_one(E: dict[str, Any], code: str, share: bool) -> tuple[float, dict, float]:
"""(맞음, 입력, 몫 x) — 변형마다 x = 1 과 몫 x 중 가장 좋은 것."""
top = (0.0, {}, 1.0)
for given, ov in variants(code):
s = sim(E, ov)
if s > top[0]:
top = (s, given, 1.0)
if share:
for x in fit.SHARES:
s2 = 0.95 * sim(E, ov, x)
if s2 > top[0]:
top = (s2, given, x)
return top
def cover(E: dict[str, Any], code: str) -> float:
"""E 의 차원을 로직 벡터가 얼마나 덮나(로직의 남는 차원은 안 셈)."""
top = 0.0
for _, ov in variants(code):
num = den = 0.0
for t, a in (E.get("노임") or {}).items():
w = 0.5 if t == COMMON else 1.0
b = (ov.get("노임") or {}).get(t, 0.0)
den += w
num += w * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0)
for k in E.get("장비") or {}:
den += 1
num += any(_kinds(k)[0] & a for a, _, _ in ov.get("장비") or [])
top = max(top, num / den if den else 0)
return top
# ── 후보 풀 · 앞선 믿음 ────────────────────────────────────────────────
def title_cands(title: str, unit: str, got: dict, n: int = 8) -> list[str]:
text = re.sub(r"\s+", "", re.sub(r"[\d.·:()\[\]%]+", " ", str(title or "")))
if len(text) < 2:
return []
found = hopyo_match.candidates({"이름": text, "단위": unit}, "로직", got, n)
return [c["로직"] for c in found if c.get("로직")]
class Prior:
"""앞선 믿음 — 앞 차례 · 줄 근거 조항(윗마디 포함) · 같은 책."""
def __init__(self, line: dict[str, Any], ranked: list[str]):
self.rank = {c: k for k, c in enumerate(ranked)}
self.n = max(len(ranked), 1)
self.theirs = [list(x) for x in ((line.get("근거") or {}).get("출처") or [])]
self.books = {b for b, _ in self.theirs}
self.table = hopyo_basis.ours()
def __call__(self, code: str) -> float:
p = 0.3 * (1 - self.rank[code] / self.n) if code in self.rank else 0.0
our = self.table.get(code)
if our and self.theirs:
p += 0.3 if hopyo_match.clause(self.theirs, our)[0] >= 0.9 else 0
p += 0.1 if our["책"] in self.books else 0
return p
def _sum(parts: list[dict]) -> dict[str, Any]:
out: dict[str, dict] = {"노임": {}, "장비": {}}
for s in parts:
for k in ("노임", "장비"):
for d, q in (s.get(k) or {}).items():
out[k][d] = out[k].get(d, 0) + q
return out
def pool_for(E: dict[str, Any], base: list[str], extra: list[str]) -> list[str]:
idx = _load()["색인"]
rare: set[str] = set()
for d in dims(E):
if len(idx.get(d, ())) <= 40:
rare |= idx.get(d, set())
return list(dict.fromkeys([*base, *extra, *sorted(rare)]))
def _hint(title: Any) -> float | None:
m = _SHARE.search(str(title or ""))
return int(m.group(1)) / 100 if m and 0 < int(m.group(1)) < 100 else None
def ranked(E: dict, pool: list[str], prior: Prior, share: bool, hint: float | None) -> list[tuple]:
"""[(순위 점수, 맞음, 몫 x, 로직, 입력)] — 순위 차례 · 제목 몫과 같은 x 면 +0.1."""
out = []
for c in pool:
f, given, x = fit_one(E, c, share)
if f <= 0:
continue
score = f + prior(c)
if hint is not None:
score += 0.1 if abs(x - hint) < 1e-9 else -0.1
out.append((score, f, x, c, given))
out.sort(key=lambda z: -z[0])
return out
def is_mixed(parts: list[dict], line: dict[str, Any]) -> bool:
"""섞인 줄 — 제목 · 이름 · 규격에 몫 표시 + 노임만 든 몫 + 장비만 든 몫."""
text = " ".join(
[str(p.get("제목")) for p in parts] + [str(line.get(k)) for k in ("이름", "규격")]
)
marked = bool(_SHARE.search(text) or _RATIO.search(text))
man = any(p.get("노임") and not p.get("장비") for p in parts)
mach = any(p.get("장비") and not p.get("노임") for p in parts)
return marked and man and mach
def machine_of(E: dict[str, Any]) -> str:
kinds = {frozenset(_kinds(k)[0]) for k in E.get("장비") or {}}
return MACHINE["굴착기"] if kinds == {frozenset({"굴착기"})} else MACHINE_ANY
# ── 풀이 ───────────────────────────────────────────────────────────────
def combine(
primary: str | None, parts: list[dict], line: dict, got: dict, base: list[str]
) -> list[dict[str, Any]]:
"""[{로직, 어디, 맞음, 몫, 단계, 입력}] — 1순위 + 1순위가 못 덮는 절 · 몫의 로직."""
one = lambda c, w, f, x, step, g=None: { # noqa: E731
"로직": c,
"어디": w,
"맞음": f,
"몫": x,
"단계": step,
"입력": g or {},
}
chosen = [one(primary, "1순위", None, 1.0, "1순위")] if primary else []
if not parts:
return chosen
prior, unit = Prior(line, base), str(line.get("단위") or "")
mixed = is_mixed(parts, line)
groups: dict[str, list] = {}
for p in parts:
groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p)
have = {primary} if primary else set()
def machine(E: dict, where: str) -> None:
if mixed or len(groups) > 1:
m = machine_of(E)
chosen.append(one(m, where, None, 1.0, "장비 몫"))
have.add(m)
for title, ps in groups.items():
E = _sum(ps)
if primary and cover(E, primary) >= fit.COVER:
continue
if E["장비"] and not E["노임"]: # 장비만 든 절 = 기계 시간 로직(닫힌 세계 규칙)
machine(E, title)
continue
share = any(_hint(p.get("제목")) for p in ps) or _hint(title) is not None
pool = pool_for(E, base, title_cands(title, unit, got))
cand = ranked(E, pool, prior, share, _hint(title))
if cand and cand[0][1] >= fit.COMBO[1] and cand[0][3] not in have:
chosen.append(
one(cand[0][3], title, round(cand[0][1], 3), cand[0][2], "절", cand[0][4])
)
have.add(cand[0][3])
continue
for p in ps:
if primary and cover(p, primary) >= fit.COVER:
continue
if p.get("장비") and not p.get("노임"):
machine(p, p.get("제목"))
continue
hint = _hint(p.get("제목"))
pool = pool_for(p, base, title_cands(p.get("제목"), unit, got))
cand = ranked(p, pool, prior, hint is not None or share, hint)
if cand and cand[0][1] >= fit.COMBO[2] and cand[0][3] not in have:
f, x, c, g = round(cand[0][1], 3), cand[0][2], cand[0][3], cand[0][4]
chosen.append(one(c, p.get("제목"), f, x, "몫", g))
have.add(c)
return chosen
def solve(
top: list[str], parts: list[dict], line: dict, got: dict, base: list[str]
) -> tuple[list[str], list[dict[str, Any]]]:
"""(새 차례, 조합 대상) — 섞인 줄이고 1순위가 블록 합을 MIXED_CUT 못 덮으면 1순위를 인력 몫
로직으로 바꿈(몫 먼저) · 조합이 하나뿐이면 대상 빈 것."""
chosen = combine((top or [None])[0], parts, line, got, base)
codes = list(dict.fromkeys(c["로직"] for c in chosen if c["로직"]))
if top and len(codes) > 1 and parts and is_mixed(parts, line):
if cover(_sum(parts), top[0]) < fit.MIXED_CUT:
labor = [c for c in chosen if c["로직"] != top[0] and c["단계"] in ("몫", "절")]
if labor:
chosen = [c for c in chosen if c["로직"] != top[0]]
top = [labor[0]["로직"]] + [c for c in top if c != labor[0]["로직"]][:4]
seen: set[str] = set()
chosen = [
c for c in chosen if not (c["로직"] in seen or seen.add(c["로직"]))
] # 같은 로직은 처음 것
if len(chosen) < 2:
return top, []
return top, [{**c, "체크": c["로직"] not in MACHINE_SET or c["로직"] == top[0]} for c in chosen]
+309
View File
@@ -0,0 +1,309 @@
"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2) — 호표 줄 + 이어진 단가산출 블록 →
직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀(식 맨 앞 단가는 뗌).
엑셀꼴 = 블록 `글`(머리 뺀 몸 줄)을 줄마다 풂 — 절 `^\\d+\\.` · 작은 절 `^[가-하]\\.` ·
직종 줄 「직종 : 단가 × 식 = 금액」(우리 인력 이름 · 옛이름) · 장비 = 「노무비 · 경비 · 재료비 :
단가 ÷ Q」
+ 지금 장비(노무비 > 경비 > 재료비) · 자재 = 「이름 단가 × 수량 = 금액」 · 「: 별도계상」 ·
못 풀면 금액 ÷ 단가(「역산」 · 품 되찾기만).
STmate = 블록 `자원`(L · X · M · S) 품이 곧 품(노무비 · 재료비 · 경비 세 줄 같은 품은 한 번) ·
섞음 = 이름 · 규격 몫 둘(작은 쪽 인력 · 큰 쪽 장비).
몫(조합용 · 설계 2-7) = 맨 윗 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares`).
블록 없는 기계(hr) 줄 = `machine` · 노임 · 장비를 바로 적은 호표 = `direct`.
값 · 규칙은 336 실험(C4 `s1_excel_fp` · K5 `stmate`) 그대로.
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete.M02_Hopyo_Shares import trade
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs, specnums
COST = {"노무비", "재료비", "경비"}
SKIPW = {
"소계",
"계",
"합계",
"인부소계",
"자재소계",
"주자재소계",
"운반",
"상차",
"기계",
"다짐",
"적사",
"살수",
"집토",
"암절취",
"채집",
"인력",
"장비",
"작업조건",
"1일시공량",
"작업면적",
"1시간작업량",
"기계채집",
"구조물터파기",
}
_COLON = re.compile(
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,40}?)\s*[::]\s*(?P<rhs>.*)$"
)
_MAT = re.compile(
r"^\s*(?P<name>[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P<rhs>[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$"
)
_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%")
_COEF = re.compile(
r"(?<![A-Za-z가-힣])(q|qs|q1|K|k|f|f1|f2|E|Es|Cm|L|V1|V2|T|rt|n|t1|t2|t3|t4)\s*=\s*"
r"(\d+(?:\.\d+)?)(?![\d.]*\s*[×x*÷/])"
)
_HEAD = re.compile(r"^\s*(\d+)\.\s*(.*)$")
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
OLD = re.compile(
r"((?<![\d,.])20(0\d|1\d|20)(?![\d,.])|(?<!\d)(1\d|20)년|임도품셈|임도표준품셈|사방품셈|사방사업표준품셈)"
)
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
_HOURS = re.compile(r"(hr|시간)", re.I)
TRADE_UNITS = ("인",)
def _flat(s: Any) -> str:
return re.sub(r"\s+", "", str(s or ""))
def _ratio(rhs: str) -> float | None:
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용)."""
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
n = _LASTNUM.search(rhs)
if not m or not n:
return None
p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", ""))
return a / p if p else None
def rates_in(text: Any) -> set[float]:
"""글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1."""
t = str(text or "")
out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t)}
out = {v for v in out if 0 < v < 1}
for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t):
a, b = float(a), float(b)
if a and b and a + b <= 20:
out |= {round(a / (a + b), 4), round(b / (a + b), 4)}
return out
def empty(line: dict[str, Any], block: dict[str, Any] | None = None) -> dict[str, Any]:
block = block or {}
basis = line.get("근거") or {}
name, spec = str(line.get("이름") or ""), str(line.get("규격") or "")
return {
"이름": name,
"규격": spec,
"단위": str(line.get("단위") or ""),
"출처": [list(x) for x in block.get("출처") or basis.get("출처") or []],
"인력": {}, # 직종: {품, 밑품, 율, 어떻게}
"장비": [], # {갈래, 규격, 시간, 밑시간, 율, 절, 글}
"자재": [], # {이름, 품, 밑품}
"율": [], # [바탕, %]
"계수": {},
"조건": {},
"이름조건": {k: sorted(v) for k, v in conds(f"{name} {spec}").items()},
"옛판": False,
"몫": [],
}
def _labor(fp: dict, name: str, q: float, base: float, rates: list, how: str) -> None:
one = fp["인력"].setdefault(name, {"품": 0.0, "밑품": 0.0, "율": [], "어떻게": []})
one["품"] += q
one["밑품"] += base
one["율"] += rates
one["어떻게"].append(how)
def _round(fp: dict[str, Any]) -> dict[str, Any]:
for one in fp["인력"].values():
one["품"], one["밑품"] = round(one["품"], 6), round(one["밑품"], 6)
return fp
def excel(block: dict[str, Any] | None, line: dict[str, Any]) -> dict[str, Any]:
"""HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 글 · 자원이 없으면
기계(hr) 줄 지문(장비 1 시간) 또는 빈 지문."""
if block and block.get("자원"):
return _round(_stmate(block, line))
if block and block.get("글"):
return _round(_text(block, line))
return machine(line)
def machine(line: dict[str, Any]) -> dict[str, Any]:
"""블록 없는 기계(hr) 줄 → 장비 [{갈래, 규격, 시간 1}]."""
fp = empty(line)
text = f"{fp['이름']} {fp['규격']}"
types = mtypes(text)
if types:
hours = 1.0 if _HOURS.search(fp["단위"]) else None
one = {"갈래": sorted(types), "규격": sorted(specnums(text)), "시간": hours}
fp["장비"] = [{**one, "밑시간": None, "율": [], "절": "0", "글": fp["이름"]}]
fp["조건"] = fp["이름조건"]
return fp
def _text(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
"""엑셀꼴 — 블록 글 줄마다 풂(C4 `north`)."""
fp = empty(line, block)
head = f"{block.get('번', '')}.{block.get('이름', '')} [{block.get('단위', '')}]"
text = [head, *[str(t) for t in block.get("글") or []]]
front = [t for t in text[:5]] + [t for t in text if "※" in t or "작업조건" in t]
head_text = " ".join([fp["이름"], fp["규격"], *front])
fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()}
fp["옛판"] = bool(OLD.search(" ".join(text[:6])))
sec, sub, mach, label = 0, "", None, ""
got_cost: dict[tuple, tuple] = {}
item_rates = rates_in(f"{fp['이름']} {fp['규격']}")
sec_rates: set[float] = set()
def unmix(got: dict | None) -> dict | None:
"""율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름."""
if got and not got["율"]:
for v in got.get("인자") or []:
if round(v, 4) in item_rates | sec_rates and 0 < v < 1:
return {**got, "밑품": got["밑품"] / v, "율": [[round(v, 4), "섞음"]]}
return got
for raw in text[1:]:
row = raw.rstrip()
h, s = _HEAD.match(row), _SUB.match(row)
if h or s:
if h:
sec, sub = int(h.group(1)), ""
sec_rates = rates_in(h.group(2))
else:
sub = s.group(1)
sec_rates = sec_rates | rates_in(s.group(2))
title = (h or s).group(2)
ts = mtypes(title)
mach = (ts, specnums(title)) if ts else None
label = title.strip() if ts else ""
for k, v in _PCT.findall(row):
fp["율"].append([k, float(v)])
cm = _COLON.match(row)
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
who = hopyo_basis.trade_names().get(name) if cm else None
if who:
rhs = cm.group("rhs")
got = unmix(parse_rhs(rhs))
if got:
_labor(fp, who, got["품"], got["밑품"], got["율"], "식")
elif r := _ratio(rhs):
_labor(fp, who, r, r, [], "역산")
continue
if cm and name in COST:
got = unmix(parse_rhs(cm.group("rhs")))
if got and mach:
key = (sec, sub, label)
prio = 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
if key not in got_cost or prio < got_cost[key][0]:
got_cost[key] = (prio, mach, got)
continue
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
fp["자재"].append({"이름": cm.group("name").strip(), "품": None, "밑품": None})
continue
mm = _MAT.match(row)
if mm and not cm and (got := parse_rhs(mm.group("rhs"))):
fp["자재"].append(
{"이름": mm.group("name").strip(), "품": got["품"], "밑품": got["밑품"]}
)
continue
if not (h or s):
ts = mtypes(row)
if ts and "%" not in row and not cm:
mach, label = (ts, specnums(row)), row.strip()
for k, v in _COEF.findall(row):
fp["계수"].setdefault(k, float(v))
for (sec_, sub_, lab), (_, (ts, sp), got) in got_cost.items():
where = f"{sec_}.{sub_}" if sub_ else str(sec_)
one = {"갈래": sorted(ts), "규격": sorted(sp), "시간": got["품"], "밑시간": got["밑품"]}
fp["장비"].append({**one, "율": got["율"], "절": where, "글": lab})
fp["몫"] = shares.sections(text[1:])
return fp
def _stmate(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
"""STmate — 자원 품이 곧 품(K5 `stmate`)."""
fp = empty(line, block)
text = [str(t) for t in block.get("글") or []][:60]
fp["조건"] = {
k: sorted(v) for k, v in conds(" ".join([fp["이름"], fp["규격"], *text[:4]])).items()
}
fp["옛판"] = bool(OLD.search(" ".join(text[:4])))
for t in text:
for k, v in _COEF.findall(t):
fp["계수"].setdefault(k, float(v))
for k, v in _PCT.findall(t):
fp["율"].append([k, float(v)])
rates = rates_in(f"{fp['이름']} {fp['규격']}")
lab_rate = min(rates) if len(rates) >= 2 else None
mach_rate = max(rates) if len(rates) >= 2 else None
seen: Counter = Counter()
for x in block.get("자원") or []:
kind, q = x.get("갈래"), x.get("품")
nm, sp = str(x.get("이름") or ""), str(x.get("규격") or "")
if not q:
continue
seen[(x.get("코드"), round(float(q), 9))] += 1
if seen[(x.get("코드"), round(float(q), 9))] > 1: # 노무비 · 재료비 · 경비 세 줄 같은 품
continue
q = float(q)
ts = mtypes(f"{nm} {sp}")
if kind == "L":
if who := trade(nm):
base = q / lab_rate if lab_rate else q
_labor(fp, who, q, base, [[lab_rate, "섞음"]] if lab_rate else [], "QTY")
elif kind == "X" or (kind == "S" and ts):
one = {"갈래": sorted(ts), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q}
base = q / mach_rate if mach_rate else q
fp["장비"].append({**one, "밑시간": base, "율": [], "절": "0", "글": f"{nm} {sp}"})
elif kind in ("M", "S"):
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": q, "밑품": q})
fp["몫"] = shares.sections_stm(block.get("자원") or [], text)
return fp
def direct(hopyo: dict[str, Any]) -> dict[str, Any] | None:
"""노임 · 장비를 바로 적은 호표 → 줄 수량 합 지문(호표 1 단위) · 근거글 출처 — 없으면 None."""
lines = hopyo.get("줄") or []
fp = empty({k: hopyo.get(k) for k in ("이름", "규격", "단위")})
texts = [str(t) for t in hopyo.get("근거글") or []]
texts += [f"{x.get('이름')} {x.get('규격')}" for x in lines if x.get("갈래") == "근거글"]
fp["출처"] = hopyo_basis.sources(" ".join(texts))
hit = False
for x in lines:
q = x.get("수량")
if not isinstance(q, (int, float)) or q <= 0:
continue
if kind_of(x) == "노임" and (who := trade(x.get("이름"))):
_labor(fp, who, float(q), float(q), [], "QTY")
hit = True
elif kind_of(x) == "기계":
text = f"{x.get('이름')} {x.get('규격')}"
if ts := mtypes(text):
one = {"갈래": sorted(ts), "규격": sorted(specnums(text)), "시간": float(q)}
fp["장비"].append({**one, "밑시간": float(q), "율": [], "절": "0", "글": text})
hit = True
return _round(fp) if hit and fp["인력"] else None
def kind_of(line: dict[str, Any]) -> str:
"""줄 갈래 + 「노임」(단위 인 + 우리 직종 · 나 창 읽기 전에도)."""
kind = str(line.get("갈래") or "")
unit = _flat(line.get("단위"))
if kind in ("기타", "자재", "노임") and unit in TRADE_UNITS and trade(line.get("이름")):
return "노임"
return kind
+411
View File
@@ -0,0 +1,411 @@
"""M02 일위대가 맞춤 — 순위 · 판정 · 블록 셈 · 뒤 셈 · 맞춤셈 캐시(PLAN 338-1 · 설계 2-4 · 2-6 ·
2-10).
순위 = 로직마다 가장 닮은 변형의 지문 점수(인력 · 장비 · 자재 · 율 · 단위 · 조건 · 조항 · 이름 ·
금액 없음)
→ 지문 위 POOL ∪ 이름 · 근거 차례(`Match.b_order`) 위 다섯 → 점수 + BETA × 차례 덤 → 위 다섯 ·
같음(이름 같음 · 씨앗) 1위 고정 — 지문 「딱 맞음」이 다른 로직이면 지문 · 섞인 줄은 이름 · 근거
차례로
몫 풀이(`Combine.solve`).
판정(1위) = 맞음(옵션 수준 ≤ 1 · 엑셀 몫을 다 덮음 · 숫자 하나뿐 줄 아님) · 다른 점(옵션 수준 2 또는
직종 묶음 같음 + 인용 조항 같음) · 후보 — 글은 숫자 사실만(엑셀 · 우리 품).
뒤 셈 = 올리기 · 보기가 `ensure` → 일감 줄 하나 · 블록 차례로 셈 · 20 블록마다 캐시 덮음 ·
캐시 = `tmp/m02_hopyo/맞춤_<HP16>_<판16>.json`. 서버 단독 · 원본 읽기만.
손잡이(아래 상수)는 336 · K5 실험 값 그대로 — 한 파일로 다시 맞추지 않음.
"""
from __future__ import annotations
import hashlib
import json
import queue
import threading
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete.M02_Hopyo_Words import close, cond_score, cosine, jacc, nfkc
# ── 손잡이(한 곳) ──────────────────────────────────────────────────────
W = {
"인력": 3.0,
"장비": 2.0,
"자재": 1.0,
"율": 0.3,
"단위": 0.5,
"조건": 0.6,
"조항": 1.5,
"이름": 0.6,
}
BETA = 1.0
RB = (1, 0.6, 0.45, 0.35, 0.25)
POOL = 40
TOP_OPT = 5
CAP = 400
TOL = (0.005, "R", 0.03)
SURE = (0.03, 0.3)
MIXED_CUT = 0.35
COMBO = (0.85, 0.75, 0.6)
COVER = 0.6
SHARES = (0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9)
EVERY = 20 # 블록 몇 개마다 캐시 덮음
COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"}
HOUR = {"hr", "시간", "h"}
NO_NUMBER = "숫자 판정 없음"
_STATE: dict[str, Any] = {"판": None}
# ── 우리 지문 정리(점수용) ─────────────────────────────────────────────
def mat_words(name: Any) -> set[str]:
t = nfkc(name).lower().replace(".", "").replace(" ", "")
out = set(hopyo_match.words(name)) | ({"pvc"} if "pvc" in t else set())
return {w for w in out if len(w) >= 2}
def _prep(o: dict[str, Any], grams: dict[str, set]) -> dict[str, Any]:
"""기계경비 로직은 조종원 뺌 · 모두 0 인 변형 뺌 · 섞기 전 변형 덧 · 고를 수 있는 장비."""
vs = []
for v in o["변형"]:
L = {} if o["기계경비"] else {t: q for t, q in v["L"].items() if q > 0}
X = [(set(a), set(b), q) for a, b, q in v["X"] if q > 0]
M = {k: q for k, q in v["M"].items() if q > 0}
if not (L or X or M) and (v["L"] or v["X"] or v["M"]):
continue
vs.append({"in": v["in"], "L": L, "X": X, "M": M})
ri, rm = (v.get("mix") or {}).get("인력비율"), (v.get("mix") or {}).get("기계비율")
if (ri and 0 < ri < 100) or (rm and 0 < rm < 100): # 섞기 전 품(인력 · 기계 100%)
Lu = {t: q * 100 / ri for t, q in L.items()} if ri and 0 < ri < 100 else L
Xu = [(a, b, q * 100 / rm) for a, b, q in X] if rm and 0 < rm < 100 else X
vs.append({"in": {**v["in"], "_섞기전": "100%"}, "L": Lu, "X": Xu, "M": M})
if not vs: # 엔진이 못 돈 로직 — 직종 이름만(품 모름)
X0 = [(set(a), set(b), None) for a, b in o["장비"] if a]
vs = [{"in": None, "L": {t: None for t in o["직종"]}, "X": X0, "M": {}}]
opt = []
if o["기계경비"] and (o["장비고름"] or o["장비"]):
ts = set().union(*[set(a) for a, _ in o["장비고름"] + o["장비"]])
sp = set().union(*[set(b) for _, b in o["장비고름"]]) if o["장비고름"] else set()
opt = [(ts, sp)]
mats = set().union(*[mat_words(n) for n in o["자재"]], set())
for v in vs:
for n in v["M"]:
mats |= mat_words(n)
return {
"로직": o["로직"],
"단위": o["단위"],
"vs": vs,
"opt": opt,
"matw": mats,
"pctv": {round(p, 3) for _, p in o["덧줄율"]},
"nameg": grams.get(o["로직"], set()),
"nc": {k: set(v) for k, v in o["이름조건"].items()},
"oc": {k: set(v) for k, v in o["고름조건"].items()},
}
def prepared() -> list[dict[str, Any]] | None:
"""점수용 우리 지문 — 우리 지문 준비 전 None."""
table = ours.table()
if table is None:
return None
if _STATE["판"] != id(table):
grams = {x["로직"]: x["grams"] for x in hopyo_match.targets()["로직"]}
_STATE.update(판=id(table), 표=[_prep(o, grams) for o in table])
return _STATE["표"]
# ── 엑셀 지문 정리 · 점수 ──────────────────────────────────────────────
def ex_prep(efp: dict[str, Any]) -> dict[str, Any]:
return {
**efp,
"E": {t: o["품"] for t, o in efp["인력"].items() if o["품"]},
"Eb": {t: o["밑품"] for t, o in efp["인력"].items() if o["밑품"]},
"matl": [w for w in (mat_words(m["이름"]) for m in efp["자재"]) if w],
"pctv": {round(v, 3) for _, v in efp["율"]},
"nameg": hopyo_match._grams(hopyo_match.tidy(efp["이름"])),
"theirs": [list(x) for x in efp["출처"]],
"조건s": {k: set(v) for k, v in efp["조건"].items()},
"이름조건s": {k: set(v) for k, v in efp["이름조건"].items()},
}
def unit_fit(a: Any, b: Any) -> bool:
"""단위 맞음 + 별칭(개소 = 개 = 본 · hr = 시간) — 품 크기 견줌에만."""
if hopyo_match.fits(a, b):
return True
na = "".join(str(a or "").split()).replace("/", "").lower()
for nb in ("".join(x.split()).replace("/", "").lower() for x in str(b or "").split("·")):
if na == nb or (na in COUNT and nb in COUNT) or (na in HOUR and nb in HOUR):
return True
return False
def lab_sim(E: dict, Eb: dict, ov: dict, unit_ok: bool) -> tuple[float, float | None]:
if not ov:
return 0.0, None
keys = set(E) | set(ov)
w = lambda t: 0.5 if t == "보통인부" else 1.0 # noqa: E731
st = sum(w(t) for t in set(E) & set(ov)) / sum(w(t) for t in keys)
if any(q is None for q in ov.values()): # 품 모름 — 직종 겹침만
return 0.6 * st, None
shape = max(cosine(E, ov), cosine(Eb, ov))
if not unit_ok:
return 0.35 * st + 0.25 * shape, None
mag = sum(max(close(E.get(t), ov.get(t)), close(Eb.get(t), ov.get(t))) for t in keys) / len(
keys
)
return 0.35 * st + 0.25 * shape + 0.40 * mag, mag
def mach_sim(EM: list, OX: list, opt: list, unit_ok: bool) -> float:
if not OX and not opt:
return 0.0
got = []
for m in EM:
mt, ms = set(m["갈래"]), set(m["규격"])
best = 0.0
for ot, osp, oh in OX:
j = jacc(mt, ot)
if not j:
continue
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
hc = max(close(m.get("시간"), oh), close(m.get("밑시간"), oh)) if unit_ok and oh else 0
best = max(best, 0.6 * j + 0.2 * sp + 0.2 * hc)
for ot, osp in opt:
if mt and mt <= ot:
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
best = max(best, 0.6 + 0.2 * sp + 0.2 * (1.0 if unit_ok else 0.0))
got.append(best)
et = set().union(*[set(m["갈래"]) for m in EM])
ot_all = set().union(*[t for t, _, _ in OX]) if OX else set()
extra = len(ot_all - et) / max(1, len(ot_all | et)) if ot_all else 0.0
return sum(got) / len(got) * (1 - 0.5 * extra)
def score_pair(e: dict, o: dict, table: dict | None = None) -> tuple[float, dict, dict | None]:
"""(점수, 채널, 고른 변형) — 로직마다 가장 닮은 변형 · `table` = `Basis.ours()`(한 번 읽어
넘김)."""
unit_ok = unit_fit(e["단위"], o["단위"])
ch = {"단위": 1.0 if unit_ok else 0.0}
g1, g2 = e["nameg"], o["nameg"]
ch["이름"] = 2 * len(g1 & g2) / (len(g1) + len(g2)) if g1 and g2 else 0.0
our = (table if table is not None else hopyo_basis.ours()).get(o["로직"])
c = hopyo_match.clause(e["theirs"], our)[0] if (our and e["theirs"]) else 0.0
ch["조항"] = c * (0.5 if e["옛판"] else 1.0)
hit, _ = cond_score(e["조건s"], o["nc"], o["oc"])
_, clash = cond_score(e["이름조건s"], o["nc"], {})
ch["조건"] = hit - clash
if e["matl"]:
near = lambda ws: any(a in b or b in a for a in ws for b in o["matw"]) # noqa: E731
ch["자재"] = sum(1 for ws in e["matl"] if near(ws)) / len(e["matl"])
ch["율"] = 1.0 if (e["pctv"] and o["pctv"] and e["pctv"] & o["pctv"]) else 0.0
base = sum(W.get(k, 0) * v for k, v in ch.items())
best, bestv, bestch = None, None, {}
for v in o["vs"]:
s, vch = base, {}
if e["E"]:
ls, mag = lab_sim(e["E"], e["Eb"], v["L"], unit_ok)
vch["인력"], vch["품"] = ls, mag
s += W["인력"] * ls
elif v["L"]:
s -= 0.6
if e["장비"]:
vch["장비"] = mach_sim(e["장비"], v["X"], o["opt"], unit_ok)
s += W["장비"] * vch["장비"]
elif v["X"] or o["opt"]:
s -= 0.4
if best is None or s > best:
best, bestv, bestch = s, v, vch
return best, {**ch, **bestch}, bestv
def rank(e: dict, n: int = POOL) -> list[tuple]:
"""[(점수, 로직, 채널, 변형)] — 위 n."""
table = hopyo_basis.ours()
out = [(*_swap(score_pair(e, o, table), o["로직"]),) for o in prepared() or []]
out.sort(key=lambda x: -x[0])
return out[:n]
def _swap(got: tuple, code: str) -> tuple:
return got[0], code, got[1], got[2]
def sure(e: dict, pool: list[tuple]) -> bool:
"""딱 맞음 — 1위 직종 · 품(3% 안)이 다 맞음(보통인부 하나뿐 줄 아님) 또는 장비 갈래 · 시간 맞음
·
2위와 점수 차 SURE[1] 넘음."""
if not pool:
return False
s1, _, ch, _ = pool[0]
gap = s1 - pool[1][0] if len(pool) > 1 else 9
if e["E"]:
rich = len(e["E"]) >= 2 or set(e["E"]) != {"보통인부"} or bool(e["장비"])
ok = rich and (ch.get("품") or 0) >= 1 - SURE[0] and ch.get("인력", 0) >= 1 - SURE[0]
else:
ok = bool(e["장비"]) and ch.get("장비", 0) >= 0.95
return ok and gap > SURE[1]
def fuse(e: dict, pool: list[tuple], border: list[str]) -> list[str]:
"""지문 점수 + BETA × 이름 · 근거 차례 덤 → 위 다섯."""
bonus = {k: RB[i] for i, k in enumerate(border[:5])}
cand = {k: s for s, k, _, _ in pool}
by = {o["로직"]: o for o in prepared() or []}
table = hopyo_basis.ours()
for k in bonus:
if k not in cand and k in by:
cand[k] = score_pair(e, by[k], table)[0]
return sorted(cand, key=lambda k: -(cand[k] + BETA * bonus.get(k, 0)))[:TOP_OPT]
def single(e: dict) -> bool:
"""숫자 하나뿐 줄 — 직종 하나 또는 장비 시간 하나(기계 1 시간 줄 뺌) · 같은 숫자 로직이 많아
우연 맞음."""
E = [k for k, x in e["인력"].items() if x.get("품")]
X = [m for m in e["장비"] if m.get("시간")]
return len(E) + len(X) == 1 and not (X and abs(X[0]["시간"] - 1.0) < 1e-9)
def has_numbers(efp: dict) -> bool:
return bool(efp["인력"] or any(m.get("시간") for m in efp["장비"]) or efp["자재"])
# ── 블록 셈 ────────────────────────────────────────────────────────────
def compute(block: dict | None, line: dict, got: dict | None = None) -> dict[str, Any]:
"""블록 + 줄 하나 → 맞춤셈 한 칸(설계 3-2 꼴)."""
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # 판정이 이 파일을 부름
return verdict.block(block, line, got or hopyo_match.targets())
# ── 뒤 셈 · 캐시 ───────────────────────────────────────────────────────
_JOBS: queue.Queue = queue.Queue()
_PENDING: set[str] = set()
_WORKER: dict[str, Any] = {"스레드": None}
_LOCK = threading.Lock()
def _hp_path(folder: Path, key: str) -> Path:
return hopyo_store._path(folder, key)
def cache_path(folder: Path, key: str) -> Path:
hp = hashlib.sha256(_hp_path(folder, key).read_bytes()).hexdigest()[:16]
return ours.folder() / f"맞춤_{hp}_{ours.version()}.json"
def units(doc: dict[str, Any]) -> list[tuple[str, dict | None, dict]]:
"""셈 단위 [(열쇠, 블록, 줄)] — 블록 + 줄 열쇠 한 번 · 블록 없는 기계 줄 · 직접 줄 호표."""
blocks = {int(b["번"]): b for b in doc.get("단산") or [] if b.get("번") is not None}
out, seen = [], set()
for h in doc.get("호표") or []:
for line in h.get("줄") or []:
ref = line.get("참조") or {}
kind = finger.kind_of(line)
if ref.get("종류") == "단산":
key = unit_key(line)
if key not in seen:
seen.add(key)
out.append((key, blocks.get(int(ref.get("번") or 0)), line))
elif kind == "기계":
key = unit_key(line)
if key not in seen:
seen.add(key)
out.append((key, None, line))
if (fp := finger.direct(h)) is not None and f"호표:{h['번']}" not in seen:
seen.add(f"호표:{h['번']}")
out.append(
(f"호표:{h['번']}", {"_지문": fp}, {k: h.get(k) for k in ("이름", "규격", "단위")})
)
return out
def unit_key(line: dict[str, Any]) -> str:
ref = line.get("참조") or {}
if ref.get("종류") == "단산":
return f"단산:{ref.get('번')}|{hopyo_match.line_key(line)}"
return f"줄:{hopyo_match.line_key(line)}"
def _read(path: Path) -> dict[str, Any] | None:
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
return None
return doc if isinstance(doc, dict) else None
def result(folder: Path, key: str) -> dict[str, Any] | None:
"""맞춤셈 캐시(이 HP 파일 · 지금 판) — 없으면 None."""
try:
return _read(cache_path(folder, key))
except OSError:
return None
def status(folder: Path, key: str) -> dict[str, Any]:
"""{끝, 한, 모두, 까닭?}."""
got = result(folder, key)
if got and got.get("끝"):
return {"끝": True, "한": got["셈"][0], "모두": got["셈"][1]}
why = None if ours.ready() else "우리 로직 준비 중"
done, total = got["셈"] if got else (0, None)
return {"끝": False, "한": done, "모두": total, **({"까닭": why} if why else {})}
def run_job(folder: Path, key: str) -> dict[str, Any]:
"""HP 파일 하나를 끝까지 셈(뒤 스레드 · 시험이 바로 부름) → 캐시."""
if ours.table() is None:
thread = ours.warm(build_missing=True)
if thread is not None:
thread.join()
path = cache_path(folder, key)
doc = hopyo_store.read(folder, key)
todo = units(doc)
got = _read(path) or {}
done = got.get("블록") if got.get("판") == ours.version() else None
out = {"판": ours.version(), "지문판": ours.FP_VERSION, "셈": [0, len(todo)], "끝": False}
out["블록"] = dict(done or {})
targets = hopyo_match.targets()
for at, (unit, block, line) in enumerate(todo, 1):
if unit not in out["블록"]:
out["블록"][unit] = compute(block, line, targets)
out["셈"] = [at, len(todo)]
if at % EVERY == 0:
atomic_write_json(path, out)
out["끝"] = True
atomic_write_json(path, out)
return out
def _work() -> None:
while True:
folder, key = _JOBS.get()
try:
run_job(folder, key)
except Exception: # noqa: BLE001 — 한 파일이 깨져도 일감 줄은 계속(보기에서 「셈 중」 그대로)
pass
finally:
with _LOCK:
_PENDING.discard(f"{folder}|{key}")
def ensure(folder: Path, key: str) -> dict[str, Any]:
"""캐시 있으면 끝 · 없으면 일감 줄에 넣음(같은 파일은 한 번) → {끝, 한, 모두, 까닭?}."""
now = status(folder, key)
if now["끝"]:
return now
with _LOCK:
tag = f"{folder}|{key}"
if tag not in _PENDING:
_PENDING.add(tag)
_JOBS.put((Path(folder), key))
if _WORKER["스레드"] is None or not _WORKER["스레드"].is_alive():
_WORKER["스레드"] = threading.Thread(target=_work, name="m02-hopyo-fit", daemon=True)
_WORKER["스레드"].start()
return now
+81 -1
View File
@@ -319,7 +319,7 @@ def candidates(
scored.sort(key=lambda x: -x[0])
out = []
for score, one in scored:
label = (one["이름"], one.get("규격"), one["단위"])
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
if label in seen:
continue
seen.add(label)
@@ -329,6 +329,86 @@ def candidates(
return out
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
_GENERIC = frozenset(
"인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 "
"설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 "
"건설표준품셈 이하 이상".split()
)
def fix_typo(theirs: list) -> list[list[str]]:
"""엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」."""
out = []
for book, n in theirs or []:
m = _TYPO_CLAUSE.match(str(n))
out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n])
return out
def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]:
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5
·
나 창 `Basis.clause` 가 있으면 그것."""
if hasattr(hopyo_basis, "clause"):
return hopyo_basis.clause(theirs, our)
nums = [n for book, n in theirs if book == our["책"]]
same = sorted(n for n in nums if n in our["조항"])
if same:
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-"))
if kids:
return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음"
heads = {n.rsplit("-", 1)[0] for n in nums}
near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads)
if near_:
return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음"
return 0.0, [], ""
def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool:
if cand["근거"]["직종"]:
return True
mine = {w for w in hopyo_basis._loose_words(f"{line.get('이름')}") if w not in _GENERIC}
theirs = {w for w in hopyo_basis._loose_words(cand["이름"]) if w not in _GENERIC}
return any(a in b or b in a for a in mine for b in theirs)
def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict:
"""이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15]}` — 이름 후보 +
근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) ·
옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리."""
got = got or targets()
basis_ = dict(line.get("근거") or {})
if basis_.get("출처"):
line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}}
basis = hopyo_basis.candidates(line, got)
named = candidates(line, "로직", got, None)
merged = [c.get("로직") for c in hopyo_basis.merge(basis, named) if c.get("로직")]
found = _exact(line, "로직", got)
if len(found) == 1:
tgt = found[0]["로직"]
theirs = (line.get("근거") or {}).get("출처") or []
our = hopyo_basis.ours().get(tgt)
if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs:
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
if our and clause(theirs, our)[0] >= 0.9:
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
strong = [c for c in basis if c["근거"]["까닭"].startswith("조항 같음")]
strong = [c for c in strong if _strong_basis(line, c)]
if not strong:
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]]))
return {"상태": "후보", "top": top[:5], "합침": merged[:15]}
top = [x["로직"] for x in found]
rest = [c for c in hopyo_basis.merge(basis, named) if c.get("로직") not in top]
first = next((c.get("로직") for c in named if c.get("로직") not in top), None)
top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))]
return {"상태": "후보" if top else "없음", "top": top[:5], "합침": merged[:15]}
# ── 층 기억 ───────────────────────────────────────────────────────────
def chain(folder: Path) -> list[Path]:
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
+497
View File
@@ -0,0 +1,497 @@
"""M02 일위대가 맞춤 — 옵션 거꾸로 찾기(PLAN 338-1 · 설계 2-5): 고른 우리 로직의 입력 칸마다
후보(옵션 공간)를 엔진으로 돌려 엑셀 품(직종 품 · 장비 시간 · 자재 수량)과 같은 입력을 찾음.
공간 = 고르기 모두 · 기계(EQ) = 엑셀 규격 수와 맞는 기종(없으면 모두) · 범위 = 두 끝 + 범위 안 엑셀
수 ·
표 찾기 칸 = 그 표 줄 값 · 그 밖 수 = 자동값 + 같은 이름 계수 + 단위 붙은 엑셀 수(% 칸 = 0 덧 ·
「품」 칸 = 엑셀 품 × 결과단위) · 뺌 = 지역 · 보정작업 · 조종원직종.
조합 = 곱 ≤ CAP 모두(안 닿는 칸 · 같은 품 값 묶음 셈) · 넘으면 엑셀 조건 낱말로 고르기 좁힘 ·
그래도 넘으면 기준점 둘에서 하나씩 + 고르기 둘씩.
수준 = 0(0.5%) · 1(R = 0.5% + 엑셀 값 자릿수 반올림 폭) · 2(3%) · 9(없음) — 「딱 맞음」(핵심 + 엑셀
몫을
다 덮음)만 셈 · 같은 수준이면 거리 작은 것. 규칙 · 문턱은 336 K5(`k5_lib` · `stage_b`) 그대로.
엔진 = `M02_Hopyo_Ours.run`(읽기만).
"""
from __future__ import annotations
import itertools
import json
import math
import re
from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Fit as fit # 손잡이(CAP · TOL) — 부를 때만 씀
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete.M02_Hopyo_Words import nfkc, specnums
FIXED = ("지역", "보정작업", "조종원직종")
_NUM = re.compile(r"(?<![\d,.])(\d+(?:\.\d+)?)(?![\d,])")
UNITS = {
"%": ("%",),
"㎝": ("㎝", "cm"),
"cm": ("㎝", "cm"),
"㎞": ("㎞", "km"),
"km": ("㎞", "km"),
"㎜": ("㎜", "mm"),
"mm": ("㎜", "mm"),
"㎥": ("㎥", "m3"),
"㎡": ("㎡", "m2"),
"m": ("m",),
"회": ("회",),
"개월": ("개월",),
"층": ("층",),
"t": ("t", "ton", "톤"),
"ton": ("t", "ton", "톤"),
"톤": ("t", "ton", "톤"),
"분": ("분",),
"초": ("초", "sec"),
"kg": ("kg", "㎏"),
"㎏": ("kg", "㎏"),
}
UNITW = {"mm", "cm", "kg", "ton", "sec", "kgf", "mpa"}
# ── 엑셀 쪽 수 ─────────────────────────────────────────────────────────
def ex_of(efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
"""엑셀 지문 → 옵션 공간 재료(글 · 글머리 · 계수 · 규격 수 · 조건 · 품 값)."""
texts = [efp["이름"], efp.get("규격") or ""] + [str(t) for t in text or []]
sp: set[float] = set()
for m in efp["장비"]:
sp |= set(m.get("규격") or [])
sp |= specnums(f"{efp['이름']} {efp.get('규격') or ''}")
return {
"글": texts,
"글머리": texts[:8],
"계수": efp.get("계수") or {},
"규격수": sp,
"조건": efp.get("조건") or {},
"품값": {Decimal(str(x["품"])) for x in efp["인력"].values() if x.get("품")},
}
def excel_numbers(texts: list[str], spec: dict[str, Any] | None = None) -> set[Decimal]:
"""글의 수 — spec 이 있으면 그 칸 단위가 뒤에 붙거나 칸 이름이 앞에 붙은 수만(쉼표 단가 뺌)."""
out: set[Decimal] = set()
flat = [nfkc(t) for t in texts]
if spec is None:
pats = [_NUM]
else:
u = nfkc(spec.get("단위")).strip().lower()
vs = UNITS.get(u, (u,) if u and len(u) <= 3 and "/" not in u else ())
vs = () if u == "%" else vs # 「노무비의 2%」 같은 율이 감률 칸 후보로 드는 것을 막음
num = r"(\d+(?:\.\d+)?)"
pats = []
if vs:
alt = "|".join(re.escape(x) for x in vs)
pats.append(
re.compile(r"(?<![\d,.])" + num + r"\s*(?:" + alt + r")(?![A-Za-z가-힣])", re.I)
)
pats.append(
re.compile(re.escape(nfkc(spec["이름"])) + r"\s*[:=(]?\s*" + num + r"(?![\d,])")
)
for t in flat:
for pat in pats:
for n in pat.findall(t):
v = Decimal(n)
if 0 < v <= 2000:
out.add(v.normalize() if v == v.to_integral() else v)
return out
def eq_numbers(code: str) -> set[float]:
"""기계 규격 수 — 단위 붙은 수 + 맨 수(「41 [80∼95]」 → 41 · 80 · 95)."""
one = ours.whole().numbers.get("EQ", {}).get(str(code)) or {}
bare = {float(n) for n in re.findall(r"\d+(?:\.\d+)?", nfkc(one.get("규격")))}
return ours.eq_of(code)[1] | bare
def _uses(row: dict) -> tuple[dict, set]:
try:
return m01.mau._uses(row)
except Exception: # noqa: BLE001 — 식을 못 읽는 로직은 찾기 칸 없음
return {}, set()
def space(code: str, ex: dict[str, Any] | None = None) -> list[tuple[str, list, str]]:
"""[(입력 이름, 후보, 갈래)] — 갈래 = 기계 · 고르기 · 범위 · 표 · 수."""
whole = ours.whole()
row = whole.logic(code)
auto = m01.mau.values(whole, row)
finds, _ = _uses(row)
ex = ex or {}
texts = ex.get("글") or []
coef = {k.lower(): Decimal(str(v)) for k, v in (ex.get("계수") or {}).items()}
spn = ex.get("규격수") or set()
times = Decimal(str(ours.times_of(row)))
out = []
for s in row.get("입력") or []:
name, opts = s["이름"], s.get("고르기") or []
if name in FIXED:
continue
if opts and all(isinstance(v, str) and ours.EQ_CODE.match(v) for v in opts):
hits = {v: len(eq_numbers(v) & spn) for v in opts} if spn else {}
most = max(hits.values(), default=0)
keep = [
v for v in opts if most and hits[v] == most
] # 엑셀 규격 수가 가장 많이 겹치는 기종
out.append((name, keep or list(opts), "기계"))
elif opts:
out.append((name, list(opts), "고르기"))
elif isinstance(s.get("범위"), list) and len(s["범위"]) == 2:
lo, hi = (Decimal(str(x)) for x in s["범위"])
vals = {lo, hi} | {v for v in excel_numbers(texts, s) if lo <= v <= hi}
out.append((name, sorted(vals), "범위"))
elif name in finds:
rows = set()
for ref, cond in finds[name]:
rows |= set(m01.mau._fits(whole, ref, cond))
out.append((name, sorted(rows) or [auto.get(name)], "표"))
else:
base = auto.get(name)
vals = {base} if base is not None else set()
if name.lower() in coef:
vals.add(coef[name.lower()])
vals |= excel_numbers(texts, s) if texts else set()
if str(s.get("단위") or "").strip() == "%":
vals.add(Decimal(0))
if name == "품":
vals |= {v * times for v in ex.get("품값", set())}
vals.discard(None)
out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수"))
return out
def _product(sp: list) -> list[dict]:
names = [n for n, _, _ in sp]
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o, _ in sp])]
def narrow(sp: list, ex: dict[str, Any] | None) -> list:
"""고르기 후보를 엑셀 조건 낱말로 좁힘 — 낱말이 든 후보가 있으면 그것만."""
words = {w for ws in ((ex or {}).get("조건") or {}).values() for w in ws}
flat = re.sub(r"\s+", "", nfkc(" ".join((ex or {}).get("글머리") or [])))
out = []
for name, opts, kind in sp:
if kind == "고르기" and len(opts) > 1:
hit = [
o
for o in opts
if any(w in str(o) for w in words) or (len(str(o)) >= 2 and str(o) in flat)
]
if hit:
out.append((name, hit, kind))
continue
out.append((name, opts, kind))
return out
def modes() -> tuple[tuple[str, float, bool], ...]:
"""허용 셋 — 0.5% · R(0.5% + 엑셀 값 자릿수 반올림 폭) · 3%."""
return (("0.5%", fit.TOL[0], False), ("R", fit.TOL[0], True), ("3%", fit.TOL[2], False))
def combos(
sp: list, ex: dict[str, Any] | None = None, cap: int | None = None
) -> tuple[list[dict], str]:
"""(조합, 어떻게 — 모두 · 좁힘 · 하나씩)."""
cap = cap or fit.CAP
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= cap:
return _product(sp), "모두"
sp2 = narrow(sp, ex)
if math.prod(len(o) for _, o, _ in sp2) <= cap:
return _product(sp2), "좁힘"
base = {n: o[0] for n, o, _ in sp2}
out = [dict(base)] + [{**base, n: v} for n, o, _ in sp2 for v in o[1:]]
small = [(n, o) for n, o, k in sp2 if k != "수"]
for (n1, o1), (n2, o2) in itertools.combinations(small, 2):
if len(o1) * len(o2) <= 400:
out += [{**base, n1: a, n2: b} for a in o1 for b in o2]
seen, uniq = set(), []
for c in out:
k = json.dumps(c, sort_keys=True, default=str, ensure_ascii=False)
if k not in seen:
seen.add(k)
uniq.append(c)
return uniq[:cap], "하나씩"
def sig(v: dict | None, nd: int = 4) -> str | None:
"""품 꼴 서명 — 직종 · 장비(갈래 · 시간) · 자재 · 딸린 로직(유효 nd 자리)."""
if v is None:
return None
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items() if q),
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"] if q),
sorted((k, r(q)) for k, q in v["M"].items() if q),
sorted((k, r(q)) for k, q in v["S"].items() if q),
],
ensure_ascii=False,
)
def plan(code: str, ex: dict[str, Any] | None = None) -> tuple[list[dict], str, list, list, dict]:
"""(조합, 어떻게, 움직이는 칸, 안 닿는 칸, 값 묶음)."""
sp = space(code, ex)
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= fit.CAP:
cb = _product(sp)
sg = [sig(ours.run(code, c)) for c in cb]
active, inert, cls = [], [], {}
for n, o, k in sp:
if len(o) < 2:
continue
ctx: dict[str, dict] = {}
for c, s_ in zip(cb, sg, strict=True):
rest = {x: str(y) for x, y in c.items() if x != n}
ctx.setdefault(json.dumps(rest, sort_keys=True, ensure_ascii=False), {})[
str(c[n])
] = s_
keys = sorted(ctx)
prof = {str(v): tuple(ctx[g].get(str(v)) for g in keys) for v in o}
if len(set(prof.values())) == 1:
inert.append(n)
continue
active.append(n)
if k != "고르기":
ids: dict = {}
cls[n] = {v: ids.setdefault(pf, len(ids)) for v, pf in prof.items()}
return cb, "모두", active, inert, cls
base = {n: o[0] for n, o, _ in sp}
alt = {n: o[-1] for n, o, _ in sp}
act, inert = [], []
for n, o, k in sp:
if len(o) == 1:
continue
s0 = {sig(ours.run(code, {**base, n: v})) for v in o}
s1 = {sig(ours.run(code, {**alt, n: v})) for v in o} if len(s0) == 1 else s0
if len(s0) == 1 and len(s1) == 1:
inert.append(n)
continue
if k != "고르기": # 같은 표 줄에 드는 수는 한 값으로 — 두 기준점 품이 같으면 같은 값
keep, seen = [], set()
for v in o:
pair = (sig(ours.run(code, {**base, n: v})), sig(ours.run(code, {**alt, n: v})))
if pair not in seen:
seen.add(pair)
keep.append(v)
o = keep
act.append((n, o, k))
cb, how = combos(act, ex)
return [{**base, **c} for c in cb], how, [n for n, _, _ in act], inert, {}
def vary(hits: list[dict], act: list[str], cls: dict) -> list[str]:
"""맞은 조합들 사이에서 값이 갈리는 칸 — 수 칸은 같은 값 묶음이면 한 값."""
return [
n for n in act if len({cls.get(n, {}).get(str(g.get(n)), str(g.get(n))) for g in hits}) > 1
]
# ── 맞대기 ──────────────────────────────────────────────────────────────
def near(a: Any, b: Any, t: float, r: float = 0.0) -> bool:
"""a(엑셀) ≈ b(우리) — 비 t 안 · r = 엑셀 값 자릿수 반올림 폭."""
return bool(a and b and a > 0 and b > 0 and abs(a - b) <= max(t * max(a, b), r))
def ru(x: float) -> float:
"""엑셀 값 반올림 폭 — 소수 둘째~넷째 자리로 적힌 값만 · 값의 4% 안."""
s = repr(float(x))
if "e" in s or "." not in s:
return 0.0
d = len(s.split(".")[1].rstrip("0"))
return min(0.5 * 10 ** (-d), 0.04 * float(x)) if 2 <= d <= 4 else 0.0
def mat_words(name: Any) -> set[str]:
"""자재 이름 낱말 — 한글 두 자 넘는 덩이 · 영문 세 자 넘는 덩이 · 단위 낱말 뺌."""
t = nfkc(name).lower().replace(".", "")
return {w for w in re.findall(r"[가-힣]{2,}|[a-z]{3,}", t) if w not in UNITW}
def _named(ws: set[str], k: str) -> bool:
w = mat_words(k)
return bool(ws & w) or any(a in b or b in a for a in ws for b in w)
def compare(
e: dict, v: dict | None, t: float, base: bool = False, rnd: bool = False
) -> dict | None:
"""엑셀 지문 ↔ 우리 셈 → {직종, 장비, 자재, 핵심, 직종만, 덧} · base = 엑셀 밑품으로."""
if v is None:
return None
tol = lambda x: ru(x) if rnd else 0 # noqa: E731
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
ov = {k: q for k, q in v["L"].items() if q and q > 0}
out: dict[str, Any] = {"덧": []}
if E:
same = set(E) == set(ov)
ok = same and all(near(E[k], ov[k], t, tol(E[k])) for k in E)
out["직종"] = "같음" if ok else "직종 같음" if same else "다름"
else:
out["직종"] = "우리만" if ov else "없음"
EX = [m for m in e["장비"] if m.get("시간")]
OX = [(set(a), q) for a, _, q in v["X"] if q]
if EX and OX:
ok = []
for m in EX:
mine = [q for a, q in OX if a & set(m["갈래"])]
if not mine:
out["덧"].append("엑셀만 장비 " + "·".join(sorted(m["갈래"])))
continue
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
hit = any(near(h, q, t, tol(h)) for q in mine) or near(h, sum(mine), t, tol(h))
ok.append(hit)
et = set().union(*[set(m["갈래"]) for m in e["장비"]])
out["덧"] += ["우리만 장비 " + "·".join(sorted(a)) for a, _ in OX if not a & et]
out["장비"] = ("같음" if all(ok) else "다름") if ok else "갈래 안 겹침"
else:
out["장비"] = "엑셀만" if EX else "우리만" if OX else "없음"
EM = [m for m in e["자재"] if m.get("품")]
OM = {k: q for k, q in v["M"].items() if q}
if EM and OM:
ok = []
for m in EM:
mine = [q for k, q in OM.items() if _named(mat_words(m["이름"]), k)]
if mine:
x = m["밑품"] if base and m.get("밑품") else m["품"]
ok.append(any(near(x, q, t, tol(x)) for q in mine))
out["자재"] = ("같음" if all(ok) else "다름") if ok else "이름 안 겹침"
else:
out["자재"] = "엑셀만" if EM else "없음"
core = [out["직종"] == "같음"] if E else [False] if ov else []
core += [out[k] == "같음" for k in ("장비", "자재") if out[k] in ("같음", "다름")]
out["핵심"] = bool(core) and all(core)
out["직종만"] = bool(E) and out["직종"] == "같음"
return out
def strong(e: dict, c: dict | None) -> bool:
"""딱 맞음 — 핵심 + 엑셀 몫을 다 덮음(엑셀만 · 우리만 장비 없음 · 자재 다름 없음 ·
「보통인부 하나뿐 · 장비 없음」 줄 아님)."""
if not c or not c.get("핵심"):
return False
if c.get("장비") in ("엑셀만", "우리만", "갈래 안 겹침"):
return False
if any("엑셀만 장비" in d or "우리만 장비" in d for d in c.get("덧") or []):
return False
if c.get("자재") == "다름":
return False
labor = [k for k, x in e["인력"].items() if x.get("품")]
return not (labor == ["보통인부"] and not any(m.get("시간") for m in e["장비"]))
def gap(e: dict, v: dict | None, base: bool = False) -> float:
"""닮음 거리 — 직종 묶음 어긋남 + 품 |ln 비| 평균 + 장비 시간 반 무게."""
if v is None:
return 99.0
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
ov = {k: q for k, q in v["L"].items() if q and q > 0}
d = 0.0
if E or ov:
d += len(set(E) ^ set(ov)) / len(set(E) | set(ov))
both = [min(1.0, abs(math.log(E[k] / ov[k]))) for k in set(E) & set(ov)]
d += sum(both) / len(both) if both else 1.0
EX = [m for m in e["장비"] if m.get("시간")]
OX = [(set(a), q) for a, _, q in v["X"] if q]
if EX and OX:
hs = []
for m in EX:
mine = [q for a, q in OX if a & set(m["갈래"])]
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
hs.append(min(1.0, min(abs(math.log(h / q)) for q in mine)) if mine else 1.0)
d += 0.5 * sum(hs) / len(hs)
return d
def csig(e: dict, v: dict | None, nd: int = 4) -> str | None:
"""엑셀이 보이는 몫만의 품 서명 — 직종 · 엑셀 갈래와 겹치는 장비 · 엑셀 이름과 겹치는 자재."""
if v is None:
return None
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set()
em = [mat_words(m["이름"]) for m in e["자재"] if m.get("품")]
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items() if q),
sorted((tuple(a), r(q)) for a, _, q in v["X"] if q and set(a) & et),
sorted((k, r(q)) for k, q in v["M"].items() if q and any(_named(ws, k) for ws in em)),
],
ensure_ascii=False,
)
def ours_qty(v: dict | None) -> dict[str, Any]:
"""우리 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
if v is None:
return {"인력": {}, "장비": [], "자재": []}
return {
"인력": {k: round(q, 6) for k, q in v["L"].items() if q},
"장비": [{"갈래": list(a), "시간": round(q, 6)} for a, _, q in v["X"] if q],
"자재": [{"이름": k, "수량": round(q, 6)} for k, q in v["M"].items() if q],
}
def _plain(value: Any) -> Any:
if isinstance(value, Decimal):
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
return value
def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
"""로직 하나의 옵션 거꾸로 찾기 → {수준, 판정, 입력, 갈리는칸, 견줌, 우리품, 거리, 섞기전}."""
none = {"수준": 9, "판정": "없음", "입력": {}, "갈리는칸": [], "견줌": None}
none.update(우리품=ours_qty(None), 거리=99.0, 섞기전=False, 직종같음=False)
try:
ex = ex_of(efp, text)
cb, how, act, _, cls = plan(code, ex)
except Exception: # noqa: BLE001 — 로직 없음 · 식 못 읽음
return {**none, "판정": "못 셈"}
best = (9, 9, 99.0, None, None)
hits: dict[int, list] = {0: [], 1: [], 2: []}
for g in cb:
v = ours.run(code, g)
if v is None or not (v["L"] or v["X"] or v["M"]):
continue
lvl = weak = 9
for i, (_, t, rnd) in enumerate(modes()):
c = compare(efp, v, t, rnd=rnd) or {}
if c.get("핵심"):
weak = min(weak, i)
if strong(efp, c):
hits[i].append((g, csig(efp, v)))
lvl = min(lvl, i)
d = gap(efp, v)
if (lvl, weak, d) < best[:3]:
best = (lvl, weak, d, g, v)
lvl, _, d, g, v = best
if g is None:
return none
got = [h for h in hits.get(lvl, [])] if lvl < 9 else []
split = vary([h for h, _ in got], act, cls)
if not got:
verdict = "없음"
elif len({s for _, s in got}) > 1:
verdict = "여럿(다른 품)"
else:
verdict = "여럿(같은 품)" if split else "하나만"
auto = m01.mau.values(ours.whole(), ours.whole().logic(code)) # 움직인 칸 + 자동값과 다른 칸
base = compare(efp, v, fit.TOL[2], base=True) or {}
labor = {k for k, x in efp["인력"].items() if x.get("품")}
return {
"수준": lvl,
"판정": verdict,
"입력": {n: _plain(x) for n, x in g.items() if n in act or str(x) != str(auto.get(n))},
"모든입력": {n: _plain(x) for n, x in g.items()},
"갈리는칸": split,
"견줌": compare(efp, v, fit.TOL[2]),
"직종같음": bool(v["L"]) and set(k for k, q in v["L"].items() if q) == labor,
"우리품": ours_qty(v),
"거리": round(d, 4),
"섞기전": lvl == 9 and bool(base.get("핵심")),
"어떻게": how,
}
+364
View File
@@ -0,0 +1,364 @@
"""M02 일위대가 맞춤 — 우리 돈 로직 지문 · 마스터 판 열쇠 · 디스크 캐시 · 엔진 셈 캐시
(PLAN 338-1 · 설계 2-3).
지문 = 정적(호표 인력 → 직종 · 「로직(기계경비 …)」 → 장비 갈래 · 규격 · 재료 이름 · 덧줄 % ·
이름 · 고르기 글 조건 낱말 · 기계경비 로직) + 변형(고르기 입력 곱 64 이하 모두 · 넘으면 하나씩 ·
지역 · 조종원직종 · 보정작업 · 기종 목록 칸 뺌)마다 단위당 L(직종 인) · X(장비 시간) · M(자재) ·
S(딸린 로직) · mix(인력비율 · 기계비율).
캐시 = `tmp/m02_hopyo/우리지문_<판>.json`(환경 `AISLO_HOPYO_CACHE` 로 바꿈) · 판 = 마스터 첫 층 JSON
sha256 묶음 · 처음 셈은 뒤 스레드(≈ 2 분) · 다음은 읽기만.
엔진 = `Labor_Doc.run_line` 과 같은 길(자동값 + 입력 → `mf.run`) · 셈 잠금 하나(뒤 셈 · 요청 겹침) ·
메모리 캐시. 원본(`resources/master_data`) 읽기만. 규칙은 336 실험(C4 `s2_ours_fp` · K5 `run`)
그대로.
"""
from __future__ import annotations
import hashlib
import itertools
import json
import math
import os
import re
import threading
import time
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Cache as m01_cache
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림
ROOT = Path(__file__).resolve().parents[1]
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
EQ_CODE = re.compile(r"^\d{4}-\d{4}$")
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
SKIP_IN = ("지역", "조종원직종", "보정작업")
FIXED_MACH = {
"GC000267": {"굴착기"},
"GC000270": {"굴착기", "집게"},
"GC000271": {"굴착기", "브레이커"},
"GF000145": {"덤프트럭"},
}
RUN_CAP = 50_000 # ponytail: 엔진 셈 캐시 칸 수 — 넘으면 통째 비움(LRU 는 필요해지면)
_ENGINE = threading.RLock() # ponytail: 셈 잠금 하나 — 엔진을 뒤 · 요청 스레드가 같이 부를 때
_STATE: dict[str, Any] = {"판": None, "자국": None, "표": None, "스레드": None}
_RUN: dict[tuple, Any] = {}
_LOCK = threading.Lock()
_NO_PRICE = {str(i): "-" for i in range(400)} # 고른 자재 = 없는 키 → 값 없음 · 찾기 안 함
WHOLE_TTL = 2.0 # ponytail: 마스터 색인을 2 초 붙듦 — 줄마다 파일 자국을 다시 안 봄
def folder() -> Path:
return Path(os.environ.get("AISLO_HOPYO_CACHE") or ROOT / "tmp" / "m02_hopyo")
# ── 마스터 판 열쇠 ─────────────────────────────────────────────────────
def version() -> str:
"""마스터 판 = 첫 층 *.json(밑줄 뺌) 이름 + sha256 16 묶음의 sha256 앞 16 — 자국이 같으면 다시
안 셈."""
stamps = m01_cache._stamps(Path(m01.FOLDER))
if _STATE["자국"] != stamps:
parts = [
f"{name}:{m01.version_of((Path(m01.FOLDER) / name).read_bytes())}" for name in stamps
]
_STATE.update(자국=stamps, 판=hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16])
return _STATE["판"]
def path_of(ver: str | None = None) -> Path:
return folder() / f"우리지문_{ver or version()}.json"
# ── 엔진 한 번 ─────────────────────────────────────────────────────────
def whole() -> Any:
now = time.monotonic()
if _STATE.get("색인") is None or now - _STATE.get("색인때", 0) > WHOLE_TTL:
_STATE.update(색인=m01.loaded()[1], 색인때=now)
return _STATE["색인"]
def is_mach_logic(row: dict[str, Any]) -> bool:
return "시간당" in str(row.get("이름") or "")
def eq_of(code: Any) -> tuple[set[str], set[float]]:
one = whole().numbers.get("EQ", {}).get(str(code)) or {}
if not one:
return set(), set()
return mtypes(f"{one.get('상세구분')} {one.get('이름')}"), specnums(one.get("규격"))
def times_of(row: dict[str, Any]) -> float:
per = _PER.findall(str(row.get("결과단위") or ""))
return float(per[0].replace(",", "")) if per and per[0] else 1.0
def _args(text: str | None) -> dict[str, str]:
out = {}
for part in re.split(r",(?![^()]*\))", text or ""):
if "=" in part:
k, v = part.split("=", 1)
out[k.strip()] = v.strip()
return out
def _labor_names() -> dict[str, str]:
w = whole()
if _STATE.get("LB판") is not w:
lb = {str(r.get("키")): str(r.get("이름")) for r in w.numbers.get("LB", {}).values()}
_STATE.update(LB판=w, LB=lb)
return _STATE["LB"]
def _engine(code: str, given: dict[str, Any]) -> dict[str, Any]:
"""{L, X, M, S, mix} — 줄 수량 ÷ 결과단위 수(단위당)."""
w = whole()
row = w.logic(code)
times = times_of(row)
by_name: dict[Any, dict] = {}
for h in row.get("호표") or []:
by_name.setdefault(h.get("이름"), h)
auto = {k: v for k, v in m01.mau.values(w, row).items() if v is not None}
with _ENGINE, m01.cm.mf.mp.choosing(_NO_PRICE): # 품만 씀 — 자재 값 찾기 건너뜀(수량 같음)
got = m01.cm.mf.run(w, code, {**auto, **m01._dec(given)})
mid, lb = got.get("중간") or {}, _labor_names()
L: dict[str, float] = {}
X: list[list] = []
M: dict[str, float] = {}
S: dict[str, float] = {}
for line in got.get("줄") or []:
h = by_name.get(line.get("이름"))
if h is None:
continue
try:
q = float(Decimal(str(line.get("수량") or 0))) / times
except ArithmeticError:
continue
kind = h.get("종류")
if kind == "인력":
t = lb.get(str(h.get("요소")), str(h.get("이름")))
L[t] = L.get(t, 0.0) + q
elif kind == "로직":
m = _SUB.match(str(h.get("요소") or ""))
sub = m.group(1) if m else ""
sub_row = w.logic(sub) if sub else {}
if sub_row and is_mach_logic(sub_row):
ts = set(FIXED_MACH.get(sub, set())) | mtypes(h.get("이름"))
sp = specnums(h.get("이름"))
for v in _args(m.group(2)).values():
lit = v.strip("'\"")
if not EQ_CODE.match(lit):
lit = str(mid.get(lit) or given.get(lit) or auto.get(lit) or "")
if EQ_CODE.match(lit):
a, b = eq_of(lit)
ts |= a
sp |= b
if q:
X.append([sorted(ts), sorted(sp), q])
elif q:
S[sub] = S.get(sub, 0.0) + q
elif kind == "재료" and q:
M[str(h.get("이름"))] = M.get(str(h.get("이름")), 0.0) + q
mix = {}
for k in ("인력비율", "기계비율"):
try:
if mid.get(k) not in (None, ""):
mix[k] = float(Decimal(str(mid.get(k))))
except ArithmeticError:
pass
return {"L": L, "X": X, "M": M, "S": S, "mix": mix}
def _mach_view(code: str, row: dict, given: dict, v: dict) -> dict:
"""「기계 시간당 기계경비」 = 고른 기계(EQ 칸)의 갈래 · 규격 1 시간(조종원은 기계경비 속)."""
auto = m01.mau.values(whole(), row)
X = []
for s in row.get("입력") or []:
opts = s.get("고르기") or []
if opts and all(isinstance(o, str) and EQ_CODE.match(o) for o in opts):
ts, sp = eq_of(given.get(s["이름"], auto.get(s["이름"])))
X.append([sorted(ts | FIXED_MACH.get(code, set())), sorted(sp), 1.0])
if not X and FIXED_MACH.get(code):
X = [[sorted(FIXED_MACH[code]), [], 1.0]]
return {**v, "L": {}, "X": X}
def run(code: str, given: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""엔진 한 번 → {L, X, M, S, mix} · 못 돌면 None(메모리 캐시)."""
given = dict(given or {})
key = (code, json.dumps(given, sort_keys=True, default=str, ensure_ascii=False))
if key not in _RUN:
try:
v = _engine(code, given)
row = whole().logic(code)
v = _mach_view(code, row, given, v) if is_mach_logic(row) else v
except Exception: # noqa: BLE001 — 못 도는 조합은 없음으로(엔진 예외가 여럿)
v = None
if len(_RUN) >= RUN_CAP:
_RUN.clear()
_RUN[key] = v
return _RUN[key]
# ── 지문 ───────────────────────────────────────────────────────────────
def _static(code: str, row: dict[str, Any], unit: str) -> dict[str, Any]:
picks = [s for s in row.get("입력") or [] if s.get("고르기")]
labels, opt_mach = [], []
for s in picks:
names = s.get("고르기이름") or {}
for v in s.get("고르기") or []:
if isinstance(v, str) and EQ_CODE.match(v):
ts, sp = eq_of(v)
if ts:
opt_mach.append([sorted(ts), sorted(sp)])
else:
labels.append(str(names.get(v) or v))
lb = _labor_names()
trades, mach, mats = set(), [], []
for h in row.get("호표") or []:
kind = h.get("종류")
if kind == "인력":
trades.add(lb.get(str(h.get("요소")), str(h.get("이름"))))
elif kind == "로직":
m = _SUB.match(str(h.get("요소") or ""))
ts = set(FIXED_MACH.get(m.group(1) if m else "", set())) | mtypes(h.get("이름"))
for v in _args(m.group(2) if m else "").values():
if EQ_CODE.match(v.strip("'\"")):
ts |= eq_of(v.strip("'\""))[0]
if ts:
mach.append([sorted(ts), sorted(specnums(h.get("이름")))])
elif kind == "재료":
el = h.get("요소")
words = (
(el.get("검색어") or el.get("이름") or el.get("상세구분"))
if isinstance(el, dict)
else ""
)
mats.append(f"{h.get('이름')} {words or ''}".strip())
pct = [
[str(d.get("이름")), float(v)]
for d in row.get("덧줄") or []
for v in _PCT.findall(str(d.get("식") or ""))
]
if is_mach_logic(row):
mach.append([sorted(FIXED_MACH.get(code, set())), []])
pick_text = " ".join(labels + [str(s.get("이름")) for s in picks])
return {
"로직": code,
"이름": str(row.get("이름") or ""),
"단위": unit,
"직종": sorted(trades),
"장비": mach,
"장비고름": opt_mach,
"자재": mats,
"덧줄율": pct,
"이름조건": {k: sorted(v) for k, v in conds(row.get("이름")).items()},
"고름조건": {k: sorted(v) for k, v in conds(pick_text).items()},
"기계경비": is_mach_logic(row),
}
def combos(row: dict[str, Any]) -> list[dict[str, Any]]:
"""변형 입력 — 고르기 곱 64 이하 모두 · 넘으면 하나씩(기본 + 칸마다)."""
picks = []
for s in row.get("입력") or []:
opts = s.get("고르기") or []
if not opts or any(w in str(s.get("이름")) for w in SKIP_IN):
continue
if any(isinstance(v, str) and EQ_CODE.match(v) for v in opts):
continue
picks.append((str(s["이름"]), list(opts)))
if not picks:
return [{}]
if math.prod(len(o) for _, o in picks) <= 64:
names = [n for n, _ in picks]
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o in picks])]
return [{}] + [{n: v} for n, opts in picks for v in opts]
def _sig(v: dict) -> str:
r = lambda x: float(f"{x:.4g}") # noqa: E731
return json.dumps(
[
sorted((k, r(q)) for k, q in v["L"].items()),
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"]),
sorted((k, r(q)) for k, q in v["M"].items()),
],
ensure_ascii=False,
)
def fingerprint(code: str, unit: str) -> dict[str, Any]:
"""로직 하나 → 정적 지문 + 변형(서로 다른 품 꼴만)."""
row = whole().logic(code)
fp = _static(code, row, unit)
seen, variants, errors = set(), [], 0
for given in combos(row) if not fp["기계경비"] else [{}]:
try:
v = _engine(code, given) # 기계경비 로직도 엔진 그대로(맞춤이 고르기 장비로 봄)
except Exception: # noqa: BLE001 — 못 도는 조합은 셈만
errors += 1
continue
if (k := _sig(v)) not in seen:
seen.add(k)
variants.append({"in": {n: str(x) for n, x in given.items()}, **v})
return {**fp, "갈래수": len(variants), "오류": errors, "변형": variants}
# ── 표 · 캐시 · 데움 ───────────────────────────────────────────────────
def _read(ver: str) -> list[dict] | None:
try:
doc = json.loads(path_of(ver).read_bytes())
except (OSError, ValueError):
return None
ok = isinstance(doc, dict) and doc.get("판") == ver and doc.get("지문판") == FP_VERSION
return doc.get("표") if ok else None
def build(ver: str | None = None) -> list[dict]:
"""1,438 돈 로직 지문을 셈 → 디스크 캐시(같은 판이면 덮음)."""
ver = ver or version()
table = [fingerprint(one["로직"], one["단위"]) for one in hopyo_match.targets()["로직"]]
atomic_write_json(path_of(ver), {"판": ver, "지문판": FP_VERSION, "표": table})
return table
def table() -> list[dict] | None:
"""우리 지문(판이 같은 것) · 준비 전 None — 디스크에 있으면 읽어 둠."""
ver = version()
with _LOCK:
if _STATE.get("표판") != ver:
_STATE.update(표=_read(ver), 표판=ver)
return _STATE["표"]
def ready() -> bool:
return table() is not None
def warm(build_missing: bool = False) -> threading.Thread | None:
"""디스크 캐시 읽기(뒤) · `build_missing` 이면 없을 때 뒤 스레드로 셈 — 셈 중이면 그 스레드."""
running = _STATE.get("스레드")
if running is not None and running.is_alive():
return running
def work() -> None:
try:
if table() is None and build_missing:
ver = version()
got = build(ver)
with _LOCK:
_STATE.update(표=got, 표판=ver)
except Exception: # noqa: BLE001 — 마스터가 깨져도 서버를 막지 않음(보기에서 「준비 중」)
pass
thread = threading.Thread(target=work, name="m02-hopyo-ours", daemon=True)
_STATE["스레드"] = thread
thread.start()
return thread
+177
View File
@@ -0,0 +1,177 @@
"""M02 일위대가 맞춤 몫(PLAN 338-1 · 설계 2-2 · 2-7) — 단가산출 블록을 맨 윗 절(1. 2. 3.)
· 작은 절(가. 나.)로 갈라 몫마다 노임(직종 → 인) · 장비(「굴착기|0.6」 → 시간) 벡터.
엑셀꼴 = 「직종 : 노임 × 식 = 금액」(식을 못 풀면 금액 ÷ 노임) · 장비 이름 줄 뒤 「노무비 : 단가 ÷ Q
× 몫」 ·
소계 · 합계 절 뺌. STmate = 자원 L · X(절 = 자원 `절` · 없으면 한 몫). 금액 안 씀.
규칙은 336 실험(C5 `c01_trees.sections` · `sections_stm`) 그대로 · 장비 갈래는
`M02_Hopyo_Words.mtypes`.
"""
from __future__ import annotations
import ast
import operator
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc
_SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|kw|hp)", re.I)
_OPS = {
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Add: operator.add,
ast.Sub: operator.sub,
}
_TRADE = re.compile(
r"^[\s*·\-○◇]*([가-힣][가-힣\s]*?)\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*([\d,]+(?:\.\d+)?))?\s*$"
)
_SECTION = re.compile(r"^\s*(\d+)\s*\.\s*(.*)$")
_EQ_COST = re.compile(r"^\s*노\s*무\s*비\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*[\d,.]+)?\s*$")
_SKIP_SEC = re.compile(r"^(합\s*계|소\s*계|계)")
_SUBLABEL = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
_COSTLINE = re.compile(r"^\s*(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:")
def trade(name: Any) -> str | None:
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None."""
names = hopyo_basis.trade_names()
flat = re.sub(r"\s+", "", str(name or ""))
return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat))
def eq_key(text: Any) -> str | None:
"""장비 글 → 「굴착기|0.6」(갈래 + 로 묶음 · 크기 없으면 「굴착기|」) · 장비 아니면 None."""
kinds = mtypes(text)
if not kinds:
return None
m = _SIZE.search(nfkc(text).replace(",", ""))
size = (
m.group(1).rstrip("0").rstrip(".") if m and "." in m.group(1) else m.group(1) if m else ""
)
return f"{'+'.join(sorted(kinds))}|{size}"
def _eval(node: ast.AST) -> float:
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval(node.operand)
raise ValueError("식 아님")
def qty_of(expr: str) -> float | None:
"""「273,540 × 3인 ÷ 55㎥ × 2」 → 3/55×2 · 「59,020 ÷ 23.1 × 90%」 → 1/23.1×0.9."""
s = re.sub(r"\([^()0-9]*\)", "", str(expr)).replace(",", "")
s = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", s)
s = s.replace("×", "*").replace("÷", "/").replace("X", "*").replace("x", "*")
s = re.sub(r"/\s*(?=[^\d(\s.])", " ", s) # 「인/㎥」 의 / 는 단위
s = re.sub(r"[^\d.+\-*/() ]", " ", s)
s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 「4.」 → 4
s = re.sub(r"\(\s*\)", " ", s).strip()
m = re.match(r"\s*\d+(?:\.\d+)?", s)
if not m:
return None
s = "1" + s[m.end() :]
s = re.sub(r"(?<=[\d)])\s+(?=[\d(])", "*", s) # 빈칸으로 붙은 수는 곱
try:
v = _eval(ast.parse(s, mode="eval"))
except (SyntaxError, ValueError, ZeroDivisionError):
return None
return v if 0 < v < 1e4 else None
def _part(title: str, parent: str) -> dict[str, Any]:
return {"제목": title, "절": parent, "노임": {}, "장비": {}}
def sections(texts: list[str]) -> list[dict[str, Any]]:
"""블록 몸 글 → 몫마다 {제목, 절, 노임, 장비} — 노임 · 장비가 하나도 없는 몫은 뺌."""
out: list[dict[str, Any]] = []
cur: dict[str, Any] | None = None
eq, parent = None, ""
for raw in texts:
line = str(raw).rstrip()
sec = _SECTION.match(line)
if sec and not re.match(r"^\s*\d+\.\d", line):
title = sec.group(2).strip()
if _SKIP_SEC.match(title.replace(" ", "")):
cur = None
continue
parent = title
cur = _part(title, title)
out.append(cur)
eq = eq_key(title)
continue
sub = _SUBLABEL.match(line) if cur is not None else None
if sub and not _SKIP_SEC.match(sub.group(1).replace(" ", "")):
cur = _part(f"{parent} · {sub.group(1).strip()}", parent)
out.append(cur)
if cur is None:
if out or not line.strip():
continue
cur = _part("", "")
out.append(cur)
if re.search(r"(소계|합계|인부소계)", line.replace(" ", "")):
eq = None
continue
sub = _SUBLABEL.match(line)
if sub:
eq = eq_key(sub.group(1))
elif not _COSTLINE.match(line) and (k := eq_key(line)):
eq = k
m = _EQ_COST.match(line)
if m and eq:
q = qty_of(f"{m.group(1)} {m.group(2)}")
if q and q < 50:
cur["장비"][eq] = cur["장비"].get(eq, 0) + q
continue
t = _TRADE.match(line)
if t and (who := trade(t.group(1))):
q = qty_of(f"{t.group(2)} {t.group(3)}")
if q is None and t.group(4): # 식을 못 풀면 금액 ÷ 노임(품 되찾기만)
q = float(t.group(4).replace(",", "")) / float(t.group(2).replace(",", ""))
if q:
cur["노임"][who] = cur["노임"].get(who, 0) + q
return [s for s in out if s["노임"] or s["장비"]]
def sections_stm(res: list[dict[str, Any]], texts: list[str]) -> list[dict[str, Any]]:
"""STmate 자원(L · X) → 몫(자원 `절` = 글 줄 차례면 그 앞 맨 윗 절 · 글이면 그 제목 · 없으면 한
몫)."""
marks = []
for at, t in enumerate(texts):
sec = _SECTION.match(t)
if sec and not re.match(r"^\s*\d+\.\d", t):
if not _SKIP_SEC.match(sec.group(2).replace(" ", "")):
marks.append((at, sec.group(2).strip()))
def which(where: Any) -> str:
if isinstance(where, str):
return where
if isinstance(where, int) and not isinstance(where, bool):
got = [m for m in marks if m[0] <= where]
return got[-1][1] if got else ""
return ""
secs: dict[str, dict[str, Any]] = {}
seen = set()
for x in res:
q, kind = x.get("품"), x.get("갈래")
if not q or kind not in ("L", "X") or (x.get("코드"), q, x.get("절")) in seen:
continue
seen.add((x.get("코드"), q, x.get("절")))
title = which(x.get("절"))
s = secs.setdefault(title, _part(title, title))
if kind == "L" and (who := trade(x.get("이름"))):
s["노임"][who] = s["노임"].get(who, 0) + float(q)
elif kind == "X":
k = eq_key(f"{x.get('이름') or ''} {x.get('규격') or ''}") or f"?{x.get('코드')}"
s["장비"][k] = s["장비"].get(k, 0) + float(q)
return [s for s in secs.values() if s["노임"] or s["장비"]]
+200
View File
@@ -0,0 +1,200 @@
"""M02 일위대가 맞춤 — 블록 하나 셈 · 판정(PLAN 338-1 · 설계 2-1 · 2-6 · 3-2).
블록 + 줄 → 엑셀 지문(`Finger`) → 이름 · 근거 차례(`Match.b_order`) → 지문 순위 섞기(`Fit`) ·
같음 1위 고정(딱 맞음이 이김) → 섞인 줄 몫 먼저 · 조합(`Combine.solve`) → 위 다섯 옵션
거꾸로(`Option`) →
판정 맞음 · 다른 점 · 후보 + 후보 다섯(로직마다 옵션 판정 꼬리표).
글 · 꼬리표는 숫자 사실만(엑셀 · 우리 품) — 「옛 판」 같은 까닭은 짓지 않음.
"""
from __future__ import annotations
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Combine as combine
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Option as option
def _num(x: float) -> str:
return f"{x:.4g}"
def excel_qty(efp: dict[str, Any]) -> dict[str, Any]:
"""엑셀 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
return {
"인력": {k: round(x["품"], 6) for k, x in efp["인력"].items() if x.get("품")},
"장비": [{"갈래": m["갈래"], "시간": m["시간"]} for m in efp["장비"] if m.get("시간")],
"자재": [{"이름": m["이름"], "수량": m["품"]} for m in efp["자재"] if m.get("품")],
}
def _public(efp: dict[str, Any]) -> dict[str, Any]:
keep = ("인력", "장비", "자재", "출처", "조건", "옛판", "율", "계수")
return {k: efp.get(k) for k in keep}
def _label(code: str, values: dict[str, Any]) -> str:
"""옵션 요약 — 고르기 글은 그대로 · 수 칸은 「칸 값」 · 기계 코드는 고르기이름."""
specs = {s.get("이름"): s for s in hopyo_match._logic_specs(code)}
out = []
for name, v in values.items():
names = (specs.get(name) or {}).get("고르기이름") or {}
out.append(str(names.get(v) or v) if isinstance(v, str) else f"{name} {v}")
return " · ".join(out)
def _part_fp(part: dict[str, Any], x: float, line: dict[str, Any]) -> dict[str, Any]:
"""조합 몫 → 옵션 찾기용 지문(몫 x 를 풀어 100% 품)."""
fp = finger.empty(line)
k = x or 1.0
for t, q in (part.get("노임") or {}).items():
fp["인력"][t] = {"품": q / k, "밑품": q / k, "율": [], "어떻게": ["몫"]}
return fp
def _diffs(efp: dict, found: dict) -> list[str]:
"""숫자 사실 꼬리표 — 직종 품 다름 · 섞음 · 율 · 엑셀만 · 우리만 장비."""
tags = []
ours_l = (found.get("우리품") or {}).get("인력") or {}
for t, x in efp["인력"].items():
a, b = x.get("품"), ours_l.get(t)
if a and b and not option.near(a, b, fit.TOL[2]):
tags.append(f"{t} 엑셀 {_num(a)} · 우리 {_num(b)}")
elif a and not b:
tags.append(f"{t} 엑셀만 {_num(a)}")
tags += [f"{t} 우리만 {_num(q)}" for t, q in ours_l.items() if t not in efp["인력"]]
for x in efp["인력"].values():
for v, note in x.get("율") or []:
tag = f"섞음 — 엑셀이 × {_num(v)}" if note == "섞음" else f"율 {note} × {_num(v)}"
if v and tag not in tags:
tags.append(tag)
tags += [d for d in ((found.get("견줌") or {}).get("덧") or []) if d not in tags]
return tags
def _state(efp: dict, code: str, found: dict | None, theirs: list) -> str:
lvl = 9 if (found is None or fit.single(efp)) else found["수준"]
if lvl <= 1:
return "맞음"
our = hopyo_basis.ours().get(code)
clause_ok = bool(our and theirs and hopyo_match.clause(theirs, our)[0] >= 0.9)
if lvl == 2 or (found and found.get("직종같음") and clause_ok):
return "다른 점"
return "후보"
def _no_numbers(entry: dict, top: list[str], why: str) -> dict[str, Any]:
entry["순위"] = [{"로직": k, "점수": None, "B": i} for i, k in enumerate(top[: fit.TOP_OPT])]
entry["차례"] = top[: fit.TOP_OPT]
entry["판정"] = {"상태": "후보", "글": why, "꼬리표": [why]}
return entry
def block(block_: dict | None, line: dict[str, Any], got: dict[str, Any]) -> dict[str, Any]:
"""블록(또는 `{_지문}` 직접 줄 호표) + 줄 → 맞춤셈 한 칸(설계 3-2)."""
if block_ and "_지문" in block_:
efp, text = block_["_지문"], []
else:
efp, text = finger.excel(block_, line), (block_ or {}).get("글") or []
border = hopyo_match.b_order(line, got, text)
entry: dict[str, Any] = {"지문": _public(efp), "품": excel_qty(efp), "B": border["상태"]}
entry.update(옵션={}, 조합=None)
if not fit.has_numbers(efp):
return _no_numbers(entry, border["top"], fit.NO_NUMBER)
if fit.prepared() is None:
return _no_numbers(entry, border["top"], "우리 로직 준비 중")
e = fit.ex_prep(efp)
pool = fit.rank(e)
btop = border["top"]
scores = {k: s for s, k, _, _ in pool}
fused = fit.fuse(e, pool, btop)
if border["상태"] == "같음": # 같음 1위 고정 — 지문 딱 맞음이 다른 로직이면 지문 1위
first = pool[0][1] if fit.sure(e, pool) and pool[0][1] != btop[0] else btop[0]
top = [first] + [k for k in fused if k != first][:4]
else:
top = fused
parts = efp.get("몫") or []
if parts and btop and combine.is_mixed(parts, line): # 섞인 줄 — 이름 · 근거 차례로 몫 먼저
top = btop[: fit.TOP_OPT]
top, combo = combine.solve(top, parts, line, got, border["합침"])
opts = {k: option.find(k, efp, text) for k in top[: fit.TOP_OPT]}
for c in combo:
if c["로직"] not in opts and c["체크"]:
part = next((p for p in parts if p.get("제목") == c["어디"]), None)
pfp = _part_fp(part, c["몫"], line) if part else efp
opts[c["로직"]] = option.find(c["로직"], pfp, text) if pfp["인력"] else None
theirs = efp["출처"]
chans = {k: ch for _, k, ch, _ in pool}
entry["순위"] = [
{
"로직": k,
"점수": round(scores[k], 3) if k in scores else None,
"채널": {a: round(b, 3) for a, b in (chans.get(k) or {}).items() if b is not None},
"B": btop.index(k) if k in btop else None,
}
for k in top[: fit.TOP_OPT]
]
entry["차례"] = top[: fit.TOP_OPT]
entry["옵션"] = opts
entry["조합"] = combo or None
entry["판정"] = judge(efp, top, opts, theirs, got)
entry["후보"] = candidates(efp, top, opts, theirs, got, scores)
return entry
def judge(efp: dict, top: list[str], opts: dict, theirs: list, got: dict) -> dict[str, Any]:
"""1위 판정 {상태, 글, 꼬리표}."""
if not top:
return {"상태": "후보", "글": "후보 없음", "꼬리표": []}
names = {x["로직"]: x["이름"] for x in got["로직"]}
t1, found = top[0], opts.get(top[0]) or {}
state = _state(efp, t1, opts.get(t1), theirs)
exact = [k for k in top[: fit.TOP_OPT] if _state(efp, k, opts.get(k), theirs) == "맞음"]
tags = []
if state == "맞음":
label = _label(t1, found.get("입력") or {})
text = " · ".join(x for x in ("맞음 — " + t1, names.get(t1, ""), label) if x)
if len(exact) > 1:
tags.append("맞음 둘")
if found.get("갈리는칸"):
tags.append(f"{' · '.join(found['갈리는칸'])} 못 가름")
return {"상태": state, "글": text, "꼬리표": tags}
if state == "다른 점":
tags = _diffs(efp, found)
return {"상태": state, "글": " · ".join(["다른 점 — " + t1, *tags[:2]]), "꼬리표": tags}
return {"상태": "후보", "글": f"후보 {len(top[: fit.TOP_OPT])}", "꼬리표": []}
def candidates(
efp: dict, top: list[str], opts: dict, theirs: list, got: dict, scores: dict
) -> list[dict[str, Any]]:
"""후보 다섯 — 로직마다 옵션 판정 꼬리표 · 「맞음 둘」이면 둘째 맞음이 맨 앞."""
by = {x["로직"]: x for x in got["로직"]}
table = hopyo_basis.ours()
out = []
for k in top[: fit.TOP_OPT]:
found = opts.get(k) or {}
state = _state(efp, k, opts.get(k), theirs)
our = table.get(k)
sc, clauses, why = hopyo_match.clause(theirs, our) if (our and theirs) else (0, [], "")
one = by.get(k) or {"로직": k, "이름": "", "단위": ""}
text = state if state != "다른 점" else " · ".join(["다른 점", *_diffs(efp, found)[:1]])
out.append(
{
"로직": k,
"이름": one["이름"],
"단위": one["단위"],
"점수": round(scores[k], 3) if k in scores else None,
"판정": state,
"글": text,
"근거": {"조항": clauses, "까닭": why},
}
)
first = [c for c in out[1:] if c["판정"] == "맞음"][:1]
if out and out[0]["판정"] == "맞음" and first:
out = [out[0], *first, *[c for c in out[1:] if c is not first[0]]]
return out
+247
View File
@@ -0,0 +1,247 @@
"""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
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
"""338 골든 도우미 — 실물 엑셀(`resources/knowledge/original/실무문서`)을 지금 읽기로 읽어 정답 표
(`fixtures/m02_hopyo_golden.json`)와 맞대고 지표(설계 7-3)를 셈. 읽기만.
블록 글 = HP 블록에 `글` 이 있으면 그것(나 창 2a 뒤) · 없으면 단가산출 시트 산출근거 열 글(엑셀꼴) ·
거창 = 줄 칸을 이어 붙임. STmate 파일은 읽기(`M02_Hopyo_Excel.read_book`)가 되면 들어감 · 아니면 뺌.
python resources/tester/helper_m02_hopyo_golden.py → 지표 찍음 · tmp/m02_338/golden.json
"""
from __future__ import annotations
import json
import sys
import time
from functools import lru_cache
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402
DOCS = ROOT / "resources/knowledge/original/실무문서"
FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_golden.json"
FILES = {
"북부청임도": DOCS
/ "2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
"북부청사방": DOCS
/ "2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls",
"거창": DOCS
/ "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
"봉화": DOCS
/ "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"동부청영월": DOCS / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"울진대흥": DOCS
/ "2024년 간선임도(기번3-울진.대흥)(공통)"
/ "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"영덕계류": DOCS
/ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료"
/ "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"울진소광": DOCS
/ "25년 산불진화임도(기번8)(울진소광)"
/ "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
LABELED = ("북부청임도", "북부청사방", "동부청영월")
def labels() -> list[dict[str, Any]]:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def _row_text(row: list, start: int) -> str:
cells = []
for v in row[start:140]:
if v is None or v == "":
continue
cells.append(str(int(v) if isinstance(v, float) and v == int(v) else v).strip())
return " ".join(cells)
@lru_cache(maxsize=8)
def read(tag: str) -> dict[str, Any] | None:
"""파일 → HP 꼴(블록 `글` 붙임) · 못 읽으면 None(STmate 읽기 전)."""
path = FILES[tag]
data = path.read_bytes()
try:
book = hx.read_book(path.name, data)
except hx.ExcelError:
return None
if any(b.get("글") or b.get("자원") for b in book.get("단산") or []):
return book
calc = hx._sheets(path.name, data).get(hb.SHEET) or []
_, col, _ = hb._columns(calc)
join = tag == "거창"
for b in book.get("단산") or []:
a, z = b["행"]
rows = calc[a:z]
text = [_row_text(r, col) if join else (r[col] if col < len(r) else "") for r in rows]
b["글"] = [str(t).rstrip() for t in text if isinstance(t, str) and t.strip()]
return book
def _key(line: dict[str, Any]) -> tuple:
ref = line.get("참조") or {}
no = ref.get("번") if ref.get("종류") == "단산" else None
code = str(ref.get("코드") or line.get("코드") or "")
return line.get("이름"), line.get("규격") or "", line.get("단위") or "", no, code[:6]
def _lab_key(lab: dict[str, Any]) -> tuple:
return lab["이름"], lab["규격"], lab["단위"], lab.get("단산"), str(lab.get("코드") or "")[:6]
def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]:
"""정답 표 id → (블록, 줄) — 시험이 실물 줄 하나를 꺼냄."""
lab = next(x for x in labels() if x["id"] == lid)
book = read(lab["파일"])
blocks = {int(b["번"]): b for b in book.get("단산") or []}
for h in book["호표"]:
for line in h["줄"]:
if _key(line) == _lab_key(lab) or (
not line.get("참조") and _key(line)[:3] == _lab_key(lab)[:3]
):
ref = line.get("참조") or {}
return (blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None), line
raise KeyError(lid)
def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
"""{줄: {id: 결과}, 호표: {(파일, 번): (ids, refs)}, 읽음: [파일], 시간}."""
labs = labels()
tags = tags or tuple(dict.fromkeys(lab["파일"] for lab in labs))
got = hm.targets()
out: dict[str, Any] = {"줄": {}, "호표": {}, "읽음": [], "못읽음": [], "시간": {}}
for tag in tags:
book = read(tag)
if book is None:
out["못읽음"].append(tag)
continue
out["읽음"].append(tag)
want = {_lab_key(lab): lab for lab in labs if lab["파일"] == tag}
loose = {k[:3]: lab for k, lab in want.items()}
blocks = {int(b["번"]): b for b in book.get("단산") or []}
t0 = time.time()
for h in book["호표"]:
ids, refs = [], []
for line in h["줄"]:
ref = line.get("참조") or {}
if ref.get("종류") == "일위" and ref.get("번"):
refs.append(int(ref["번"]))
lab = want.get(_key(line)) or (loose.get(_key(line)[:3]) if not ref else None)
if lab is None:
continue
ids.append(lab["id"])
if lab["id"] in out["줄"]:
continue
block = blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None
out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)}
out["호표"][f"{tag}|{h['번']}"] = (list(dict.fromkeys(ids)), refs)
out["시간"][tag] = round(time.time() - t0, 1)
return out
def chosen_of(entry: dict[str, Any]) -> set[str]:
"""줄이 고른 로직(T1) — 조합이면 체크한 것 · 아니면 1위."""
combo = entry.get("조합") or []
top = (entry.get("차례") or [None])[0]
codes = {c["로직"] for c in combo if c["로직"] and c.get("체크")}
return codes or ({top} if top else set())
def _gather(res: dict, key: str, seen: tuple = ()) -> list[str]:
tag = key.split("|")[0]
if key not in res["호표"] or key in seen:
return []
ids, refs = res["호표"][key]
out = list(ids)
for no in refs:
out += _gather(res, f"{tag}|{no}", (*seen, key))
return list(dict.fromkeys(out))
def metrics(res: dict[str, Any], group: set[str] | None = None) -> dict[str, Any]:
"""설계 7-3 지표 — 사람 정답 줄 · 새 파일 · 조합 맞음(T1 · 덮을 수 있는 호표)."""
rows = [r for r in res["줄"].values() if group is None or r["lab"]["무리"] in group]
have = [r for r in rows if r["lab"]["정답"]]
ok1 = lambda r: (r["셈"]["차례"] or [None])[0] in set(r["lab"]["정답"]) # noqa: E731
state = lambda r: r["셈"]["판정"]["상태"] # noqa: E731
opt_ok = 0
for r in have:
top = (r["셈"]["차례"] or [None])[0]
found = (r["셈"]["옵션"] or {}).get(top) or {}
if ok1(r) and found.get("수준", 9) <= 1 and state(r) == "맞음":
opt_ok += 1
out = {
"줄": len(have),
"1순위": sum(ok1(r) for r in have),
"다섯 안": sum(bool(set(r["셈"]["차례"][:5]) & set(r["lab"]["정답"])) for r in have),
"맞음": sum(state(r) == "맞음" for r in rows),
"맞음 틀림": sum(state(r) == "맞음" and not ok1(r) for r in rows),
"다른 점": sum(state(r) == "다른 점" for r in rows),
"다른 점 틀림": sum(state(r) == "다른 점" and not ok1(r) for r in rows),
"옵션까지": opt_ok,
}
keys = [k for k in res["호표"] if k.split("|")[0] in LABELED]
whole = cover = 0
by = res["줄"]
for key in keys:
ids = [
i
for i in _gather(res, key)
if i in by and (group is None or by[i]["lab"]["무리"] in group)
]
if not ids:
continue
slots, chosen = [], set()
for i in ids:
lab = by[i]["lab"]
if not lab["정답"]:
slots.append(set())
elif lab.get("복합") and lab["id"] != "S062":
slots += [{x} for x in lab["복합"]]
else:
slots.append(set(lab["정답"]))
chosen |= chosen_of(by[i]["셈"])
if any(not s for s in slots):
continue
cover += 1
alts = set().union(*slots)
whole += all(chosen & s for s in slots) and not (chosen - alts)
out["조합 호표"], out["조합 맞음"] = cover, whole
return out
def show(res: dict[str, Any]) -> dict[str, Any]:
table = {
"사람": metrics(res, {"사람"}),
"새 파일": metrics(res, {"자동", "씨앗", "자동+씨앗"}),
**{
tag: metrics(
{**res, "줄": {i: r for i, r in res["줄"].items() if r["lab"]["파일"] == tag}},
{"사람"},
)
for tag in LABELED
if tag in res["읽음"]
},
}
return {"읽음": res["읽음"], "못읽음": res["못읽음"], "시간": res["시간"], "지표": table}
if __name__ == "__main__":
t = time.time()
res = evaluate(tuple(sys.argv[1:]) or None)
summary = show(res)
print(json.dumps(summary, ensure_ascii=False, indent=1))
print("전체", round(time.time() - t, 1), "s")
dump = {
i: {
"lab": r["lab"],
"차례": r["셈"]["차례"],
"판정": r["셈"]["판정"],
"조합": r["셈"]["조합"],
"B": r["셈"]["B"],
}
for i, r in res["줄"].items()
}
(ROOT / "tmp/m02_338").mkdir(parents=True, exist_ok=True)
(ROOT / "tmp/m02_338/golden.json").write_text(
json.dumps(dump, ensure_ascii=False, indent=1, default=str), "utf-8"
)
+147
View File
@@ -0,0 +1,147 @@
"""338-1 엑셀 지문(`M02_Hopyo_Finger` · `_Words` · `_Shares`) — 식 풀기 일곱 꼴 · 합판 지문 · 고임돌
섞음 ·
몫 · STmate 자원 · 기계 줄 · 직접 줄 호표. 실물 = 북부청 임도(읽기만)."""
import pytest
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs
from resources.tester.helper_m02_hopyo_golden import line_of
approx = pytest.approx
# ── 식 풀기 ────────────────────────────────────────────────────────────
def test_식_단가_떼고_품():
got = parse_rhs("273,540 × 3인 ÷ 55㎥ × 2 = 29,840.7")
assert got["단가"] == 273540 and got["품"] == approx(0.10909, abs=1e-5) and got["율"] == []
def test_식_퍼센트_율():
got = parse_rhs("59,020 ÷ 23.1 × 90% = 2,299.5")
assert got["품"] == approx(0.03896, abs=1e-5)
assert got["밑품"] == approx(1 / 23.1) and got["율"] == [[0.9, "%"]]
def test_식_인당_단위_섞음():
got = parse_rhs("172,068 × 0.3인/㎥ × 10% = 5,162")
assert got["품"] == approx(0.03) and got["밑품"] == approx(0.3)
def test_식_바꿔_쓴_식은_앞_율을_이어받음():
got = parse_rhs("215,907 × 4인 ÷ P × 1.2(할증) = 215,907 × 0.104 × 1.2 = 26,945")
assert got["품"] == approx(0.1248) and got["밑품"] == approx(0.104)
assert got["율"] == [[1.2, "할증"]]
def test_식_횟수_품할증():
got = parse_rhs("172,068 × 0.05인 × 2회 = 17,206")
assert got["품"] == approx(0.1) and got["밑품"] == approx(0.05) and got["율"] == [[2.0, "회"]]
got = parse_rhs("273,540 × 0.1 × 1.2(품할증) = 32,824")
assert got["밑품"] == approx(0.1) and got["율"] == [[1.2, "품할증"]]
def test_식_못_풀면_역산():
assert parse_rhs("273,540 × P = 30,000") is None
block = {"번": 1, "이름": "시험", "단위": "㎥", "글": [" 특별인부 : 273,540 × P = 27,354"]}
fp = finger.excel(block, {"이름": "시험", "규격": "", "단위": "㎥"})
assert fp["인력"]["특별인부"]["품"] == approx(0.1) and fp["인력"]["특별인부"]["어떻게"] == [
"역산"
]
def test_장비_갈래_조건_낱말():
assert mtypes("유압식 백호우 0.7㎥") == {"굴착기"} and mtypes("펌프카 41m") == {"콘크리트펌프"}
assert conds("합판거푸집 4회 보통")["횟수"] == {"4회"}
assert conds("구조물터파기(암)")["토질"] == {"암"}
# ── 실물 지문 ──────────────────────────────────────────────────────────
def test_합판_4회_지문():
block, line = line_of("N001")
fp = finger.excel(block, line)
assert fp["인력"]["형틀목공"]["품"] == approx(0.1111, abs=1e-4)
assert fp["인력"]["보통인부"]["품"] == approx(0.0444, abs=1e-4)
assert "4회" in fp["조건"]["횟수"] and fp["출처"] == [["건설품셈", "6-3-1"]]
assert {m["이름"][:4] for m in fp["자재"] if m["품"]} >= {"내수합판"}
def test_레미콘_소량_지문_장비_시간():
block, line = line_of("N000")
fp = finger.excel(block, line)
assert fp["인력"]["콘크리트공"]["품"] == approx(0.10909, abs=1e-5)
assert [(m["갈래"], round(m["시간"], 2)) for m in fp["장비"]] == [(["굴착기"], 0.29)]
def test_고임돌_섞음_율과_몫():
block, line = line_of("N005")
fp = finger.excel(block, line)
labor = fp["인력"]["보통인부"]
assert labor["밑품"] == approx(0.3, abs=1e-3) and labor["품"] == approx(0.03, abs=1e-3)
assert any(note in ("%", "섞음") and v == approx(0.1) for v, note in labor["율"])
parts = fp["몫"]
assert any(p["노임"] and not p["장비"] for p in parts) # 인력 몫
assert any(p["장비"] and not p["노임"] for p in parts) # 장비 몫
def test_몫_절마다_노임_장비():
texts = [
" 1. 굴착기 0.6㎥ 채집(90%)",
" 노무비 : 59,020 ÷ 23.1 × 90% = 2,299",
" 2. 인력채집(10%)",
" 보통인부 : 172,068 × 0.3인/㎥ × 10% = 5,162",
" 3. 합 계",
" 보통인부 : 172,068 × 1 = 172,068",
]
parts = shares.sections(texts)
assert [p["제목"] for p in parts] == ["굴착기 0.6㎥ 채집(90%)", "인력채집(10%)"]
assert parts[0]["장비"] == {"굴착기|0.6": approx(0.9 / 23.1)}
assert parts[1]["노임"] == {"보통인부": approx(0.03)}
def test_STmate_자원_지문():
block = {
"글": ["잔토처리 (기계100%) 건설표준품셈9-3", "q = 0.7 , K = 0.9"],
"자원": [
{"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.2},
*[
{
"갈래": "X",
"코드": "X00205",
"이름": "굴삭기(무한궤도)",
"규격": "0.7㎥",
"품": 0.0229,
}
]
* 3,
{"갈래": "M", "코드": "M00001", "이름": "모래", "규격": "", "품": 1.2},
],
}
fp = finger.excel(block, {"이름": "잔토처리", "규격": "토사", "단위": "M3"})
assert fp["인력"] == {"보통인부": {"품": 0.2, "밑품": 0.2, "율": [], "어떻게": ["QTY"]}}
assert [(m["갈래"], m["규격"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], [0.7], 0.0229)]
assert fp["자재"] == [{"이름": "모래", "품": 1.2, "밑품": 1.2}] and fp["계수"]["K"] == 0.9
def test_기계_줄_블록_없음():
fp = finger.excel(None, {"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["콘크리트펌프"], 1.0)]
assert 41.0 in fp["장비"][0]["규격"] and fp["인력"] == {}
def test_직접_줄_호표():
hopyo = {
"이름": "돌붙임",
"규격": "",
"단위": "㎡",
"줄": [
{"이름": "석 공", "규격": "", "수량": 0.1, "단위": "인", "갈래": "기타"},
{"이름": "보통인부", "규격": "", "수량": 0.05, "단위": "인", "갈래": "기타"},
{"이름": "굴착기", "규격": "0.6㎥", "수량": 0.2, "단위": "hr", "갈래": "기계"},
],
}
fp = finger.direct(hopyo)
assert fp["인력"]["석공"]["품"] == 0.1 and fp["인력"]["보통인부"]["품"] == 0.05
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], 0.2)]
assert finger.direct({**hopyo, "줄": hopyo["줄"][2:]}) is None # 노임 없으면 호표 통째 아님
+122
View File
@@ -0,0 +1,122 @@
"""338-1 순위 · 판정 · 조합 · 뒤 셈(`M02_Hopyo_Fit` · `_Verdict` · `_Combine`) — 같음 규칙 · 딱
맞음이
같음을 이김 · 맞음 · 다른 점 · 후보 · 철근 둘 · 고임돌 인력 10% · 맞춤셈 캐시 · 「셈 중」.
실물 = 북부청 임도 · 사방(읽기만) · 층 · 캐시 폴더는 임시."""
import shutil
import time
import pytest
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict
from resources.tester.helper_m02_hopyo_golden import line_of, read
from resources.tester.test_m02_hopyo_excel import master_hash
@pytest.fixture(scope="module")
def got():
if not ours.ready():
ours.build()
return hm.targets()
def _block(lid: str, got: dict, **change) -> dict:
block, line = line_of(lid)
if change:
line = {**line, "근거": {}}
block = {**block, "출처": []}
return verdict.block(block, line, got)
def test_같음_규칙_이름_같음_1위_고정(got):
r = _block("N014", got)
assert r["B"] == "같음" and r["차례"][0] == "GC000214" and r["판정"]["상태"] == "맞음"
def test_딱_맞음이_같음을_이김(got):
r = _block("N001", got, 근거=None) # 인용 조항을 지우면 이름 같음 = 산림 GF000160
assert r["B"] == "같음" and hm.b_order(line_of("N001")[1], got)["상태"] == "후보"
assert r["차례"][0] == "GC000211" and r["판정"]["상태"] == "맞음"
def test_판정_셋(got):
a = _block("N000", got)
assert a["판정"]["상태"] == "맞음" and "소량감률 50" in a["판정"]["글"]
assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"] == "조항 같음"
b = _block("N008", got)
assert b["판정"]["상태"] == "다른 점" and b["차례"][0] == "GC000083"
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in b["판정"]["꼬리표"]
c = _block("S084", got)
assert c["판정"]["상태"] == "후보" and len(c["후보"]) == 5
def test_합판_맞음_못_가름_꼬리표(got):
r = _block("N001", got)
assert r["판정"]["상태"] == "맞음"
assert any(
t.endswith("못 가름") and {"측압", "폼타이규격"} <= set(t.split())
for t in r["판정"]["꼬리표"]
)
def test_조합_철근_가공_조립(got):
r = _block("N003", got)
assert {c["로직"] for c in r["조합"] if c["체크"]} == {"GC000206", "GC000207"}
def test_조합_고임돌_인력_몫_먼저(got):
r = _block("N005", got)
assert r["차례"][0] == "GF000211"
labor = next(c for c in r["조합"] if c["로직"] == "GF000211")
assert labor["몫"] == 0.1 and labor["체크"]
assert all(not c["체크"] for c in r["조합"] if c["로직"] in ("GC000267", "GC000268"))
def test_숫자_없음_옛_HP(got):
block, line = line_of("N000")
r = verdict.block({**block, "글": []}, line, got)
assert r["판정"]["상태"] == "후보" and r["판정"]["글"] == fit.NO_NUMBER
assert r["차례"][0] == "GC000198" # 이름 · 근거 차례 그대로
@pytest.fixture
def layer(tmp_path, monkeypatch, got):
cache = tmp_path / "cache"
cache.mkdir()
shutil.copy(ours.path_of(), cache / ours.path_of().name)
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
book = read("북부청임도")
small = {**book, "호표": book["호표"][:3]}
brief = hopyo_store.save(tmp_path, "북부청.xls", small, {"id": "t"})
return tmp_path, brief["키"], cache
def test_뒤_셈_캐시_셈_중(layer):
folder, key, cache = layer
before = master_hash()
assert fit.result(folder, key) is None
first = fit.ensure(folder, key)
assert first["끝"] is False and first["한"] == 0
for _ in range(300):
if fit.status(folder, key)["끝"]:
break
time.sleep(0.2)
done = fit.status(folder, key)
assert done["끝"] and done["한"] == done["모두"] > 0
got = fit.result(folder, key)
assert got["판"] == ours.version() and len(got["블록"]) == done["모두"]
assert fit.cache_path(folder, key).parent == cache
assert fit.ensure(folder, key)["끝"] # 두 번째는 캐시만 봄
assert master_hash() == before
def test_셈_단위_열쇠(got):
book = read("북부청임도")
units = fit.units({**book, "호표": book["호표"][:3]})
keys = [k for k, _, _ in units]
assert len(keys) == len(set(keys)) and keys[0].startswith("단산:")
assert all(k.startswith(("단산:", "줄:", "호표:")) for k in keys)
+63
View File
@@ -0,0 +1,63 @@
"""338-1 옵션 거꾸로 찾기(`M02_Hopyo_Option`) — 레미콘 소량 「하나만」 · 합판 갈리는 칸 · 무근 R
맞음 ·
구조물터파기 없음(다른 점) · 펌프차 기계 칸 · 견줌 · 반올림 폭. 실물 = 북부청 임도 ·
사방(읽기만)."""
import pytest
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Option as option
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from resources.tester.helper_m02_hopyo_golden import line_of
def _find(lid: str, code: str) -> dict:
block, line = line_of(lid)
return option.find(code, finger.excel(block, line), (block or {}).get("글"))
def test_레미콘_소량_하나만():
got = _find("N000", "GC000198")
assert got["수준"] == 0 and got["판정"] == "하나만"
assert got["입력"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50}
assert got["우리품"]["인력"] == {"콘크리트공": 0.109091, "보통인부": 0.036364}
def test_합판_보통_갈리는_칸():
got = _find("N001", "GC000211")
assert got["수준"] == 0 and got["판정"] == "여럿(같은 품)"
assert got["입력"]["유형"] == "보통" and got["입력"]["사용횟수"] == "4회"
assert set(got["갈리는칸"]) == {"측압", "폼타이규격"}
def test_무근_반올림_맞음():
got = _find("N013", "GC000198")
assert got["수준"] <= 1 and got["입력"]["구조물"] == "무근구조물"
def test_구조물터파기_없음_가까운_옵션():
got = _find("N008", "GC000083")
assert got["수준"] == 9 and got["판정"] == "없음" and got["직종같음"]
assert got["견줌"]["직종"] == "직종 같음" and got["거리"] < 0.1
def test_펌프차_기계_칸():
efp = finger.machine({"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
got = option.find("GC000268", efp)
assert got["수준"] == 0
code = got["입력"]["기계"]
assert ours.eq_of(code)[0] == {"콘크리트펌프"} and 41.0 in option.eq_numbers(code)
def test_없는_로직_못_셈():
efp = finger.machine({"이름": "굴착기", "규격": "0.6㎥", "단위": "hr"})
assert option.find("없는로직", efp)["판정"] == "못 셈"
def test_반올림_폭_견줌():
assert option.ru(0.15) == pytest.approx(0.005) and option.ru(0.1) == 0.0
assert option.ru(0.109091) == 0.0
e = {"인력": {"보통인부": {"품": 0.15, "밑품": 0.15}}, "장비": [], "자재": []}
v = {"L": {"보통인부": 0.1545}, "X": [], "M": {}, "S": {}}
assert not option.compare(e, v, 0.005)["핵심"] and option.compare(e, v, 0.005, rnd=True)["핵심"]
assert not option.strong(e, option.compare(e, v, 0.03)) # 보통인부 하나뿐 = 우연 맞음
+77
View File
@@ -0,0 +1,77 @@
"""338-1 우리 로직 지문(`M02_Hopyo_Ours`) — 판 열쇠 · 캐시 쓰기 · 읽기 · 준비 전 None · 합판 품 ·
기계경비 로직 · 섞기 전 변형 · 엔진 셈 캐시 · master_data 원본 그대로. 캐시 폴더는 임시."""
import pytest
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from resources.tester.test_m02_hopyo_excel import master_hash
approx = pytest.approx
@pytest.fixture
def cache(tmp_path, monkeypatch):
"""임시 캐시 폴더 + 로직 셋만 셈(1,438 대신) — 끝나면 메모리 표를 비움."""
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(tmp_path))
few = [x for x in hm.targets()["로직"] if x["로직"] in ("GC000211", "GC000198", "GC000268")]
monkeypatch.setattr(hm, "targets", lambda: {**hm._targets(id(ours.whole())), "로직": few})
monkeypatch.setitem(ours._STATE, "표판", None)
monkeypatch.setitem(ours._STATE, "표", None)
yield tmp_path
ours._STATE.update(표판=None, 표=None)
def test_판_열쇠_같음():
a, b = ours.version(), ours.version()
assert a == b and len(a) == 16 and ours.path_of(a).name == f"우리지문_{a}.json"
def test_캐시_쓰기_읽기_준비_전_none(cache):
before = master_hash()
assert ours.table() is None and not ours.ready()
built = ours.build()
assert [o["로직"] for o in built] == ["GC000198", "GC000211", "GC000268"]
assert ours.path_of().parent == cache and ours.path_of().is_file()
ours._STATE.update(표판=None, 표=None)
assert [o["로직"] for o in ours.table()] == ["GC000198", "GC000211", "GC000268"]
assert master_hash() == before
def test_데움_뒤_스레드(cache):
thread = ours.warm(build_missing=True)
thread.join(timeout=120)
assert ours.ready() and len(ours.table()) == 3
def test_합판_보통_형틀목공():
fp = ours.fingerprint("GC000211", "㎡")
plain = next(v for v in fp["변형"] if v["in"].get("유형") == "보통")
assert plain["L"]["형틀목공"] == approx(0.112, abs=1e-3)
assert plain["L"]["보통인부"] == approx(0.045, abs=1e-3)
assert fp["직종"] == ["보통인부", "형틀목공"] and not fp["기계경비"]
assert fp["고름조건"]["횟수"] # 고르기 글 「4회사용시」 낱말
def test_기계경비_로직은_고른_기계_1시간():
v = ours.run("GC000268", {})
assert v["L"] == {} and len(v["X"]) == 1 and v["X"][0][2] == 1.0
assert ours.fingerprint("GC000268", "hr")["기계경비"]
def test_섞기_전_변형():
table = ours.table() or ours.build()
mixed = next(
o
for o in table
if any(0 < ((v.get("mix") or {}).get("인력비율") or 0) < 100 for v in o["변형"])
)
prep = fit._prep(mixed, {})
assert any(v["in"] and v["in"].get("_섞기전") for v in prep["vs"])
def test_엔진_셈_캐시():
a = ours.run("GC000198", {"소량감률": 50})
assert ours.run("GC000198", {"소량감률": 50}) is a # 같은 입력은 다시 안 셈
assert ours.run("없는로직", {}) is None