"""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/맞춤__<판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 # 블록 몇 개마다 캐시 덮음 CALC_VERSION = 2 # 맞춤셈 꼴 · 셈이 바뀌면 올림 — 옛 캐시 안 씀 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()}_{CALC_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