Files
Aislo/M02_MasterTemplete/M02_Hopyo_Combine.py
T
eomsangdonandClaude Opus 5.5 912575d9bb fix(일위대가실무): 339 덧고침 맞춤 셋 — 규격 다른 별칭 꼬리표만 · 같은 장비 여러 줄 품 비교 합침 · 근거 안 닿은 덧 로직 후보로
- 규격 다른 별칭(품명|단위 느슨 열쇠) = 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤 · 상태 · 옵션은 줄 숫자 맞춤
- 품 비교 같은 갈래 장비 여러 줄은 합쳐 견줌(서버 _qty · 화면 compareText)
- 조합 덧 로직 = 단위 맞음 + 조항 · 직종 · 이름 하나 닿을 때만 · 아니면 덧 후보 · 섞인 줄 그대로 · 맞춤셈 판 2
- 골든 사람 126 1순위 118 · 다섯 안 122 · 맞음 57 틀림 0 · 조합 62/83 그대로 · 338 대상 시험 142 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 01:32:53 +09:00

326 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 일위대가 맞춤 — 조합 풀이(PLAN 338-1 · 설계 2-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) · 장비만 든 몫 =
「기계 시간당 기계경비」(체크 안 함 · 시간 = 엑셀 값).
덧은 근거가 닿을 때만(`reached` — 단위 맞음 + 조항 · 직종(보통인부 뺌) · 이름 하나) · 아니면 덧
안 붙임(후보로만) · 섞인 줄(몫 먼저)은 그대로.
순위 덤 = 앞 차례 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["로직"])
trades = {o["로직"]: set(o.get("직종") or ()) for o in table}
_STATE.update(판=(ver, id(table)), 변형=var, 색인=idx, 직종=trades)
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 reached(code: str, line: dict, where: Any, E: dict, prior: Prior, got: dict) -> bool:
"""덧 근거 닿음 — 단위 맞음 + (인용 조항 같음 · 직종 겹침(보통인부 뺌) · 이름 낱말 겹침)."""
one = next((x for x in got["로직"] if x["로직"] == code), None)
if one is None or not fit.unit_fit(line.get("단위"), one["단위"]):
return False
our = prior.table.get(code)
theirs = prior.theirs + hopyo_basis.sources(str(where or ""))
if our and theirs and hopyo_match.clause(theirs, our)[0] >= 0.9:
return True
if (set(E.get("노임") or {}) - {COMMON}) & _load()["직종"].get(code, set()):
return True
loose = lambda t: hopyo_basis._loose_words(t) - hopyo_match._GENERIC # noqa: E731
mine, theirs_w = loose(f"{where or ''} {line.get('이름') or ''}"), loose(one["이름"])
return any(a in b or b in a for a in mine for b in theirs_w)
# ── 풀이 ───────────────────────────────────────────────────────────────
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:
ok = mixed or reached(cand[0][3], line, title, E, prior, got)
f, x, c, g = round(cand[0][1], 3), cand[0][2], cand[0][3], cand[0][4]
chosen.append(one(c, title, f, x, "절" if ok else "후보", g))
have.add(cand[0][3])
if ok:
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]
ok = mixed or reached(c, line, p.get("제목"), p, prior, got)
chosen.append(one(c, p.get("제목"), f, x, "몫" if ok else "후보", 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]], list[str]]:
"""(새 차례, 조합 대상, 덧 후보) — 섞인 줄이고 1순위가 블록 합을 MIXED_CUT 못 덮으면 1순위를
인력 몫 로직으로 바꿈(몫 먼저) · 조합이 하나뿐이면 대상 빈 것 · 덧 후보 = 근거 안 닿은 덧."""
chosen = combine((top or [None])[0], parts, line, got, base)
spare = [c["로직"] for c in chosen if c["단계"] == "후보"]
chosen = [c for c in chosen if c["단계"] != "후보"]
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, [], spare
combo = [{**c, "체크": c["로직"] not in MACHINE_SET or c["로직"] == top[0]} for c in chosen]
return top, combo, spare