"""근거 제안 엔진(PLAN 793-3a · 요구 648 · 649 · 652 갈래 C · 분석 648-F H1~H5 · 648-D2) — 서버 단독 · 읽기만. 산근 블록(HP `단산` 꼴) → 절마다 번호 후보(`M02_Hopyo_Clause.basis`) ∩ 값 후보(`M02_San_Basis_Value` · 역색인 · 유의 4) ∩ 제목 후보(이름 두 글자 묶음 Dice ≥ 0.35 · 같은 말 묶음) · 기계 작업량 절(q · K · f · E · Cm 둘+)은 일 갈래(번호 · 제목) 먼저 · 차례 = 분석 648-D2 1 장 · 후보 2~6 이면 엔진 품 대조로 하나만 맞으면 확정 · 같은 기초 절은 묶어 검산(`M02_San_Basis_Check`) · 절 2+ 블록은 블록 통째 길도 셈해 나은 꼴. 꼴(648-D2) = 확정·전부 · 확정·일부 · 확정·일부(절 미확정) · 후보 2~3 · 없음. 단(근거후보 · 화면 · 793-2 SK) = 확정(기초 확정 · 엔진 품 = 엑셀 품 · 품 다름 0) · 근접(기초 확정 · 품 다름 · 품 대조 못 함 · 까닭 표지) · 사람(후보 2~3) · 없음(M01 없음 의심 → `보강` = 794-2 근거). 자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요). 794-2c — 같은 말 묶음(SYN) · 옛 번호 → 현행 절(`old_cands` · `M02_San_Basis_Map.OLD`) 번호 후보 · Q 식 절은 줄 글에서 기호 끝값을 더 읽고(`_fill`) 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계 「작업량당 기계경비」 로직(`by_machine` · 차례 「기계」). """ from __future__ import annotations import re from typing import Any from M02_MasterTemplete import M02_Hopyo_Bind as hb from M02_MasterTemplete import M02_Hopyo_Clause as clause from M02_MasterTemplete import M02_Hopyo_Formula as formula from M02_MasterTemplete import M02_Hopyo_Solve as solve from M02_MasterTemplete import M02_San_Basis_Check as chk from M02_MasterTemplete import M02_San_Basis_Const as bc from M02_MasterTemplete import M02_San_Basis_Index as bi from M02_MasterTemplete import M02_San_Basis_Map as bm from M02_MasterTemplete import M02_San_Basis_Value as bv TITLE_MIN, TITLE_GAP, TITLE_TOP = 0.35, 0.15, 5 SYN = [ ("B/H", "굴착기"), ("BACK-HOE", "굴착기"), ("BACKHOE", "굴착기"), ("백호", "굴착기"), ("굴삭", "굴착"), ("D/Z", "불도저"), ("도자", "불도저"), ("불도우저", "불도저"), ("D/P", "덤프"), ("브레카", "브레이커"), ("브레이카", "브레이커"), ("깍기", "깎기"), ("뒷채움", "뒤채움"), ("로울러", "롤러"), ("콤펙터", "콤팩터"), ("레미콘", "레디믹스트콘크리트"), ("컨테이너", "콘테이너"), ("휠라", "채움재"), ("평떼", "평잔디"), ("육샹", "육상"), ] ORDER = ["확정·전부", "확정·일부", "확정·일부(절 미확정)", "후보 2~3", "없음"] NONE = {"확정": [], "동점": [], "약함": []} class Ctx: """역색인 + M01 Master + 로직 이름 묶음(역색인 지문마다 한 벌).""" def __init__(self, idx: bi.Index): self.idx, self.m = idx, hb.m01.cm.master() self.logic = { **self.m.index.get("GC", {}), **self.m.index.get("GF", {}), **self.m.index.get("GX", {}), } self.grams = {k: grams(r.get("이름")) for k, r in self.logic.items()} self.mach = bm.machines(self.logic) _CTX: list = [None, None] def ctx() -> Ctx: idx = bi.get() if _CTX[0] is not idx: _CTX[:] = [idx, Ctx(idx)] return _CTX[1] # ── 제목 → 로직 이름 후보 ─────────────────────────────────────────── def syn(text: Any) -> str: t = str(text or "") for a, b in SYN: t = re.sub(re.escape(a), b, t, flags=re.I) return t def grams(text: Any) -> set: return clause._grams(re.sub(r"\d", " ", syn(text))) def title_cands(c: Ctx, text: str, unit: Any) -> list[tuple[str, float]]: """제목 ↔ 로직 이름 Dice ≥ 0.35 · 1등과 0.15 안 · 다섯까지 · 결과단위 성격 다르면 뺌.""" mine = grams(text) if not mine: return [] nat, scored = clause.nature(unit), [] for k, gs in c.grams.items(): theirs = clause.nature(c.logic[k].get("결과단위")) if not gs or nat and theirs and nat != theirs: continue s = 2 * len(mine & gs) / (len(mine) + len(gs)) if s >= TITLE_MIN: scored.append((k, round(s, 3))) scored.sort(key=lambda x: (-x[1], x[0])) return [x for x in scored if x[1] >= scored[0][1] - TITLE_GAP][:TITLE_TOP] if scored else [] def dice(c: Ctx, key: str, text: str) -> float: a, b = grams(text), c.grams.get(key, set()) return 2 * len(a & b) / (len(a) + len(b)) if a and b else 0.0 # ── 블록 · 절 ─────────────────────────────────────────────────────── def _flat(t: Any) -> str: return re.sub(r"[\s.()\[\]:·,]", "", str(t or "")) def parts_of(blk: dict) -> tuple[list[dict], dict, list]: """일 절(`Formula.works`)마다 {제목, 번, 길, 항목, 상수, 식, 선택값, 몫} · 블록 통째 상수 · 쓸 항목. """ nodes = blk.get("절") or formula.tree( blk.get("글") or [], blk.get("글행"), blk.get("항목") or [] ) top = formula.head(blk.get("글") or [], blk.get("글행")) works = formula.works(nodes) paths = {id(n): p for p, n in clause._paths(nodes)} items = blk.get("항목") or [] usable = solve._usable(items) lines = [str(t) for t in blk.get("글") or []] whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")} _fill(whole, lines) if len(works) < 2: one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, "글줄": lines} one |= whole one["몫"] = works[0].get("몫") if works else None return [one], whole, usable out = [] for w in works: at = [i for i in solve._gather([w], "항목") if not items[i].get("밖")] part = { "제목": f"{w['번']}. {w['제목']}", "번": w["번"], "길": paths.get(id(w)), "항목": [items[i] for i in at], "글줄": lines[w["글"][0] : _end(w) + 1] if w.get("글") else [], } part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")} _fill(part, part["글줄"]) part["몫"] = w.get("몫") out.append(part) if not any(p["항목"] for p in out): # STmate — 글행 없음 → 항목 절 이름으로 붙임 for it in usable: name = _flat(it.get("윗절") or it.get("절")) hit = next( ( p for p in out if name and (_flat(p["제목"]).endswith(name) or name in _flat(p["제목"])) ), None, ) if hit: hit["항목"].append(it) return out, whole, usable def _end(node: dict) -> int: return max([node["글"][1], *(_end(n) for n in node.get("아래") or [] if n.get("글"))]) def _fill(part: dict, lines: list[str]) -> None: """기계 작업량 절(Q 식 줄) — 못 읽은 기호(q · K · f · E · Cm)를 줄 글 끝값으로 더함 · 고르는 차례(`is_machine`)는 채우기 전 상수로.""" if bm.has_q(lines): part["기계식"] = is_machine(part) have = {chk.base_sym(x.get("기호") or "") for x in part["상수"]} part["상수"] = part["상수"] + [ x for x in bm.consts(lines) if chk.base_sym(x["기호"]) not in have ] def old_cands(c: Ctx, basis: dict, title: str, unit: Any) -> list[dict]: """옛 번호 → 현행 절 로직(`M02_San_Basis_Map`) · 건설품셈(옛) 같은 번호는 같은 절 · 제목과 뜻 겹침만 · 결과단위 성격 거름.""" mine, have, out = clause.nature(unit), {x["키"] for x in basis["후보"]}, [] for book, no in basis["출처"]: if not book.endswith(clause.OLD): continue rows = bm.old_rows(book, no) if not rows and book == "건설품셈" + clause.OLD: rows = [ o for o in clause.lookup("건설품셈", no) if o["맞음"] == "같음" and dice(c, o["키"], title) > 0 ] for o in rows: theirs = clause.nature(o["결과단위"]) if o["키"] not in have and not (mine and theirs and mine != theirs): have.add(o["키"]) out.append({**o, "절": None}) return out def value_ev(rec: dict | None, parts: list[dict]) -> tuple[dict, list[dict], dict]: """값 길 — 블록 {확정, 동점, 약함} · 절마다 · 로직별 옵션.""" if rec is None: return dict(NONE), [dict(NONE) for _ in parts], {} opts: dict = {} for b in rec.get("기초", []) + rec.get("동점", []): opts.setdefault(b["로직"], {}).update(b.get("옵션") or {}) blk = { "확정": [b["로직"] for b in rec.get("기초", [])] if rec["판정"] in ("U", "P") else [], "동점": [b["로직"] for b in rec.get("동점", [])] if rec["판정"] == "A" else [], "약함": [b["로직"] for b in rec.get("약한후보", [])[:1]], } secs, out = rec.get("절") or [], [] for i, p in enumerate(parts): s = None if len(parts) > 1: head = lambda x: _flat(x["절"]).split(".")[-1][:6] # noqa: E731 s = ( secs[i] if len(secs) == len(parts) else next((x for x in secs if head(x) and head(x) in _flat(p["제목"])), None) ) one = {"확정": [], "동점": [], "약함": []} if s and s["판정"] in ("U", "P") and s.get("기초"): one["확정"] = [s["기초"]["로직"]] opts.setdefault(s["기초"]["로직"], {}).update(s["기초"].get("옵션") or {}) elif s and s["판정"] == "A": one["동점"] = list(s.get("동점") or []) out.append(one) return blk, out, opts def is_machine(part: dict) -> bool: syms = {chk.base_sym(c.get("기호") or "") for c in part["상수"]} | { chk.base_sym(f.get("기호") or "") for f in part["식"] } return len(syms - {""}) >= 2 def pick( c: Ctx, N: list, T: list, Vb: dict, Vs: dict, machine: bool, single: bool, title: str ) -> tuple[list, str]: """일반: 번호∩값 › 값 › 번호∩제목 › 값동점∩제목 › 번호 › 제목 / 기계 절: 번호∩제목 › 번호∩값 › 제목∩값 › 번호 › 제목 › 값.""" Tk = [k for k, _ in T] sure = [ k for k in (Vs["확정"] or (Vb["확정"] if single else [])) if not title or dice(c, k, title) >= 0.2 ] anyv = list(dict.fromkeys(Vs["확정"] + Vs["동점"] + Vb["확정"] + Vb["동점"] + Vb["약함"])) def inter(a, b): return [x for x in a if x in set(b)] if machine: steps = [ ("번호∩제목", inter(N, Tk)), ("번호∩값", inter(N, anyv)), ("제목∩값", inter(Tk, anyv)), ("번호", N), ("제목", Tk), ("값", sure), ] else: steps = [ ("번호∩값", inter(N, anyv)), ("값", sure), ("번호∩제목", inter(N, Tk)), ("값동점∩제목", inter(Vs["동점"] + Vb["동점"], Tk)), ("번호", N), ("제목", Tk), ] for name, s in steps: if s: return list(dict.fromkeys(s)), name return [], "없음" def _good(v: dict) -> bool: return "오류" not in v and not v["셈"][solve.DIFF] and bool(v["셈"][solve.SAME]) def resolve(S: list, how: str, check) -> tuple[list, str]: """후보 2~6 → 검산(품 같음 있고 품 다름 없는 후보 하나)으로 거름.""" if 2 <= len(S) <= 6: good = [k for k in S if _good(check(k))] if len(good) == 1: return good, how + "+검산" return S, how def by_machine(c: Ctx, p: dict, S: list, how: str, title: str, check) -> tuple[list, str]: """기계 작업량 절(Q 식) — 번호 길이 아니고 고른 기초가 품 검산으로 안 받쳐지면 그 기계 「작업량당 기계경비」 로직(품 같음 · 또는 고른 것 없고 품 짝 있음).""" if how.startswith("번호") or not bm.has_q(p["글줄"]) or not is_machine(p): return S, how names = " ".join(str(i.get("이름") or "") for i in p["항목"]) mk = bm.machine_key(c.mach, syn(f"{p['제목']} {title} {names}")) if not mk or mk in S or (len(S) == 1 and _good(check(S[0]))): return S, how v = check(mk) cnt = v.get("셈") or {} if _good(v) or ("오류" not in v and (cnt.get(solve.SAME) or cnt.get(solve.DIFF))): return [mk], "기계" return S, how def kind_of(S: list) -> str: return "확정" if len(S) == 1 else "후보 2~3" if 2 <= len(S) <= 3 else "없음" def _found(c: Ctx, key: str, v: dict, mc: list, rec: dict | None, how: str) -> dict: kind, missing, differ = chk.judge_one(v, mc) return { "꼴": kind, "기초": key, "기초이름": c.logic.get(key, {}).get("이름", ""), "차례": how, "옵션": v.get("입력값"), "옵션길": v.get("길"), "입력필요": v.get("입력필요"), "0간주": v.get("0간주"), "글에없음": missing, "값다름": differ, "기계상수": mc, "셈": v.get("셈"), "다름까닭": chk.causes(v, mc, rec) if differ else [], } def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None) -> dict: """블록 하나 합친 길 — {꼴, 블록길, 절 [...], 통째, 기초 [키]} (648-D2 꼴).""" basis = clause.basis(blk) parts, whole, usable = parts_of(blk) title_b = f"{blk.get('이름') or ''} {blk.get('규격') or ''}".strip() basis["후보"] += old_cands(c, basis, title_b, blk.get("단위")) src = {"줄": line, "같은줄": same, "블록": blk} src["제목줄"] = { "글": re.sub(r"[-_/()\[\],~]+", " ", f"{blk.get('이름') or ''} {blk.get('규격') or ''}"), "행": None, } Vb, Vsecs, vopts = value_ev(rec, parts) allN = list(dict.fromkeys(x["키"] for x in basis["후보"])) memo: dict = {} def check(key, items, cons, share): tag = (key, id(items), share, repr(cons)) # 값에 items 를 쥐어 id 를 다시 안 씀 if tag not in memo: memo[tag] = (items, chk.verify(c.m, key, items, cons, src, share, vopts.get(key, {}))) return memo[tag][1] def share_of(p): s = p.get("몫") return (s.get("값") if isinstance(s, dict) else s) or 1 secs = [] for p, Vs in zip(parts, Vsecs, strict=True): if p["길"]: N = [ x["키"] for x in basis["후보"] if x.get("절") in (None, p["길"]) or str(p["길"]).startswith(f"{x.get('절')}.") ] text = f"{p['제목']} {title_b}" else: N, text = allN, title_b N, mach = list(dict.fromkeys(N)), p.get("기계식", is_machine(p)) T = title_cands(c, text, blk.get("단위")) S, how = pick(c, N, T, Vb, Vs, mach, len(parts) == 1, text) cons = {x: p[x] for x in ("상수", "식", "선택값")} S, how = resolve(S, how, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p))) S, how = by_machine( c, p, S, how, title_b, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p)) ) secs.append( { "절": p["제목"], "길": p["길"], "항목수": len(p["항목"]), "기계식": mach, "번호": N, "제목후보": [k for k, _ in T], "차례": how, "후보": S, "_p": p, } ) chosen = {i: s["후보"][0] for i, s in enumerate(secs) if len(s["후보"]) == 1} bases = list(dict.fromkeys(chosen.values())) group = {} for b in bases: # 같은 기초 절끼리 묶어 검산(기초 하나뿐이면 블록 통째) if len(bases) == 1: items, cons, ps = usable, whole, list(parts) else: ps = [secs[i]["_p"] for i, k in chosen.items() if k == b] items = [x for p in ps for x in p["항목"]] cons = {x: [y for p in ps for y in p[x]] for x in ("상수", "식", "선택값")} v = check(b, items, cons, share_of(ps[0]) if len(ps) == 1 else 1) mc = ( chk.mach_check(c.idx, b, chk.mach_consts(cons)) if any(s["기계식"] for s in secs) else [] ) group[b] = (v, mc) for i, s in enumerate(secs): s.pop("_p") if ( i in chosen and s["차례"] == "제목" and not _good({"셈": group[chosen[i]][0].get("셈") or {solve.DIFF: 0, solve.SAME: 0}}) ): s |= {"꼴": "후보 2~3", "제목단독": True} # 제목 하나뿐 · 품 대조로 못 받침 = 사람 확인 elif i in chosen: s |= _found(c, chosen[i], *group[chosen[i]], rec, s["차례"]) else: s["꼴"] = kind_of(s["후보"]) kinds = [s["꼴"] for s in ([s for s in secs if s["항목수"]] or secs)] if all(k == "확정·전부" for k in kinds): bk = "확정·전부" elif all(k.startswith("확정") for k in kinds): bk = "확정·일부" elif any(k.startswith("확정") for k in kinds): bk = "확정·일부(절 미확정)" else: bk = "후보 2~3" if "후보 2~3" in kinds else "없음" out = {"절": secs, "표지": basis["표지"], "출처": basis["출처"], "번호후보": allN} wb = None if len(parts) > 1: # 블록 통째 길 — 블록 제목 · 번호 · 블록 값으로 기초 하나 → 블록 전부로 검산 mach = any(s["기계식"] for s in secs) S, how = pick( c, allN, title_cands(c, title_b, blk.get("단위")), Vb, dict(NONE), mach, True, title_b ) S, how = resolve(S, how, lambda k: check(k, usable, whole, 1)) if len(S) == 1: v = check(S[0], usable, whole, 1) mc = chk.mach_check(c.idx, S[0], chk.mach_consts(whole)) if mach else [] wb = _found(c, S[0], v, mc, rec, how) if how == "제목" and not _good({"셈": v.get("셈") or {solve.DIFF: 0, solve.SAME: 0}}): wb["꼴"] = "후보 2~3" else: wb = {"꼴": kind_of(S), "후보": S, "차례": how} if wb and ORDER.index(wb["꼴"]) < ORDER.index(bk) and (wb.get("셈") or {}).get(solve.SAME): out |= {"꼴": wb["꼴"], "블록길": "통째", "통째": wb, "기초": [wb["기초"]]} else: out |= {"꼴": bk, "블록길": "절 모음" if len(parts) > 1 else "통째(절 하나)", "통째": wb} out["기초"] = list(dict.fromkeys(s["기초"] for s in secs if s.get("기초"))) return out # ── 세 단 · 근거후보 ──────────────────────────────────────────────── def chosen_units(r: dict) -> list[dict]: """블록 판정에 쓰인 단위 — 통째 길이면 통째 하나 · 아니면 기초마다 첫 절.""" if r["블록길"] == "통째" and r.get("통째"): return [r["통째"]] out, seen = [], set() for s in r["절"]: if s.get("기초") and s["기초"] not in seen: seen.add(s["기초"]) out.append(s) return out def checked(s: dict) -> str: """품 검산 — 다름 · 같음 · 짝 없음.""" cnt = s.get("셈") or {} return "다름" if cnt.get(solve.DIFF) else "같음" if cnt.get(solve.SAME) else "짝 없음" def _tier(s: dict) -> str: return "확정" if checked(s) == "같음" and not s["값다름"] else "근접" def cands(c: Ctx, r: dict) -> list[dict]: """근거후보 [{기초, 이름, 원문번호, 옵션, 옵션길, 입력필요, 품검산, 단, 까닭, 글에없음, 값다름}] · 사람 = 후보 2~3(각 후보 · 옵션 없음) · 없음 = [].""" if r["꼴"].startswith("확정"): out = [] for s in chosen_units(r): opts = {k: v for k, v in (s.get("옵션") or {}).items()} row = c.logic.get(s["기초"], {}) out.append( { "기초": s["기초"], "이름": row.get("이름", ""), "원문번호": row.get("원문번호"), "차례": s["차례"], "옵션": opts, "옵션길": s.get("옵션길") or {}, "입력필요": list(s["입력필요"] or []) + list(s["0간주"] or []), "품검산": checked(s), "단": _tier(s), "까닭": [[chk.CAUSE[t], x] for t, x in s["다름까닭"]], "글에없음": s["글에없음"], "값다름": s["값다름"], } ) return out if r["꼴"] == "후보 2~3": keys = list( dict.fromkeys( k for s in r["절"] if s["꼴"] == "후보 2~3" for k in (s.get("후보") or []) ) ) return [ { "기초": k, "이름": c.logic.get(k, {}).get("이름", ""), "원문번호": c.logic.get(k, {}).get("원문번호"), "옵션": {}, "입력필요": [], "품검산": None, "단": "사람", "까닭": [], } for k in keys[:6] ] return [] def lack(blk: dict, r: dict) -> dict: """없음 까닭 — a 기초 없음(번호 확인) · a′ 품목 없음 의심 · f 후보 4+ · 증거 흩어짐 · 794-2 보강 근거. """ if r["표지"] == "기초 없음": why = "a 기초 없음(번호 확인)" elif any(s.get("후보") for s in r["절"]): why = "f 후보 4+ · 증거 흩어짐" else: why = "a′ 품목 없음 의심" name = re.sub(r"\(.*?\)|\[.*?\]|[^가-힣]", "", f"{blk.get('이름') or ''}")[:8] return {"까닭": why, "이름": name, "품셈": [f"{b} {n}" for b, n in r["출처"]]} def suggest( blk: dict, kind: str, line: dict | None = None, same: list | None = None, g: list[str] | None = None, ) -> dict: """산근 블록 하나 → 근거 제안 {단, 꼴, 근거후보, 절, 보강?, 값판정}. `kind` = 책 꼴(STmate · 엑셀꼴) · `line` · `same` = 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) · `g` = STmate 숨은 G 식(없으면 보이는 글).""" c = ctx() pr = bc.prep(blk, kind, g) rec = bv.value(c.idx, pr) r = run_block(c, blk, line, same or [], rec) out = {**r, "값판정": rec["판정"], "근거후보": cands(c, r)} if r["꼴"].startswith("확정"): tiers = {x["단"] for x in out["근거후보"]} outside = [ m[6:] for x in out["근거후보"] for m in x["글에없음"] if m.startswith("기초 밖 줄 ") ] out["까닭"] = [["기초 밖 줄", ", ".join(outside)]] if outside else [] # 엑셀에만 있는 줄 sure = tiers == {"확정"} and r["꼴"] != "확정·일부(절 미확정)" out["단"] = "확정" if sure else "근접" else: out["단"] = {"후보 2~3": "사람"}.get(r["꼴"], "없음") weak = (rec.get("약한후보") or [None])[0] title = f"{blk.get('이름') or ''} {blk.get('규격') or ''}" if ( weak and weak["로직"] not in {x["기초"] for x in out["근거후보"]} and dice(c, weak["로직"], title) >= 0.2 ): row = c.logic.get( weak["로직"], {} ) # 값 길 약한 1 등(유의 4 미만) — 제목과 뜻 겹치면 사람 후보 맨 앞 opts = {k: v for k, v in weak["옵션"].items()} out["근거후보"].insert( 0, { "기초": weak["로직"], "이름": row.get("이름", ""), "원문번호": row.get("원문번호"), "차례": "값약함", "옵션": opts, "옵션길": dict.fromkeys(opts, "V"), "입력필요": [], "품검산": None, "단": "사람", "까닭": [], }, ) out["단"] = "사람" if out["단"] == "없음": out["보강"] = lack(blk, r) return out