diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index cc96feae1..e7b8ae1be 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -5,6 +5,8 @@ SC · SK · 장부 · `M02_Hopyo_Make`) · 경로 층 = 파일 층(판정 · 검산 · `to` = 만들 층) / 만들 층(옮기기). 올림 = Book + 격자 + 엑셀 사전(`Row.lexicon`) + 블록 풀이 뒤 셈(`Judge.ensure` · 파생 저장본) · 옛 맞춤 뒤 셈 · 시동 데움 없음 · [끝내기] = 저장본도 지움. +근거 제안(793-3a) = `hopyo-basis/{키}`(목록) · `hopyo-basis/{키}/{번}`(블록) · `M02_San_Basis_Store` +(올림 때 HP `숨은식` + 뒤 셈 · 파생 저장본 · 판정 길 `hopyo-judge` 칸은 그대로). 그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 · 구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 · 단가산출 만들기)은 785-7 에서 지움. @@ -36,6 +38,7 @@ from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"]) @@ -93,6 +96,28 @@ def hopyo_judge_block( return _call(folder, lambda: hopyo_judge.block(folder, key, no, target)) +@router.get("/layers/{layer}/hopyo-basis/{key}") +def hopyo_basis_list( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """근거 제안(793-3a) — `{셈중, 블록 {번: 제안}, 보강}` · 셈 중 = 뒤 셈 시작(읽기만).""" + folder = _root(session, layer, owner) + return _call(folder, lambda: san_basis.listing(folder, key)) + + +@router.get("/layers/{layer}/hopyo-basis/{key}/{no}") +def hopyo_basis_block( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """근거 제안 블록 하나 — 저장본 › 그 자리 셈(`M02_San_Basis.suggest` 꼴).""" + folder = _root(session, layer, owner) + return _call(folder, lambda: san_basis.block(folder, key, no)) + + class Pick(BaseModel): """고른 것(785-3 꼴) — 금액 · 품 칸 없음(서버가 셈) · 고정 단가 = 엑셀 줄 것.""" @@ -246,6 +271,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): hopyo_judge.ensure(store.root(), same["키"]) + san_basis.ensure(store.root(), same["키"]) return {**same, "이어서": True} try: parsed = hopyo_excel.read_book(name, data) @@ -257,8 +283,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = None sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS) parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정) + parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안) row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림) + san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a) return row @@ -411,6 +439,7 @@ def hopyo_delete( folder = _root(session, layer, owner, write=True) got = _call(folder, hopyo_forget.finish, folder, key) hopyo_judge.forget(folder, key) # 블록 풀이 저장본(785-5) + san_basis.forget(folder, key) # 근거 제안 저장본(793-3a) return got diff --git a/M02_MasterTemplete/M02_San_Basis.py b/M02_MasterTemplete/M02_San_Basis.py new file mode 100644 index 000000000..e7d513f5e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis.py @@ -0,0 +1,528 @@ +"""근거 제안 엔진(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 근거). +자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요). +""" + +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_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()} + + +_CTX: list = [None, None] + + +def ctx() -> Ctx: + idx = bi.get() + if _CTX[0] is not idx: + _CTX[:] = [idx, Ctx(idx)] + return _CTX[1] + + +# ── 제목 → 로직 이름 후보 ─────────────────────────────────────────── +def grams(text: Any) -> set: + t = str(text or "") + for a, b in SYN: + t = re.sub(re.escape(a), b, t, flags=re.I) + return clause._grams(re.sub(r"\d", " ", t)) + + +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) + whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")} + if len(works) < 2: + one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, **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], + } + part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")} + 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 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 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() + 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)), 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))) + 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 diff --git a/M02_MasterTemplete/M02_San_Basis_Check.py b/M02_MasterTemplete/M02_San_Basis_Check.py new file mode 100644 index 000000000..39c5f3d56 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Check.py @@ -0,0 +1,324 @@ +"""근거 제안 4 — 기초 하나 검산 · 옵션 · 까닭(PLAN 793-3a · 분석 648-D2 1 장) · 읽기만 · 지어 넣는 +값 없음. + +옵션 = 기존 `M02_Hopyo_Bind.bind`(B1 부르는 줄 규격 · B2 선택값 + 블록 제목 낱말 · B3 기호표 · B4 +대입식 · +B5 표 거꾸로 · B6 레미콘 규격) → 못 읽은 칸 = 값 길 옵션(「V」) → 그래도 고르기 칸이면 고르기 +값마다 넣어 +엑셀 품과 맞댐(조합 60 까지 · 다 같음 하나 = 「검산」 · ±15 % 근접 하나 = 「근접」) → 남은 칸 = +입력 필요. +감률 · 할증 · 가산 · 보정 · 구멍땜 수 칸은 셈 때만 0(검산 몫) · 옵션으로 안 냄(입력 필요 · 요구 +647-1). +품 대조 = `M02_Hopyo_Solve.pair`(엑셀 표시 자릿수 반올림 폭) · 기계 상수(q · K · f · E · E0 · Cm) = +그 로직(하위 포함)이 닿는 표의 같은 기호 칸 · 같은 기호 줄 값과 대조(`mach_check`). +까닭(`causes`) = b 판 차이 · c 현장 값 · d 감률 · 할증 배수 · e 단위 기준 차이 · f 그 밖(분석 +648-D2 5-2 규칙). +""" + +from __future__ import annotations + +import itertools +import math +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Solve as solve +from M02_MasterTemplete import M02_San_Basis_Index as bi + +RATIOS = { + 8: "일↔시간 ×8", + 1 / 8: "일↔시간 ÷8", + 100: "% ×100", + 0.01: "% ÷100", + 1000: "t↔kg ×1000", + 0.001: "t↔kg ÷1000", + 10: "×10", + 0.1: "÷10", + 60: "분↔시간 ×60", + 1 / 60: "분↔시간 ÷60", +} +CAUSE = { + "b": "판 차이", + "c": "현장 값", + "d": "감률 · 할증 배수", + "e": "단위 기준 차이", + "f": "그 밖", +} +ZERO = re.compile(r"감률|할증|가산|구멍땜|보정|증가|추가") + + +# ── 기계 상수 ↔ 그 로직 표 ────────────────────────────────────────── +def _norm(s: Any) -> str: + s = re.sub(r"\(.*?\)", "", str(s or "").replace("㎝", "cm").lower()) + return re.sub(r"[^a-z0-9]", "", s) + + +def base_sym(s: str) -> str: + m = re.match(r"(cm|e0|q|k|f|e)\d*$", _norm(s)) + return m.group(1) if m else "" + + +def mach_consts(cons: dict) -> list[dict]: + out = list(cons.get("상수", [])) + for f in cons.get("식", []): # 식 끝값(Cm = t1+…+t4 = 10.7)은 셈 결과 — 대입 수만 + out += [{"기호": k, "값": v} for k, v in (f.get("대입") or {}).items()] + return out + + +def mach_check(idx: bi.Index, key: str, consts: list[dict]) -> list[dict]: + out, seen = [], set() + tables = idx.item_tables.get(key, set()) + for c in consts: + sym, v = base_sym(c.get("기호") or ""), c.get("값") + if not sym or not isinstance(v, (int, float)) or (c.get("기호"), v) in seen: + continue + seen.add((c.get("기호"), v)) + pool = [] + for t in tables: + tb = idx.tables[t] + for row in tb.get("줄", []): + row_sym = any(isinstance(x, str) and base_sym(x) == sym for x in row.values()) + for col in tb.get("값칸", {}): + if base_sym(col) == sym or row_sym: + pool += bi.nums_of(row.get(col)) + if not pool: + out.append({"기호": c["기호"], "값": v, "대조": "칸 없음"}) + continue + d = len(str(v).split(".")[1]) if "." in str(v) else 0 + near = min(pool, key=lambda x: abs(x - v)) + hit = abs(near - v) <= max(0.5 * 10 ** (-d), abs(v) * 0.005) + out.append( + { + "기호": c["기호"], + "값": v, + "대조": "표에 있음" if hit else "표에 없음", + "가까운": near, + } + ) + return out + + +# ── 옵션 · 검산 ───────────────────────────────────────────────────── +def zeroable(m: Any, key: str, name: str) -> bool: + """글에 없으면 0(없음)으로 볼 만한 수 칸 — 고르기 없음 · 이름이 감률 · 할증 · 가산 · 보정 류.""" + spec = next((x for x in hb.specs_of(m.logic(key)) if x["이름"] == name), {}) + return not spec.get("고르기") and bool(ZERO.search(name)) + + +def _conv(v: Any) -> Any: + if isinstance(v, list): + ends = [x for x in v if isinstance(x, (int, float))] + return sum(ends) / len(ends) if ends else None + return v + + +def probe( + m: Any, key: str, specs: dict, hard: list, base: dict, items: list, share: Any +) -> dict | None: + """못 읽은 고르기 칸을 고르기 값마다 넣어 엑셀 품과 맞댐 — 「검산」 · 「근접」 옵션.""" + if not hard or not all(specs.get(n, {}).get("고르기") for n in hard): + return None + opts = [specs[n]["고르기"] for n in hard] + if math.prod(len(o) for o in opts) > 60: + return None + exact, close = [], [] + + def sig(rr): + return tuple(round(r["우리"]["품"] or 0, 6) for r in rr if r.get("우리")) + + for combo in itertools.product(*opts): + g2 = dict(zip(hard, combo)) + try: + rr = solve.pair(hb.quantities(m, key, {**base, **g2}), solve._usable(items), share) + except Exception: # noqa: BLE001 — 그 조합은 셈 못 함 + continue + same = sum(r["판정"] == solve.SAME for r in rr) + if any(r["판정"] == solve.NEED for r in rr) or not same: + continue + ratios = [ + r["엑셀"]["품"] / r["우리"]["품"] + for r in rr + if r["판정"] == solve.DIFF and r["엑셀"]["품"] and r["우리"]["품"] + ] + if not ratios: + exact.append((g2, rr)) + elif all(abs(x - 1) <= 0.15 for x in ratios): + close.append((same, -len(ratios), g2, rr)) + if len(exact) == 1: + return {"값": exact[0][0], "줄": exact[0][1], "길": "검산"} + if len(exact) > 1 and len({sig(rr) for _, rr in exact}) == 1: + return {"값": exact[0][0], "줄": exact[0][1], "길": "검산(같은 품 여럿)"} + if not exact and close: + close.sort(key=lambda x: (-x[0], -x[1])) + top = [c for c in close if (c[0], c[1]) == (close[0][0], close[0][1])] + if len(top) == 1 or len({sig(c[3]) for c in top}) == 1: + return { + "값": close[0][2], + "줄": close[0][3], + "길": "근접" + ("" if len(top) == 1 else "(같은 품 여럿)"), + } + return None + + +def verify(m: Any, key: str, items: list, consts: dict, src: dict, share: Any, vopts: dict) -> dict: + """기초 하나 검산 — bind(B1~B6) · 값 길 옵션 · 고르기 맞댐 · 엑셀 품 대조.""" + mine = {**src, **consts} + if src.get("제목줄"): + mine["선택값"] = list(consts.get("선택값") or []) + [src["제목줄"]] + try: + b = hb.bind(m, key, mine, solve.fitter(m, key, items, share)) + specs = {s["이름"]: s for s in hb.specs_of(m.logic(key))} + given = {**b["입력값"], **{n: v[0]["값"] for n, v in b["엇갈림"].items()}} + fromv = { + n: _conv(vopts[n]) + for n in b["입력필요"] + b["안씀"] + if n in vopts and _conv(vopts[n]) is not None + } + need = [n for n in b["입력필요"] if n not in fromv] + zero = {n: 0 for n in need if zeroable(m, key, n)} + idle = { + n: b["기본"][n] for n in b["안씀"] if b["기본"].get(n) is not None and n not in fromv + } + base = {**idle, **given, **fromv, **zero} + rows = solve.pair(hb.quantities(m, key, base), solve._usable(items), share) + got = probe(m, key, specs, [n for n in need if n not in zero], base, items, share) + if got: + fromv |= got["값"] + need = [n for n in need if n not in got["값"]] + rows = got["줄"] + same_q = [] # 엇갈림인데 어느 값이든 품이 같음 = 문제 아님 + for n, alts in b["엇갈림"].items(): + sigs = set() + for alt in alts: + q = hb.quantities(m, key, {**idle, **given, **fromv, **zero, n: alt["값"]}) + sigs.add( + tuple(round(float(x["품"]), 6) if x.get("품") is not None else None for x in q) + ) + if len(sigs) == 1: + same_q.append(n) + except Exception as e: # noqa: BLE001 — 검산 못 함 = 오류 표지(확정 안 함) + return {"키": key, "오류": repr(e)[:160]} + blk = src["블록"] + text = " ".join( + [str(blk.get("이름") or ""), str(blk.get("규격") or "")] + + [str(t) for t in blk.get("글") or []] + ) + words = {"소량": "소량|소형|소규모", "높이": "높이|수직고"} + absorbed = [ + [n, hb.plain(given[n])] + for n, w in b["출처"].items() + if w["길"] == "B5" + and n.endswith("감률") + and n in given + and not re.search(words.get(n.replace("감률", ""), n.replace("감률", "") or "소량"), text) + ] + cnt = { + v: sum(r["판정"] == v for r in rows) + for v in (solve.SAME, solve.DIFF, solve.NEED, solve.EXCEL, solve.OURS) + } + diff = [] + for r in rows: + if r["판정"] == solve.DIFF: + ex, us = r["엑셀"]["품"], r["우리"]["품"] + diff.append( + { + "줄": r["엑셀"].get("이름") or r["우리"].get("이름"), + "엑셀": ex, + "우리": us, + "배": round(ex / us, 4) if ex and us else None, + "묶음": r["엑셀"].get("묶음"), + } + ) + outside = [ + r["엑셀"].get("이름") + for r in rows + if r["판정"] == solve.EXCEL and r["엑셀"] and r["엑셀"].get("품") + ] + way = {n: alts[0]["길"] for n, alts in b["엇갈림"].items()} # 엇갈림 = 첫 값 + way |= {k: v["길"] for k, v in b["출처"].items()} + way |= {k: "V" for k in fromv if not got or k not in got["값"]} + way |= {k: got["길"] for k in got["값"]} if got else {} + return { + "키": key, + "셈": cnt, + "기초밖": outside, + "감률흡수": absorbed, + "입력값": { + **{k: hb.plain(v) for k, v in given.items()}, + **{k: hb.plain(v) for k, v in fromv.items()}, + }, + "길": way, + "입력필요": [n for n in need if n not in zero], + "0간주": list(zero), + "엇갈림": { + k: [hb.plain(x["값"]) for x in v] for k, v in b["엇갈림"].items() if k not in same_q + }, + "품다름": diff, + } + + +def judge_one(v: dict, mach: list[dict]) -> tuple[str, list, list]: + """(확정·전부 | 확정·일부, 글에 없음, 값이 M01 과 다름).""" + if "오류" in v: + return "확정·일부", [], [f"검산 오류 {v['오류'][:60]}"] + missing = list(v["입력필요"]) + [f"옵션 여럿 {k} {vals}" for k, vals in v["엇갈림"].items()] + missing += [f"기초 밖 줄 {x}" for x in v.get("기초밖", [])] + differ = [ + f"품 {d['줄']} 엑셀 {d['엑셀']:.4g} ≠ 우리 {d['우리']:.4g}" + for d in v["품다름"] + if d["엑셀"] and d["우리"] + ] + differ += [ + f"기계 {x['기호']}={x['값']} 표에 없음(가까운 {x['가까운']:g})" + for x in mach + if x["대조"] == "표에 없음" + ] + differ += [f"감률 {n}={x} 로 흡수(글에 그 말 없음) — 품 배수" for n, x in v.get("감률흡수", [])] + differ += [ + f"근접 옵션 {k}={v['입력값'].get(k)}" + for k, w in (v.get("길") or {}).items() + if str(w).startswith("근접") + ] + if not v["셈"].get(solve.SAME) and not v["셈"].get(solve.DIFF): + missing.append("(품 짝 없음)") + return ("확정·전부" if not missing and not differ else "확정·일부"), missing, differ + + +def causes(v: dict, mach: list[dict], rec: dict | None) -> list[list[str]]: + """값 다름 → [갈래 b~f, 글] 【추정 규칙 · 648-D2 5-2】.""" + out = [] + user = bool(rec) and ( + bool(rec.get("입력")) + or any( + "kg/" in str(k.get("단위", "")).lower() or "㎏/" in str(k.get("단위", "")) + for k in rec.get("K", []) + ) + ) + labor = [d["배"] for d in v.get("품다름", []) if d["배"] and d["묶음"] in ("L", "X")] + uniform = len(labor) >= 2 and all(abs(x / labor[0] - 1) <= 0.02 for x in labor) + for d in v.get("품다름", []): + r = d["배"] + if not r: + out.append(["f", f"{d['줄']} 품 0"]) + continue + norm = next((name for k, name in RATIOS.items() if abs(r / k - 1) <= 0.01), None) + if norm: + out.append(["e", f"{d['줄']} {norm}"]) + elif uniform and abs(r - 1) > 0.15: + out.append(["d", f"{d['줄']} 같은 배 ×{r:g}"]) + elif abs(r - 1) <= 0.15: + out.append(["c" if user else "b", f"{d['줄']} ×{r:g}"]) + else: + out.append(["c" if user else "f", f"{d['줄']} ×{r:g}"]) + for n, x in v.get("감률흡수", []): + out.append(["b" if isinstance(x, (int, float)) and x < 15 else "d", f"감률 {n}={x} 흡수"]) + for x in mach: + if x["대조"] == "표에 없음": + out.append( + [ + "b" if x["가까운"] and abs(x["값"] / x["가까운"] - 1) <= 0.15 else "c", + f"기계 {x['기호']}={x['값']}", + ] + ) + return out diff --git a/M02_MasterTemplete/M02_San_Basis_Const.py b/M02_MasterTemplete/M02_San_Basis_Const.py new file mode 100644 index 000000000..daf790620 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Const.py @@ -0,0 +1,378 @@ +"""근거 제안 2 — 산근 블록 글 → 상수 지문(PLAN 793-3a · 분석 648-D 1-2 뽑기) · 읽기만. + +뽑기 = 엑셀꼴(EST · STmate 보이는 글): 콜론 앞 이름 · 단가 앞 규격 · 쉼표 금액 · 항목 단가 · 「=」 +뒤 결과 +(앞 마디가 셈일 때) · 소계 줄 · 절 제목 줄 · 품셈 근거 줄 · 품셈 번호 · 연도 뺌 · 정수 끝 점 지움 / +STmate 숨은 G 식: 따옴표 글 뺌 · 수 바로 뒤 짧은 따옴표 = 단위 · `&…&` 참조 뺌. +N9 일반 수(1 · 100 · 1000 · 60 분 · 8 시간 = 「일반」 표지) · N10 입력(L · 거리 · km) 뺌 · +인력 줄 몫(「a 인 / b」 · 「a / Q」 = 블록 안 대입)에 직종 표지. +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import lru_cache +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Formula as formula +from M02_MasterTemplete import M02_Hopyo_ReadEst as est +from M02_MasterTemplete import M02_San_Basis_Index as bi + +fam = bi.fam +UNIT = ( + r"(㎡|㎥|㎏|kg|Kg|KG|ton|Ton|TON|톤|m3|M3|m2|M2|㎝|cm|㎜|mm|km|Km|KM|ℓ|hr|Hr|HR" + r"|시간|분|초|sec|min|일|인|회|본|개|매|조|대|%|EA|ea|t(?![a-zA-Z])|m(?![a-zA-Z²³])|M(?![a-zA-Z]))" +) +NUMTOK = re.compile( + r"(? str: + s = unicodedata.normalize("NFKC", str(s or "")) + return s.replace("×", "*").replace("÷", "/").replace("=", "=").replace(":", ":") + + +def _f(s: Any) -> float | None: + try: + return float(str(s).replace(",", "").rstrip(".")) + except ValueError: + return None + + +def g_text(g: str) -> tuple[str, str]: + """STmate G 식 → (절 나누기 글, 수 뽑기 글) — 따옴표 글은 이름 · 수 뒤 짧은 따옴표 = 단위.""" + parts = g.split("'") + out: list[str] = [] + for i, p in enumerate(parts): + if i % 2 == 0: + out.append(p) + elif re.search(r"\d\s*$", parts[i - 1]) and len(p) <= 8 and not re.search(r"\d", p): + out[-1] = parts[i - 1].rstrip() + out.append(f"⟨{p}⟩") + else: + out.append(" ") + s = re.sub(r"&[^&]*&", " ¤ ", "".join(out)) # 단가 · 금액 범위 참조 + s = re.sub(r":=\s*A\d+|:\s*T\d+|->|\(-?=+\)|\[[A-Za-z]\w*\]", " ", s) + return g.replace("'", ""), s + + +def result_spans(s: str) -> list[tuple[int, int]]: + """「=」 마디 중 바로 앞 마디가 셈인 마디의 첫 수 = 결과 — 여러 대입 줄(「q = 0.7 K = + 0.9」)은 결과 아님. + """ + out, at = [], 0 + segs = s.split("=") + for i, seg in enumerate(segs): + last = i == len(segs) - 1 + if ( + i + and OPER.search(segs[i - 1].split(",")[-1]) + and (last or not OPER.search(seg.split(",")[0])) + ): + m = re.search(r"\d[\d,]*(?:\.\d+)?", seg) + if m and not seg[: m.start()].strip(" ({"): + out.append((at + m.start(), at + m.end())) + at += len(seg) + 1 + return out + + +def extract(text: str, prices: set, results: set, g: bool) -> tuple[list[dict], list[dict], str]: + """줄 하나 → (상수, 입력, 수 글) — 결과 · 단가 · 금액 · 이름 · 번호 뺌.""" + s = nk(text) + if not g and "=" not in s and (formula._title(s.strip()) or re.search(r"품\s*셈", s)): + return [], [], "" # 절 제목 · 품셈 근거 줄 — 규격 · 번호뿐 + for r in DROP: + s = r.sub(" ", s) + s = HEADNO.sub(" ", s) + s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 엑셀꼴 정수 끝 점(「5. 인 / 45. ㎡」) + if not g: + if ":" in s and "=" not in s.split(":")[0]: + s = s.split(":", 1)[1] + else: # 콜론 없는 재료 · 인력 줄 — 단가 앞(이름 · 규격) 자름 + for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s): + x = _f(m.group()) + if ( + x is not None + and ("," in m.group() or x in prices) + and "=" not in s[: m.start()] + ): + s = s[m.start() :] + break + skip = [] if g else result_spans(s) + consts, inputs = [], [] + for m in NUMTOK.finditer(s): + if any(a <= m.start(1) < z for a, z in skip): + continue # 셈 결과 + raw = m.group(1) + if "," in raw.strip(","): + continue # 금액 · 단가 + raw = raw.rstrip(".,") + x = _f(raw) + if x is None or x == 0: + continue + unit = m.group(2) if m.group(2) is not None else (m.group(3) or "") + if not unit and re.match(r"[가-힣]", s[m.end() : m.end() + 1]): + continue # 「2차선」 · 「3층」 같은 낱말 속 수 + sym_hit = SYM.search(s[: m.start()]) + sym = sym_hit.group(1) if sym_hit else "" + prev = s[: m.start()].rstrip()[-1:] + one = { + "v": x, + "d": len(raw.split(".")[1]) if "." in raw else 0, + "단위": unit, + "기호": sym, + "글": text.strip()[:90], + } + if x in prices or (not g and x in results): + continue + speed = re.match(r"\s*/\s*h", s[m.end() : m.end() + 4], re.I) + if ( + (sym and INPUT_SYM.match(sym)) + or fam(unit) == "길이" + and unit.lower() in ("km",) + and not speed + ): + inputs.append(one) + continue + if ( + (x in (1, 100, 1000) and unit != "%") + or (x == 60 and fam(unit) in (None, "시간")) + or ( + x == 8 + and (prev == "/" or fam(unit) == "시간" or re.search(r"hr|시간", s, re.I)) + and fam(unit) in (None, "시간") + ) + ): + one["일반"] = True + consts.append(one) + return consts, inputs, s + + +def quotients(body: str, labor: bool, syms: dict) -> list[dict]: + """인력 줄 「3 / 4.5」 · 「5인 / 45」 · 「2인 * 1일 / 50」 · 「5인 / Q」 → 몫 상수(인/양).""" + out = [] + for m in QUO.finditer(body): + a, bt = _f(m.group(1)), m.group(2) + b = syms.get(bt) if re.match(r"[A-Za-z]", bt) else _f(bt) + unit_p = bool(re.search(r"⟨인⟩|인|명", body[m.start() : m.start(1) + len(m.group(1)) + 4])) + if not a or not b or not (labor or unit_p) or b in (8, 60, 100): + continue + out.append( + { + "v": a / b, + "d": 9, + "단위": "인", + "기호": "", + "몫": f"{a:g}/{b:g}", + "글": body.strip()[:90], + } + ) + return out + + +def trade_of(text: str) -> str | None: + """인력 줄 이름 → 우리 인력 이름(모르면 None).""" + m = est._COLON.match(nk(text)) + if not m: + return None + name = re.sub(r"^[0-9가-힣]\.|^[○◇□※·\-*ㅇ\s]+", "", re.sub(r"\s+", "", m.group("name"))) + return bi.trade(name) + + +# ── 인력 줄 가름(엑셀꼴) ───────────────────────────────────────────── +_SWAP = [ + ("m3", "㎥"), + ("M3", "㎥"), + ("m2", "㎡"), + ("M2", "㎡"), + ("m", "m"), + ("Ton", "톤"), + ("TON", "톤"), + ("ton", "톤"), + ("굴착기", "굴삭기"), + ("백호", "굴삭기"), + ("B/H", "굴삭기"), + ("BACK-HOE", "굴삭기"), + ("브레카", "브레이커"), + ("브레이카", "브레이커"), + ("뒤채움", "뒷채움"), + ("Ⅰ", "I"), + ("Ⅱ", "II"), + ("ll", "II"), + ("=", "="), + (":", ":"), +] +_TOTAL = re.compile(r"^(소계|계|합계|총계|인부소계|자재소계|주자재소계|인건비소계)[:]?#?$") + + +def _fk(s: Any) -> str: + s = unicodedata.normalize("NFKC", str(s or "")) + for a, b in _SWAP: + s = s.replace(a, b) + return s + + +def _skel(line: str) -> str: + s = re.sub(r"(?<=[\d\s)])[xX×](?=[\s\d(])", "*", _fk(line)).replace("×", "*").replace("÷", "/") + s = re.sub(r"(?<=\d)\.(?=[*/=%+\-)\s]|$)", "", re.sub(r"\s+", "", s)) + return re.sub(r"\d[\d,]*(?:\.\d+)?", "#", s) + + +@lru_cache(maxsize=1) +def _names() -> dict: + return est.trade_names() + + +def labor(line: str) -> bool: + """인력 줄(「보통인부 : …」) — 소계 · 품셈 근거 줄 뺌.""" + s = _fk(line).strip() + f, sk = re.sub(r"\s+", "", s), _skel(s) + if ( + not f + or _TOTAL.match(sk) + or re.match(r"^(\d+\.)?(합계|소계|계)[:]?", f) + and "*" not in sk + and "/" not in sk + ): + return False + if re.search(r"품[셈줌]", s) and "=" not in s: + return False + cm = est._COLON.match(s) + nm = ( + re.sub( + r"^[○◇□※·\-*]+", "", re.sub(r"^[0-9가-힣]\.", "", re.sub(r"\s+", "", cm.group("name"))) + ) + if cm + else "" + ) + return bool(cm and (_names().get(nm) or bi.trade(nm))) + + +# ── 블록 → 줄 · 절 · 상수 ──────────────────────────────────────────── +def results_of(lines: list[str], items: list[dict]) -> tuple[set, set]: + prices, results = set(), set() + for it in items or []: + prices |= { + float(v) for v in (it.get("단가") or {}).values() if isinstance(v, (int, float)) and v + } + if isinstance(it.get("금액"), (int, float)): + results.add(float(it["금액"])) + if isinstance(it.get("기준값"), (int, float)): + results.add(round(float(it["기준값"]), 1)) + for t in lines: + s = nk(t) + results |= {_f(s[a:z]) for a, z in result_spans(s)} + if re.search(r"(소\s*계|합\s*계|총\s*계|^\s*계\s*:)", s): + results |= {_f(m.group()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s)} + results.discard(None) + return prices, results + + +def block_lines(blk: dict, kind: str, g: list[str] | None) -> list[dict]: + """[{글(절 나누기), 수글, g}] — STmate + 숨은 G 식이 있으면 G · 그 밖 보이는 글.""" + out = [] + if kind == "STmate" and g: + for gl in g: + tree, s = g_text(gl) + if tree.strip() and not re.fullmatch(r"\s*\(-?=+\)\s*", tree): + out.append({"글": tree, "수글": s, "g": True}) + else: + out = [{"글": t, "수글": t, "g": False} for t in blk.get("글") or [] if t and t.strip()] + return out + + +def sections(lines: list[dict]) -> list[tuple[str, list[int]]]: + texts = [ln["글"] for ln in lines] + works = formula.works(formula.tree(texts, list(range(len(texts))))) + if not works: + return [("(블록 통째)", list(range(len(texts))))] + return [ + (f"{w['번']}. {w['제목']}"[:40], list(range(w["글"][0], w["글"][1] + 1))) for w in works + ] + + +def prep(blk: dict, kind: str, g: list[str] | None = None) -> dict: + """블록 → 줄 · 줄별 (상수, 입력) · 절.""" + lines = block_lines(blk, kind, g) + prices, results = results_of(blk.get("글") or [], blk.get("항목") or []) + per_line, bodies = [], [] + for ln in lines: + cs, ins, body = extract(ln["수글"], prices, results, ln["g"]) + per_line.append([cs, ins]) + bodies.append(body) + syms: dict = {} + for cs, _ in per_line: + for c in cs: + if c["기호"] and c["기호"] not in syms: + syms[c["기호"]] = c["v"] + for i, ln in enumerate(lines): + lab = re.search(r"&L\d", ln["글"]) if ln["g"] else labor(ln["글"]) + tr = trade_of(ln["글"]) if lab else None + qs = quotients(bodies[i], bool(lab), syms) + if tr: + tops = {q["몫"].split("/")[0] for q in qs} + for c in per_line[i][0]: + if fam(c["단위"]) == "사람" or f"{c['v']:g}" in tops: + c["직종"] = tr + for q in qs: + q["직종"] = tr + per_line[i][0] += qs + in_vals = {round(c["v"], 9) for _, ins in per_line for c in ins if c.get("기호")} + for cs, _ in per_line: + cs[:] = [c for c in cs if round(c["v"], 9) not in in_vals or c.get("몫")] + return {"lines": lines, "per_line": per_line, "secs": sections(lines) if lines else []} + + +def gather(per_line: list, ids: Any) -> tuple[list[dict], list[dict]]: + """줄 묶음 → (상수 K, 입력) — 같은 수 · 율과 소수 쌍(2 % · 0.02)은 하나(단위 있는 쪽).""" + K: list[dict] = [] + seen, ins, at = set(), [], {} + for i in ids: + cs, ii = per_line[i] + ins += ii + for c in cs: + vk = (round(c["v"], 9), c.get("직종"), bool(c.get("몫"))) + if vk in at and fam(c["단위"]) != "율" and fam(K[at[vk]]["단위"]) != "율": + if not K[at[vk]]["단위"] and c["단위"]: + K[at[vk]] = {**c, "줄": i} + continue + key = (round(c["v"], 9), fam(c["단위"]), c.get("직종")) + twin = ( + (round(c["v"] * 100, 9), "율", None) + if fam(c["단위"]) is None and c["v"] < 1 + else ((round(c["v"] / 100, 9), None, None) if fam(c["단위"]) == "율" else None) + ) + if key in seen or twin in seen: + continue + seen.add(key) + at.setdefault(vk, len(K)) + K.append({**c, "줄": i}) + return K, ins + + +def corpus(preps: list[dict]) -> list[list]: + """보정 자료 — 블록들 상수(일반 뺌 · 같은 꼴 하나) [[v, d, 단위, 직종, 몫]].""" + keys: dict = {} + for pr in preps: + for c in gather(pr["per_line"], range(len(pr["lines"])))[0]: + if not c.get("일반"): + keys[bi.kkey(c)] = None + return [list(k) for k in keys] diff --git a/M02_MasterTemplete/M02_San_Basis_Index.py b/M02_MasterTemplete/M02_San_Basis_Index.py new file mode 100644 index 000000000..65d3eeeb7 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Index.py @@ -0,0 +1,611 @@ +"""근거 제안 1 — M01 값 역색인 · 숫자 정규화 한 벌(PLAN 793-3a · 요구 648 · 분석 648-D 1 장) · +읽기만. + +칸(`cells`) = 표 칸 하나 {v, 표, 줄, 칸, 단위, 종류 val(값칸) | cond(조건 수) | ratio(「a 인 / b」 +몫) | +기준(「10㎡」 당) | eq(기계 요소 값)} · 쓰임(`uses`) = 표 → [(로직, 부름, 칸 | None, 글자 거름, +입력 묶음)] +(하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` · +직종 표지). +정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 · +N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 = +`M02_San_Basis_Const`). +우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 · +포아송 이항 꼬리 −log10. +짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시). +""" + +from __future__ import annotations + +import ast +import bisect +import glob +import json +import math +import re +import threading +import unicodedata +from collections import defaultdict +from functools import lru_cache +from pathlib import Path +from typing import Any + +from common_util import common_util_derived_store as derived +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_ReadEst as est + +mf, ma = hb.mf, hb.ma +CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json") +INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈 +PERSON = ("인",) + +# ── 단위 성격(N8) ───────────────────────────────────────────────────── +FAM = [ + x.split(":") + for x in ( + "인:사람 ㎡:넓이 m2:넓이 ㎥:부피 m3:부피 ℓ:부피 km:길이 ㎝:길이 cm:길이 ㎜:길이 mm:길이 " + "m:길이 ㎏:무게 kg:무게 ton:무게 톤:무게 t:무게 g:무게 %:율 hr:시간 h:시간 시간:시간 " + "분:시간 min:시간 초:시간 sec:시간 일:시간 회:수 본:수 개:수 매:수 조:수 대:수 ea:수" + ).split() +] + + +def fam(unit: Any) -> str | None: + u = unicodedata.normalize("NFKC", str(unit or "")).strip().lower().split("/")[0].strip() + if "%" in u: + return "율" + for a, f in FAM: + if u == a or u.startswith(a) and a not in ("m", "t", "h", "g"): + return f + return None + + +def unit_ok(tu: Any, cu: Any) -> bool: + a, b = fam(tu), fam(cu) + return a is None or b is None or a == b + + +_OPS = { + ast.Add: float.__add__, + ast.Sub: float.__sub__, + ast.Mult: float.__mul__, + ast.Div: float.__truediv__, +} + + +def _calc(node: ast.AST) -> float: + """수 · 사칙 · 부호만(그 밖 = ValueError) — eval 없음.""" + if isinstance(node, ast.Expression): + return _calc(node.body) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return float(node.value) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + x = _calc(node.operand) + return -x if isinstance(node.op, ast.USub) else x + if isinstance(node, ast.BinOp) and type(node.op) in _OPS: + return _OPS[type(node.op)](_calc(node.left), _calc(node.right)) + raise ValueError(node) + + +def _arith(s: str) -> float | None: + """「480 - 30」 · 「1/1.30」 → 수 · 못 셈 None.""" + if not re.fullmatch(r"[\d.\s+\-*/()]+", s) or not re.search(r"\d", s): + return None + try: + return _calc(ast.parse(s.strip(), mode="eval")) + except (SyntaxError, ValueError, ZeroDivisionError): + return None + + +def nums_of(v: Any) -> list[float]: + """칸 값 → [수] — 수 · [아래, 위] · 식 글자(셈 값 + 낱 수).""" + if isinstance(v, bool) or v is None: + return [] + if isinstance(v, (int, float)): + return [float(v)] + if isinstance(v, list): + return [float(x) for x in v if isinstance(x, (int, float)) and not isinstance(x, bool)] + if isinstance(v, str): + got = _arith(v) + if got is None: + return [] + parts = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", v)] + return [got] + [p for p in parts if p != got] + return [] + + +# ── 직종(N5) ───────────────────────────────────────────────────────── +@lru_cache(maxsize=None) +def trade(name: str) -> str | None: + return est.trade(name) + + +def _flat(t: Any) -> str: + return re.sub(r"[\s()()_\-·.]", "", re.sub(r"\(.*?\)|_.*$", "", str(t or ""))) + + +def canon(t: str) -> str: + return trade(t) or trade(_flat(t)) or _flat(t) + + +def same_trade(a: str, b: str) -> bool: + x, y = canon(a), canon(b) + return bool(x and y) and (x == y or len(y) >= 2 and len(x) >= 2 and (x in y or y in x)) + + +# ── M01 읽기 · 로직 훑기 ───────────────────────────────────────────── +def folder() -> Path: + return Path(hb.m01.FOLDER) + + +def stamp() -> str: + """M01 지문 — 첫 층 파일 내용(`M02_Hopyo_Judge._stamp` 와 같은 몫) + 보정 자료.""" + files = { + p.name: derived.file_digest(p) + for p in sorted(folder().glob("*.json")) + if not p.name.startswith("_") + } + return derived.derived_key("san-basis-index", files, derived.file_digest(CORPUS)) + + +def load(root: Path) -> tuple[dict, dict, dict, dict]: + tables, logic, eq = {}, {}, {} + for f in glob.glob(str(root / "소요량_*.json")) + glob.glob(str(root / "계수_*.json")): + for t in json.loads(Path(f).read_text(encoding="utf-8")).get("표", []): + tables[t["키"]] = t + for f in glob.glob(str(root / "로직_*.json")): + for it in json.loads(Path(f).read_text(encoding="utf-8"))["줄"]: + logic[it["키"]] = it + for r in json.loads((root / "기계.json").read_text(encoding="utf-8"))["줄"]: + eq[r["키"]] = r + by_no: dict = {} + for k, t in tables.items(): + by_no.setdefault((k[:2], str(t.get("원문번호"))), []).append(k) + return tables, logic, eq, by_no + + +def _resolve(ref: str, tables: dict, by_no: dict) -> str | None: + if "{" in ref: + return None + if ref in tables: + return ref + tid, _, no = ref.partition(":") + hit = by_no.get((tid, no)) or [] + return hit[0] if len(hit) == 1 else None + + +def scan(item: dict, tables: dict, by_no: dict) -> tuple: + """로직 하나 → (찾기 부름, 식 속 수, 하위 로직 부름, 기계 부름, 호표 수량 속 수).""" + names = {s["이름"] for s in item.get("입력", [])} + finds, nums, calls, eqs = [], [], [], [] + + def arg_of(node): + if node[0] == "str": + return ("lit", node[1]) + if node[0] == "num": + return ("lit", float(node[1])) + if node[0] == "name": + return ("name", node[1]) if node[1] in names else ("expr", node[1]) + return ("expr", str(node)[:80]) + + def visit(node, field=None): + kind = node[0] + if kind == "field": + visit(node[1], node[2]) + elif kind in ("find", "logic"): + if kind == "find" and (ref := _resolve(node[1], tables, by_no)): + finds.append((ref, field, {c: arg_of(a) for c, a in node[2]})) + if kind == "logic" and isinstance(node[1], str) and "{" not in node[1]: + calls.append((node[1], {c: arg_of(a) for c, a in node[2]})) + for _, a in node[2]: + if a[0] not in ("str", "num", "name"): + visit(a) + elif kind == "ref": + if node[1].startswith("EQ") and field: + eqs.append((node[1], field)) + elif kind == "num": + nums.append(float(node[1])) + elif kind == "bin": + for side in (node[2], node[3]): # 비교 글자(「SCH == 20」)는 셈 수가 아님 + if node[1] in mf._OPS and side[0] == "num": + continue + visit(side) + elif kind == "neg": + visit(node[1]) + elif kind == "call": + for a in node[2]: + visit(a) + + for text in ma._texts(item): + if any(text is h["수량"] for h in item.get("호표", [])): + continue + try: + visit(mf.parse(text)) + except mf.FormulaError: + pass + trade_nums = [] # (호표 이름, 수량 식 속 수) + for h in item.get("호표", []): + before = len(nums) + try: + visit(mf.parse(h["수량"])) + except mf.FormulaError: + continue + trade_nums += [(h.get("이름") or "", x) for x in nums[before:]] + return finds, nums, calls, eqs, trade_nums + + +def _cells(tables: dict, eq: dict) -> list[dict]: + cells: list[dict] = [] + for tk, t in tables.items(): + vcols = t.get("값칸", {}) or {} + base = re.match(r"\s*(\d+(?:\.\d+)?)\s*(\S*)", str(t.get("기준") or "")) + if base and float(base.group(1)) not in (0, 1): # 「10㎡」 · 「100m」 당 — 표 기준 수(N6) + cells.append( + { + "v": float(base.group(1)), + "표": tk, + "줄": 0, + "칸": "기준", + "단위": base.group(2), + "종류": "기준", + } + ) + for ri, row in enumerate(t.get("줄", [])): + got = {} + for col, unit in vcols.items(): + for x in nums_of(row.get(col)): + cells.append( + { + "v": x, + "표": tk, + "줄": ri, + "칸": col, + "단위": row.get("단위") or unit, + "종류": "val", + } + ) + got.setdefault(col, x) + for col, kind in (t.get("조건", {}) or {}).items(): + x = row.get(col) + if kind == "수" and isinstance(x, (int, float)) and not isinstance(x, bool): + cells.append( + {"v": float(x), "표": tk, "줄": ri, "칸": col, "단위": "", "종류": "cond"} + ) + for a, ua in vcols.items(): # 인/양 몫(N4 · 「5인 / 45㎡」 = 0.111) + if not str(row.get("단위") or ua).startswith(PERSON) or a not in got: + continue + for b, ub in vcols.items(): + ub = str(row.get("단위") or ub) + if b == a or b not in got or ub.startswith(PERSON) or "%" in ub or not got[b]: + continue + cells.append( + { + "v": got[a] / got[b], + "표": tk, + "줄": ri, + "칸": f"{a}/{b}", + "단위": "인", + "종류": "ratio", + } + ) + for ek, r in eq.items(): # 기계 요소 수(가격 · 글 뺌) + for col, x in (r.get("값") or {}).items(): + if col in ("가격", "주연료종류", "조종원직종") or col.endswith("가격"): + continue + unit = (r.get("단위") or {}).get(col, "") if isinstance(r.get("단위"), dict) else "" + cells += [ + {"v": y, "표": ek, "줄": 0, "칸": col, "단위": unit or "", "종류": "eq"} + for y in nums_of(x) + ] + return cells + + +# ── 역색인 ─────────────────────────────────────────────────────────── +class Index: + def __init__(self, root: Path | None = None): + self.tables, self.logic, self.eq, by_no = load(root or folder()) + self.cells = _cells(self.tables, self.eq) + self.scans = {k: scan(it, self.tables, by_no) for k, it in self.logic.items()} + order = sorted(range(len(self.cells)), key=lambda i: self.cells[i]["v"]) + self.vals = [self.cells[i]["v"] for i in order] + self.ids = order + self.uses = defaultdict(list) # 표 → [(로직, 부름id, 칸 | None, 거름, 묶음)] + self.eq_uses = defaultdict(list) # 기계 칸 → [(로직, 부름id, (고정 | 입력, 값))] + self.inline = defaultdict(set) + self.inline_trade = defaultdict(set) + self.eq_no = {str(r.get("원문번호")): k for k, r in self.eq.items()} | { + k: k for k in self.eq + } + self.callers = defaultdict(set) # 하위 → 부르는 위 로직(어느 깊이든) + for k in self.logic: + self._expand(k, k, {}, {}, 0, k) + self.inl_index = defaultdict(set) + for k, xs in self.inline.items(): + for x in xs: + self.inl_index[round(x, 9)].add(k) + self.trade_index = defaultdict(set) + for k, xs in self.inline_trade.items(): + for _, x in xs: + self.trade_index[round(x, 9)].add(k) + self.cells_of_table = defaultdict(list) + for c in self.cells: + self.cells_of_table[c["표"]].append(c) + self.item_tables = defaultdict(set) + for t, us in self.uses.items(): + for u in us: + self.item_tables[u[0]].add(t) + self._hits: dict = {} + self._look: dict = {} + self._df: dict = {} + self.prior() + + def _expand(self, top, key, rename, lit, depth, path): + finds, nums, calls, eqs, tnums = self.scans[key] + for name, x in tnums: + self.inline_trade[top].add((name, x)) + for ref, field, args in finds: + filt, bind = {}, {} + for col, (kind, val) in args.items(): + if kind == "lit": + filt[col] = val + elif kind == "name" and depth == 0: + bind[col] = val + elif kind == "name" and val in rename: + bind[col] = rename[val] + elif kind == "name" and val in lit: + filt[col] = lit[val] + else: # 중간 · 식 · 안 넘긴 입력 — 같은 인자를 쓰는 부름끼리 같은 줄(가짜 입력 §) + bind[col] = f"§{path}:{val}" + if not filt and not bind: # 조건 없는 부름 — 같은 표 부름끼리 같은 줄 + bind["#row"] = f"§{path}:{ref}" + gid = f"{path}|{ref}|{sorted(filt.items(), key=str)}|{sorted(bind.items())}" + self.uses[ref].append((top, gid, field, filt, bind)) + self.inline[top].update(nums) + for ref, field in eqs: + m = re.match(r"EQ:\{(\w+)\}", ref) + if m: + arg = m.group(1) + inputs = {sp["이름"] for sp in self.logic[key].get("입력", [])} + if depth == 0 and arg in inputs: + use = ("입력", arg) + elif depth > 0 and arg in rename: + use = ("입력", rename[arg]) + elif depth > 0 and arg in lit and self.eq_no.get(str(lit[arg])): + use = ("고정", self.eq_no[str(lit[arg])]) + else: + use = ("입력", f"§{path}:EQ:{arg}") + self.eq_uses[field].append((top, f"{path}@{ref}", use)) + elif ref.startswith("EQ0"): + self.eq_uses[field].append((top, f"{path}@{ref}", ("고정", ref))) + if depth >= 3: + return + for j, (sub, args) in enumerate(calls): + if sub not in self.logic: + continue + self.callers[sub].add(top) + ren, lt = {}, {} + for name, (kind, val) in args.items(): + if kind == "lit": + lt[name] = val + elif kind == "name": + if depth == 0: + ren[name] = val + elif val in rename: + ren[name] = rename[val] + elif val in lit: + lt[name] = lit[val] + self._expand(top, sub, ren, lt, depth + 1, f"{path}>{sub}.{j}") + + @staticmethod + def _pass(row: dict, filt: dict) -> bool: + """칸 하나가 줄 거름을 통과?""" + for col, want in filt.items(): + have = row.get(col) + if have is None: + continue + if isinstance(have, list): + if isinstance(want, float) and len(have) == 2: + lo, hi = have + if (lo is not None and want < lo) or (hi is not None and want > hi): + return False + continue + if isinstance(want, float): + try: + if float(have) != want: + return False + except (TypeError, ValueError): + return False + elif str(have) != str(want): + return False + return True + + @staticmethod + def _trade_ok(name: str, c: dict) -> bool: + if c["종류"] == "eq": + return False + if c["종류"] != "ratio" and fam(c["단위"]) not in (None, "사람"): + return False + return same_trade(name, c["칸"].split("/")[0]) + + def lookup(self, k: dict) -> list[tuple[int, str]]: + """상수 → [(칸 id, 꼴 eq | pct | kgt)] — N1 · N2 · N3 · N4 · N5 · N8(캐시).""" + key = kkey(k) + if key not in self._look: + self._look[key] = self._lookup(k) + return self._look[key] + + def _lookup(self, k: dict) -> list[tuple[int, str]]: + v, d, unit = k["v"], k["d"], k["단위"] + sig = len(re.sub(r"^0+|\.", "", f"{v:.{d}f}").lstrip("0")) + tol = 0.5 * 10 ** (-d) if d >= 1 and sig >= 2 else 1e-9 + if k.get("몫"): + tol = abs(v) * 1e-6 + forms = [(v, tol, "eq")] + tf = fam(unit) + if tf in (None, "율"): + forms += [(v * 100, tol * 100, "pct"), (v / 100, tol / 100, "pct")] + if tf == "무게": + forms += [(v * 1000, tol * 1000, "kgt"), (v / 1000, tol / 1000, "kgt")] + out = [] + for x, t, how in forms: + t = max(t, abs(x) * 1e-9) + for j in range( + bisect.bisect_left(self.vals, x - t), bisect.bisect_right(self.vals, x + t) + ): + cid = self.ids[j] + c = self.cells[cid] + if c["종류"] == "cond": + continue # 조건 칸(관경 · SCH)은 옵션 값 — 상수 설명에 안 씀 + if k.get("직종") and not self._trade_ok(k["직종"], c): + continue + if c["종류"] == "ratio" and how != "eq": + continue + if how == "pct" and not (tf == "율" or "%" in str(c["단위"])): + continue + if how == "kgt" and not (tf == "무게" or fam(c["단위"]) == "무게"): + continue + if ( + how == "eq" + and abs(c["v"] - v) > 1e-9 + and c["종류"] != "ratio" + and tol < 1e-6 + and not k.get("몫") + ): + continue + if not unit_ok(unit, c["단위"]) and how == "eq": + continue + out.append((cid, how)) + return out + + def hits_of(self, cid: int) -> list[tuple]: + """칸 → [(로직, 부름id, 줄, 묶음)](캐시).""" + got = self._hits.get(cid) + if got is None: + got = self._hits[cid] = self._hits_of(cid) + return got + + def _hits_of(self, cid: int) -> list[tuple]: + c = self.cells[cid] + out = [] + if c["종류"] == "기준": + return [(u[0], f"{u[1]}|기준", 0, {}) for u in self.uses.get(c["표"], [])] + if c["종류"] == "eq": + for item, callid, (kind, val) in self.eq_uses.get(c["칸"], []): + if kind == "고정" and val != c["표"]: + continue + out.append( + ( + item, + callid, + c["표"], + {val: json.dumps(c["표"])} if kind == "입력" and val else {}, + ) + ) + return out + row = self.tables[c["표"]]["줄"][c["줄"]] + cols = c["칸"].split("/") if c["종류"] == "ratio" else [c["칸"]] + for item, callid, field, filt, bind in self.uses.get(c["표"], []): + if field is not None and field not in cols and c["종류"] != "cond": + continue + if not self._pass(row, filt): + continue + b = {} + for col, name in bind.items(): + if col == "#row": + b[name] = json.dumps(c["줄"]) + elif row.get(col) is not None: + b[name] = json.dumps(row[col], ensure_ascii=False) + out.append((item, callid, c["줄"], b)) + return out + + def explain(self, k: dict) -> list[tuple]: + """상수 하나 → [(로직, 부름id | None, 줄, 묶음, 꼴, 닻)] · + 닻 = 표 칸 · 직종 식 수로 맞음.""" + out = [] + for cid, how in self.lookup(k): + if self.cells[cid]["종류"] == "eq": + continue # 기계 요소 값(상각비계수 · 잡재료율 …)은 산근 글에 거의 안 나옴 + out += [(item, call, row, bd, how, True) for item, call, row, bd in self.hits_of(cid)] + x = round(k["v"], 9) + if k.get("직종"): + for item in self.trade_index.get(x, ()): + if any( + same_trade(k["직종"], n) for n, y in self.inline_trade[item] if round(y, 9) == x + ): + out.append((item, None, None, {}, "식", True)) + else: + forms = ( + (x, round(k["v"] * 100, 9), round(k["v"] / 100, 9)) + if fam(k["단위"]) in (None, "율") + else (x,) + ) + for form in forms: + for item in self.inl_index.get(form, ()): + if len(self.inline[item]) <= INLINE_MAX: + out.append((item, None, None, {}, "식", False)) + return out + + # ── 우연 보정 ──────────────────────────────────────────────────── + def df(self, k: dict) -> int: + key = kkey(k) + if key not in self._df: + self._df[key] = max(1, len({x[0] for x in self.explain(k)})) + return self._df[key] + + def prior(self) -> None: + """보정 자료 상수마다 설명할 수 있는 로직 → 로직 몫 p · 평균 df.""" + keys = [ + dict(zip(("v", "d", "단위", "직종", "몫"), row)) + for row in json.loads(CORPUS.read_text(encoding="utf-8")) + ] + cnt: dict = defaultdict(int) + for k in keys: + for it in {x[0] for x in self.explain(k)}: + cnt[it] += 1 + self.prom = {it: (cnt[it] + 1) / (len(keys) + 2) for it in self.logic} + self.mean_df = sum(self.df(k) for k in keys) / max(len(keys), 1) + + def q_of(self, item: str, k: dict) -> float: + """로직 item 이 상수 k 를 우연히 설명할 확률 ≈ df_k · p_item / 평균 df.""" + p = self.prom.get(item, 1 / max(len(self.prom), 1)) + return min(0.95, max(1e-6, self.df(k) * p / self.mean_df)) + + +def kkey(k: dict) -> tuple: + return (k["v"], k["d"], k["단위"], k.get("직종"), bool(k.get("몫"))) + + +def pb_sig(qs: list[float], c: int) -> float: + """포아송 이항 꼬리 P(X ≥ c) 의 -log10.""" + if c <= 0: + return 0.0 + dp = [1.0] + [0.0] * len(qs) + for q in qs: + for j in range(len(dp) - 1, 0, -1): + dp[j] = dp[j] * (1 - q) + dp[j - 1] * q + dp[0] *= 1 - q + return round(-math.log10(max(sum(dp[c:]), 1e-300)), 2) + + +_LOCK = threading.Lock() +_CACHE: dict[str, Index] = {} + + +def get() -> Index: + """M01 지문마다 한 번 지은 역색인(프로세스 안 · 지문 바뀌면 다시).""" + key = stamp() + with _LOCK: + got = _CACHE.get(key) + if got is None: + trade.cache_clear() + _CACHE.clear() + got = _CACHE[key] = Index() + return got + + +def warm() -> None: + """서버 시작 때 한 번(뒤에서) — 실패 = 다음 부름에서 다시.""" + try: + get() + except Exception: # noqa: BLE001 + pass diff --git a/M02_MasterTemplete/M02_San_Basis_Store.py b/M02_MasterTemplete/M02_San_Basis_Store.py new file mode 100644 index 000000000..38e9cb77e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Store.py @@ -0,0 +1,134 @@ +"""근거 제안 길 · 저장본(PLAN 793-3a) — 서버 단독 · 읽기만(HP 는 안 고침). + +올림 = HP 에 `숨은식 {번: [G 식]}`(STmate 단가산출근거 숨은 G 열 · `hidden`) + 뒤 셈(`ensure`). +저장본 = HP 폴더 `_derived/san_basis_{키}` · 입력 지문 = HP(지문 · 판 · 올린때) + M01 역색인 지문 ++ 판 · +지문 다를 때만 다시 셈 · GET 은 안 지움 · [끝내기] = `forget`. +답 꼴 = `listing` {셈중, 블록 {번: 제안}, 보강 [{이름, 품셈, 까닭, 블록 [번]}]} · `block` = 제안 +하나 +(`M02_San_Basis.suggest` 꼴 — 단 · 꼴 · 근거후보 · 절 · 보강?). +""" + +from __future__ import annotations + +import shutil +import threading +from pathlib import Path +from typing import Any + +from common_util import common_util_derived_store as derived +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_San_Basis as basis +from M02_MasterTemplete import M02_San_Basis_Index as bi + +KIND = "san_basis_" +VERSION = "793-3a.1" # 제안 꼴이 바뀌면 올림 +SHEET = "단가산출근거" +_LOCK, _RUNNING = threading.Lock(), set() + + +def hidden(book: dict[str, Any], sheets: dict[str, list[list[Any]]]) -> dict[str, list[str]]: + """STmate 블록마다 숨은 G 열 원문 식(「_x0007_」 머리 줄만) · 그 밖 = {}.""" + rows = sheets.get(SHEET) or [] + if book.get("꼴") != "STmate" or not rows: + return {} + out = {} + for blk in book.get("단산") or []: + start, end = blk["행"] + got = [] + for r in range(start, end + 1): + row = rows[r - 1] if 0 < r <= len(rows) else [] + v = str(row[6]) if len(row) > 6 and row[6] is not None else "" + if v.startswith("_x0007_") and v != "_x0007_": + got.append(v.replace("_x0007_", "").strip()) + out[str(blk["번"])] = got + return out + + +def one(book: dict[str, Any], no: int) -> dict[str, Any]: + blk = next(b for b in book.get("단산") or [] if int(b["번"]) == int(no)) + line, same = judge._caller(book, int(no)) + g = (book.get("숨은식") or {}).get(str(no)) + return basis.suggest(blk, book.get("꼴") or "", line, same, g) + + +def _stamp(key: str, book: dict[str, Any]) -> str: + return derived.derived_key( + VERSION, key, book.get("지문"), book.get("판"), book.get("올린때"), bi.stamp() + ) + + +def solved(folder: Path, key: str) -> dict[str, Any] | None: + book = hopyo_store.read(folder, key) + got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book)) + return got["value"] if got else None + + +def solve_all(folder: Path, key: str) -> dict[str, Any]: + book = hopyo_store.read(folder, key) + out = { + str(b["번"]): one(book, int(b["번"])) + for b in book.get("단산") or [] + if b.get("번") is not None + } + derived.write_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book), out, keep=1) + return out + + +def _work(folder: Path, key: str) -> None: + try: + solve_all(folder, key) + except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본) + pass + finally: + with _LOCK: + _RUNNING.discard(f"{folder}|{key}") + + +def ensure(folder: Path, key: str) -> bool: + """저장본 있음 = 참 · 없으면 뒤 셈 시작(같은 파일 한 번) = 거짓.""" + if solved(folder, key) is not None: + return True + tag = f"{folder}|{key}" + with _LOCK: + if tag in _RUNNING: + return False + _RUNNING.add(tag) + threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start() + return False + + +def shortfall(got: dict[str, Any]) -> list[dict[str, Any]]: + """없음 블록 → M01 보강 목록(794-2 근거) — 이름 · 품셈 묶음마다 블록 번.""" + out: dict[tuple, dict] = {} + for no, r in got.items(): + if r.get("보강"): + b = r["보강"] + one_ = out.setdefault((b["이름"], tuple(b["품셈"])), {**b, "블록": []}) + one_["블록"].append(int(no)) + return sorted(out.values(), key=lambda x: (-len(x["블록"]), x["이름"])) + + +def listing(folder: Path, key: str) -> dict[str, Any]: + """{셈중, 블록 {번: 제안}, 보강} — 셈 중 = 블록 {} · 뒤 셈 시작.""" + got = solved(folder, key) + if got is None: + ensure(folder, key) + return {"키": key, "셈중": True, "블록": {}, "보강": []} + return {"키": key, "셈중": False, "블록": got, "보강": shortfall(got)} + + +def block(folder: Path, key: str, no: int) -> dict[str, Any]: + """블록 하나 — 저장본 › 그 자리 셈.""" + got = (solved(folder, key) or {}).get(str(no)) + if got is not None: + return got + book = hopyo_store.read(folder, key) + if not any(b.get("번") is not None and int(b["번"]) == int(no) for b in book.get("단산") or []): + raise judge._bad(f"없는 블록 「단산{no}」", 404) + return one(book, no) + + +def forget(folder: Path, key: str) -> None: + shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True) diff --git a/M02_MasterTemplete/M02_San_Basis_Value.py b/M02_MasterTemplete/M02_San_Basis_Value.py new file mode 100644 index 000000000..eed555c7e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Value.py @@ -0,0 +1,307 @@ +"""근거 제안 3 — 값 길: 상수 지문 → M01 로직 후보(PLAN 793-3a · 분석 648-D 1-3) · 읽기만. + +로직마다 입력 값 조합(400 넘으면 입력마다 혼자 가장 많이 덮는 값) → 찾기마다 줄 하나(새로 덮는 +상수가 +가장 많은 줄부터) → 덮은 상수 · 우연 보정 유의(−log10 꼬리 확률) · 판정 U 유일 · 다 설명 · 옵션 +하나 / +P 유일 / A 동점(유의 1 안) / N 유의 4 미만 / N0 상수 없음 · 동점이 모두 한 하위 로직을 부르면 그 +하위(핵). +블록 = 일 절 2+ · 상수 2+ 주 절이 있으면 주 절 판정 모음 + 절 밖 상수 · 그 밖 블록 통째 + 덧 기초. +""" + +from __future__ import annotations + +import itertools +import json +import math +from collections import defaultdict +from typing import Any + +from M02_MasterTemplete import M02_San_Basis_Const as bc +from M02_MasterTemplete import M02_San_Basis_Index as bi + +TIE = 1.0 # 동점 폭(유의) +SIG_MIN = 4.0 # 1등 유의가 이 아래면 N — 로직 1,515 중 우연 1등 ≈ 10^-3.2 +fam = bi.fam + + +def solve(hits: list[tuple]) -> dict: + """hits = [(로직, 부름id, 줄, 묶음)] · 식 속 수 = 부름id None → 덮음 · 옵션 · 옵션여럿.""" + inl = {h[0] for h in hits if h[1] is None} + bound = [h for h in hits if h[1] is not None] + inputs = sorted({i for h in bound for i in h[3] if not i.startswith("§")}) + vals = {i: sorted({h[3][i] for h in bound if i in h[3]}) for i in inputs} + by_call: dict = defaultdict(lambda: defaultdict(set)) + row_bind = {} + for k, call, row, b in bound: + by_call[call][row].add(k) + row_bind[(call, row)] = b + order = sorted(by_call, key=lambda c: -max(len(x) for x in by_call[c].values())) + + def run(combo: dict): + cov, fixed, pick = set(inl), dict(combo), {} + while True: + best = None + for call in order: + if call in pick: + continue + for r, ks in by_call[call].items(): + gain = len(ks - cov) + if ( + gain + and (best is None or gain > best[0]) + and all( + fixed.get(i, v) == v if i.startswith("§") else combo.get(i) == v + for i, v in row_bind[(call, r)].items() + ) + ): + best = (gain, call, r, ks) + if best is None: + break + _, call, r, ks = best + cov |= ks + pick[call] = r + fixed |= {i: v for i, v in row_bind[(call, r)].items() if i.startswith("§")} + return cov, pick, -len(pick) + + if (math.prod(len(v) for v in vals.values()) if vals else 1) <= 400: + combos = [dict(zip(inputs, c)) for c in itertools.product(*[vals[i] for i in inputs])] or [ + {} + ] + else: # ponytail: 입력 값 조합이 많으면 입력마다 혼자 가장 많이 덮는 값 — 상호작용 놓칠 수 있음 + cnt = defaultdict(set) + for h in bound: + for i, v in h[3].items(): + cnt[(i, v)].add(h[0]) + combos = [{i: max(vals[i], key=lambda v: len(cnt[(i, v)])) for i in inputs}] + best, outs = (-1, -1), [] + for combo in combos: + cov, pick, ev = run(combo) + score = (len(cov), ev) + if score > best: + best, outs = score, [(combo, cov, pick)] + elif score == best: + outs.append((combo, cov, pick)) + used = { + i + for _, _, pick in outs + for call, r in pick.items() + for i in row_bind[(call, r)] + if not i.startswith("§") + } + resolved, opt_amb = {}, {} + for i in sorted(used): + seen = sorted({o[0].get(i) for o in outs if o[0].get(i) is not None}) + if len(seen) == 1: + resolved[i] = json.loads(seen[0]) + elif seen: + opt_amb[i] = [json.loads(x) for x in seen] + same_cov = len({frozenset(o[1]) for o in outs}) == 1 + return {"덮음": outs[0][1], "옵션": resolved, "옵션여럿": opt_amb, "조합같은덮음": same_cov} + + +def rank(idx: bi.Index, K: list[dict]) -> list[dict]: + """상수 목록 → 로직 후보(유의 차례).""" + per_item, anchored, how_of = defaultdict(list), set(), defaultdict(dict) + for ki, k in enumerate(K): + for item, call, row, bd, how, anc in idx.explain(k): + per_item[item].append((ki, call, row, bd)) + how_of[item].setdefault(ki, how) + if anc: + anchored.add(item) + real = sorted(i for i, k in enumerate(K) if not k.get("일반")) + ub = [] + for it, hs in per_item.items(): + if it in anchored: + c = len({h[0] for h in hs} & set(real)) + ub.append((bi.pb_sig([idx.q_of(it, K[i]) for i in real], c), c, it)) + ub.sort(key=lambda x: (-x[0], -x[1], x[2])) + out, best = [], 0.0 + for s_ub, c, item in ub: + if c == 0 or s_ub < best - TIE or len(out) >= 200: + break + got = solve(per_item[item]) + got_real = got["덮음"] & set(real) + s_val = bi.pb_sig([idx.q_of(item, K[i]) for i in real], len(got_real)) + best = max(best, s_val) + out.append({"로직": item, **got, "꼴": how_of[item], "실수": len(got_real), "유의": s_val}) + out.sort(key=lambda o: (-o["유의"], -o["실수"], o["로직"])) + return out + + +def collapse(idx: bi.Index, tied: list[dict]) -> tuple[list[dict], str]: + """동점 정리 — 다른 동점이 모두 그 로직을 부르면 그 하위(핵) · 위 로직 하나면 그 위 로직 · + 그 밖 = 동점 위 로직이 덮는 하위는 뺌.""" + for c in tied: + others = [o for o in tied if o is not c] + if others and all(o["로직"] in idx.callers.get(c["로직"], set()) for o in others): + return ( + (others, "위 로직 하나") + if len(others) == 1 + else ([c], f"핵 하위 로직(위 로직 {len(others)})") + ) + keep = [] + for o in tied: + if not [ + p + for p in tied + if p is not o + and p["로직"] in idx.callers.get(o["로직"], set()) + and o["덮음"] <= p["덮음"] + ]: + keep.append(o) + return keep, "" + + +def judge(idx: bi.Index, K: list[dict]) -> dict: + """한 단위(절 · 블록) 판정 — U · P · A · N · N0.""" + real = {i for i, k in enumerate(K) if not k.get("일반")} + if not real: + return {"판정": "N0", "까닭": "지문 없음(상수 0)", "후보": [], "K": K} + cands = [o for o in rank(idx, K) if o["실수"]] + if not cands: + return {"판정": "N", "까닭": "맞는 표 없음", "후보": [], "K": K} + top = cands[0] + if top["유의"] < SIG_MIN: + return { + "판정": "N", + "까닭": f"유의 못 미침(1등 {top['실수']}/{len(real)} · 유의 {top['유의']})", + "후보": cands[:5], + "K": K, + } + tied, note = collapse(idx, [o for o in cands if o["유의"] >= top["유의"] - TIE]) + if len(tied) >= 2: + return { + "판정": "A", + "까닭": f"동점 {len(tied)}", + "후보": tied[:12], + "n": len(tied), + "K": K, + "남음": sorted(real - top["덮음"]), + } + top = tied[0] + left = sorted(real - top["덮음"]) + full = not left and not top["옵션여럿"] and top["조합같은덮음"] + rest = [o for o in cands if o is not top] + return { + "판정": "U" if full else "P", + "까닭": note, + "후보": [top] + rest[:4], + "K": K, + "남음": left, + } + + +def cover(idx: bi.Index, K: list[dict], first: dict) -> list[dict]: + """블록 덮기 — 첫 기초 뒤 남은 상수로 다음 기초(새 상수 2+ · 유일 · 넷까지).""" + picks, done = [first], set(first["덮음"]) + for _ in range(3): + rest = [k if i not in done else {**k, "일반": True} for i, k in enumerate(K)] + if all(k.get("일반") for k in rest): + break + got = judge(idx, rest) + if got["판정"] not in ("U", "P"): + break + o = got["후보"][0] + if len(o["덮음"] - done) < 2: + break + picks.append({**o, "동점": 1}) + done |= o["덮음"] + return picks + + +def assemble(idx: bi.Index, bK: list[dict], mains: list) -> dict: + """절 2+ 블록 — 주 절 판정 모음 + 절 밖 상수(머리 · 비목 절)는 고른 기초가 덮는지 · + 남으면 따로.""" + picks, verdicts, covered = [], [], set() + + def key(c): + return (round(c["v"], 9), fam(c["단위"]), c.get("직종")) + + for _, _, K, sj in mains: + verdicts.append(sj["판정"]) + if sj["판정"] in ("U", "P"): + o = sj["후보"][0] + mine = {key(K[j]) for j in o["덮음"]} + picks.append({**o, "덮음": {i for i, c in enumerate(bK) if key(c) in mine}}) + covered |= {key(c) for c in K} + rest = [c if key(c) not in covered else {**c, "일반": True} for c in bK] + left = {i for i, c in enumerate(rest) if not c.get("일반")} + if left and picks: + chosen = {p["로직"] for p in picks} + for o in rank(idx, rest): + if o["로직"] not in chosen: + continue + got = o["덮음"] & left + for p in picks: + if p["로직"] != o["로직"]: + continue + p["덮음"] = p["덮음"] | got + if got: # 절 밖 상수가 정한 옵션도 얹음(같은 입력 다른 값 = 여럿) + for kk, vv in o["옵션"].items(): + if kk in p["옵션"] and p["옵션"][kk] != vv: + p["옵션여럿"] = {**p["옵션여럿"], kk: [p["옵션"][kk], vv]} + else: + p["옵션"] = {**p["옵션"], kk: vv} + left -= got + if len(left) >= 2: + extra = judge(idx, [c if i in left else {**c, "일반": True} for i, c in enumerate(bK)]) + if extra["판정"] in ("U", "P"): + picks.append(extra["후보"][0]) + left -= extra["후보"][0]["덮음"] + verdicts.append(extra["판정"]) + if not picks: + out = { + "판정": "A" if "A" in verdicts else ("N" if "N" in verdicts else "N0"), + "후보": [], + "K": bK, + } + a = next((x[3] for x in mains if x[3]["판정"] == "A"), None) + return out | ({"후보": a["후보"], "n": a["n"], "K": a["K"]} if a else {}) + allu = not left and all(not p["옵션여럿"] for p in picks) + return { + "판정": "U" if allu else "P", + "후보": picks, + "K": bK, + "기초목록": picks, + "남음": sorted(left), + } + + +def _show(o: dict) -> dict: + return {"로직": o["로직"], "옵션": o["옵션"], "옵션여럿": o["옵션여럿"]} + + +def value(idx: bi.Index, pr: dict) -> dict: + """블록 준비(`Const.prep`) → 값 판정 {판정 U|P|A|N|N0, 기초 · 동점 · 약한후보 [{로직, 옵션}], + 절 [{절, 판정, 기초?, 동점?}], K, 입력}.""" + lines, per_line, secs = pr["lines"], pr["per_line"], pr["secs"] + bK, bIns = bc.gather(per_line, range(len(lines))) + sec_res = [] + for title, ids in secs: + K, _ = bc.gather(per_line, ids) + sec_res.append((title, ids, K, judge(idx, K))) + mains = [x for x in sec_res if len([k for k in x[2] if not k.get("일반")]) >= 2] + multi = len(secs) >= 2 and len(mains) >= 1 + bj = assemble(idx, bK, mains) if multi else judge(idx, bK) + rec: dict[str, Any] = { + "판정": bj["판정"], + "K": [{"단위": c["단위"]} for c in bK], + "입력": [c["v"] for c in bIns], + } + if bj["판정"] in ("U", "P"): + rec["기초"] = [_show(p) for p in (bj.get("기초목록") or cover(idx, bK, bj["후보"][0]))] + elif bj["판정"] == "A": + rec["동점"] = [_show(o) for o in bj["후보"]] + rec["n"] = bj["n"] + elif bj["판정"] == "N" and bj.get("후보"): + rec["약한후보"] = [_show(o) for o in bj["후보"][:3]] + rec["절"] = [] + for title, _, _, sj in sec_res: + one = {"절": title, "판정": sj["판정"]} + if sj["판정"] in ("U", "P"): + one["기초"] = _show(sj["후보"][0]) + elif sj["판정"] == "A": + one["동점"] = [o["로직"] for o in sj["후보"]] + rec["절"].append(one) + return rec diff --git a/M02_MasterTemplete/M02_San_Basis_corpus.json b/M02_MasterTemplete/M02_San_Basis_corpus.json new file mode 100644 index 000000000..20fd4fd93 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_corpus.json @@ -0,0 +1 @@ +[[0.6, 1, "m3", null, false], [0.55, 2, "", null, false], [0.35, 2, "", null, false], [18.0, 0, "sec", null, false], [90.0, 0, "", null, false], [23.1, 1, "m3", null, false], [90.0, 0, "%", null, false], [0.3, 1, "인", "보통인부", false], [10.0, 0, "%", null, false], [0.7, 1, "분", null, false], [0.45, 2, "", null, false], [20.0, 0, "sec", null, false], [135.0, 0, "", null, false], [34.02, 2, "m3", null, false], [7.0, 0, "km", null, false], [4.5, 1, "ton", null, false], [1.75, 2, "ton", null, false], [1.1, 1, "분", null, false], [9.23, 2, "m3", null, false], [110.0, 0, "m3", null, false], [0.009, 3, "인", "보통인부", false], [0.009, 3, "인", "특별인부", false], [0.7, 1, "", null, false], [10.0, 0, "km", null, false], [10.0, 0, "", null, false], [0.42, 2, "분", null, false], [13.09, 2, "m3", null, false], [0.875, 3, "", null, false], [1.25, 2, "", null, false], [0.75, 2, "", null, false], [21.0, 0, "sec", null, false], [81.0, 0, "m3", null, false], [12.0, 0, "개", null, false], [2.0, 0, "", null, false], [51.02, 2, "m3", null, false], [290.0, 0, "", null, false], [0.003, 3, "인", "특별인부", false], [0.003, 3, "인", "보통인부", false], [50.0, 0, "Km", null, false], [40.0, 0, "", null, false], [30.0, 0, "Km", null, false], [15.0, 0, "Km", null, false], [10.0, 0, "Km", null, false], [0.17, 2, "인", "보통인부", false], [575.0, 0, "", null, false], [1750.0, 0, "", null, false], [20.79, 2, "m3", null, false], [9.96, 2, "m3", null, false], [50.0, 0, "cm", null, false], [2.0, 0, "m", null, false], [0.002, 3, "kg", null, false], [0.0586, 4, "kg", null, false], [0.0011, 5, "인", "특별인부", false], [0.01676, 5, "인", "보통인부", false], [270.0, 0, "", null, false], [800.0, 0, "mm", null, false], [0.24, 2, "인", "배관공(수도)", false], [6.0, 0, "", null, false], [0.04, 9, "인", "배관공(수도)", true], [0.12, 2, "인", "보통인부", false], [0.02, 9, "인", "보통인부", true], [2.0, 0, "%", null, false], [0.25, 2, "hr", null, false], [336.0, 0, "", null, false], [0.32, 2, "인", "배관공(수도)", false], [0.05333333333333334, 9, "인", "배관공(수도)", true], [0.16, 2, "인", "보통인부", false], [0.02666666666666667, 9, "인", "보통인부", true], [0.3, 1, "hr", null, false], [42.0, 0, "회", null, false], [575.0, 0, "kg", null, false], [25.0, 0, "kg", null, false], [6.0, 0, "개", null, false], [24.0, 0, "개", null, false], [36.0, 0, "개", null, false], [48.0, 0, "개", null, false], [18.0, 0, "", null, false], [23.0, 0, "", null, false], [34.0, 0, "", null, false], [56.0, 0, "", null, false], [78.0, 0, "", null, false], [26.7, 1, "%", null, false], [2.0, 1, "hr", null, false], [0.58, 2, "인", "비계공", false], [0.34, 2, "인", "특별인부", false], [0.1, 1, "인", "보통인부", false], [0.2, 1, "", null, false], [0.9, 1, "", null, false], [25.0, 0, "톤", null, false], [0.7, 1, "m3", null, false], [0.447, 3, "Ton", null, false], [20.0, 0, "", null, false], [30.0, 0, "km", null, false], [35.0, 0, "km", null, false], [11.46, 2, "m3", null, false], [1200.0, 0, "mm", null, false], [0.39, 2, "인", "배관공(수도)", false], [0.065, 9, "인", "배관공(수도)", true], [0.19, 2, "인", "보통인부", false], [0.03166666666666667, 9, "인", "보통인부", true], [0.35, 2, "hr", null, false], [505.0, 0, "", null, false], [1.3, 1, "", null, false], [0.85, 2, "", null, false], [15.0, 1, "ton", null, false], [1.9, 1, "ton", null, false], [39.87, 2, "m3", null, false], [266.0, 0, "", null, false], [518.0, 0, "", null, false], [20.0, 0, "m", null, false], [0.96, 2, "", null, false], [30.0, 0, "m", null, false], [0.92, 2, "", null, false], [40.0, 0, "m", null, false], [0.88, 2, "", null, false], [55.0, 0, "m", null, false], [70.0, 0, "m", null, false], [3.2, 1, "", null, false], [0.5, 1, "", null, false], [0.25, 2, "분", null, false], [0.91, 2, "분", null, false], [77.93, 2, "m3", null, false], [757.0, 0, "", null, false], [460.0, 0, "", null, false], [0.72, 2, "", null, false], [0.07, 2, "인", "형틀목공", false], [0.03, 2, "인", "보통인부", false], [15.0, 0, "", null, false], [1.9, 1, "", null, false], [21.0, 0, "", null, false], [7.0, 0, "", null, false], [29.36, 2, "m3", null, false], [703.0, 0, "", null, false], [15.0, 0, "톤", null, false], [1.175, 3, "", null, false], [1.7, 1, "ton", null, false], [1.72, 2, "", null, false], [1.8, 1, "", null, false], [8.0, 0, "", null, false], [14.0, 0, "", null, false], [0.6, 1, "", null, false], [0.5, 1, "분", null, false], [3.18, 2, "m3", null, false], [23.0, 0, "m3", null, false], [0.13043478260869565, 9, "인", "콘크리트공", true], [0.13043478260869565, 9, "인", "보통인부", true], [63.0, 0, "m3", null, false], [3.0, 0, "인", "콘크리트공", false], [0.047619047619047616, 9, "인", "콘크리트공", true], [0.015873015873015872, 9, "인", "보통인부", true], [55.0, 0, "m3", null, false], [0.05454545454545454, 9, "인", "콘크리트공", true], [0.01818181818181818, 9, "인", "보통인부", true], [1.03, 2, "m2", null, false], [38.0, 0, "%", null, false], [0.038, 3, "m3", null, false], [9.0, 0, "%", null, false], [45.0, 0, "m2", null, false], [5.0, 0, "인", "형틀목공", false], [0.1111111111111111, 9, "인", "형틀목공", true], [2.0, 0, "인", "보통인부", false], [0.044444444444444446, 9, "인", "보통인부", true], [1.0, 0, "%", null, false], [32.7, 1, "%", null, false], [11.0, 0, "%", null, false], [50.0, 0, "m2", null, false], [0.1, 9, "인", "형틀목공", true], [0.04, 9, "인", "보통인부", true], [0.095, 3, "매", null, false], [55.0, 0, "%", null, false], [35.0, 0, "m2", null, false], [4.0, 0, "인", "형틀목공", false], [0.11428571428571428, 9, "인", "형틀목공", true], [0.02857142857142857, 9, "인", "보통인부", true], [3.0, 0, "%", null, false], [10.0, 0, "cm", null, false], [0.65, 2, "", null, false], [19.0, 0, "sec", null, false], [66.32, 2, "m3", null, false], [0.1, 1, "", null, false], [0.15, 2, "m", null, false], [2.0, 0, "회", null, false], [25.8, 1, "", null, false], [12.3, 1, "", null, false], [12.8, 1, "", null, false], [13.0, 0, "%", null, false], [8.0, 0, "%", null, false], [1600.0, 0, "", null, false], [1416.0, 0, "kg", null, false], [160000.0, 0, "", null, false], [5.0, 0, "", null, false], [104.87, 2, "", null, false], [22.0, 0, "sec", null, false], [180.0, 0, "", null, false], [55.57, 2, "m3", null, false], [350.0, 0, "", null, false], [441.0, 0, "", null, false], [4.5, 1, "Ton", null, false], [0.3, 1, "", null, false], [0.09, 2, "", null, false], [6.0, 0, "인", "철근공", false], [3.4, 1, "Ton", null, false], [60.0, 0, "cm", null, false], [75.0, 0, "cm", null, false], [67.5, 1, "cm", null, false], [50.0, 0, "%", null, false], [500.0, 0, "m", null, false], [0.675, 3, "", null, false], [0.77, 2, "", null, false], [4.0, 0, "개", null, false], [40.0, 0, "cm", null, false], [0.026, 3, "인", "작업반장", false], [0.026, 3, "", null, false], [0.134, 3, "", null, false], [15.0, 0, "Ton", null, false], [2.4, 1, "ton", null, false], [1.35, 2, "", null, false], [12.2, 1, "m3", null, false], [0.83, 2, "", null, false], [10.0, 0, "m2", null, false], [1.3, 2, "", null, false], [4.8, 1, "", null, false], [1.04, 2, "", null, false], [3.84, 2, "", null, false], [26.95, 2, "m3", null, false], [14.43, 2, "m3", null, false], [0.096, 3, "", null, false], [0.011, 3, "인", "특별인부", false], [0.011, 3, "인", "보통인부", false], [24.26, 2, "m3", null, false], [40.0, 0, "Km", null, false], [20.0, 0, "Km", null, false], [1.1, 1, "", null, false], [10.5, 1, "", null, false], [11.0, 0, "", "보통인부", false], [450.0, 0, "", null, false], [0.024444444444444446, 9, "인", "보통인부", true], [5.0, 0, "%", null, false], [214.0, 0, "", null, false], [0.043, 3, "인", "형틀목공", false], [0.67, 2, "인", "철근공", false], [0.00623, 5, "", null, false], [0.22, 2, "인", "보통인부", false], [6.5, 1, "kg", null, false], [3.53, 2, "인", "철근공", false], [1.18, 2, "인", "보통인부", false], [0.11, 2, "", null, false], [0.04, 2, "", null, false], [0.22, 2, "hr", null, false], [77.7, 1, "m2", null, false], [759.0, 0, "", null, false], [250.0, 0, "", null, false], [15.0, 0, "mm", null, false], [0.029, 3, "인", "형틀목공", false], [0.006, 3, "인", "보통인부", false], [4.0, 0, "본", null, false], [0.25, 9, "인", "특별인부", true], [0.25, 9, "인", "보통인부", true], [390.0, 0, "m3", null, false], [250.0, 0, "m3", null, false], [230.0, 0, "m3", null, false], [3.0, 0, "", null, false], [817.0, 0, "", null, false], [1.06, 2, "인", "보통인부", false], [3.76, 2, "hr", null, false], [65.0, 0, "", null, false], [0.33, 2, "", null, false], [0.007, 3, "인", "화약취급공", false], [0.021, 3, "hr", null, false], [24.0, 0, "%", null, false], [0.009, 3, "hr", null, false], [1.4, 1, "", null, false], [1.625, 3, "", null, false], [1.85, 2, "", null, false], [20.46, 2, "m3", null, false], [290.0, 0, "m3", null, false], [0.0034482758620689655, 9, "인", "보통인부", true], [0.0034482758620689655, 9, "인", "특별인부", true], [310.0, 0, "m3", null, false], [844.0, 0, "", null, false], [764.0, 0, "", null, false], [50.0, 0, "m3", null, false], [23.43, 2, "m3", null, false], [150.0, 0, "m3", null, false], [78.0, 0, "m3", null, false], [756.0, 0, "", null, false], [419.0, 0, "", null, false], [379.0, 0, "", null, false], [37.71, 2, "m3", null, false], [785.0, 0, "", null, false], [190.0, 0, "m3", null, false], [58.21, 2, "m3", null, false], [28.0, 0, "", null, false], [19.0, 0, "", null, false], [3.5, 1, "%", null, false], [24.89, 2, "m3", null, false], [54.05, 2, "m3", null, false], [366.0, 0, "", null, false], [501.0, 0, "", null, false], [63.0, 0, "인", null, false], [2.0, 0, "인", "특별인부", false], [42.0, 0, "본", null, false], [0.047619047619047616, 9, "인", "특별인부", true], [0.023809523809523808, 9, "인", "보통인부", true], [0.08, 2, "%", null, false], [31.0, 0, "본", null, false], [0.06451612903225806, 9, "인", "특별인부", true], [0.03225806451612903, 9, "인", "보통인부", true], [1.05, 2, "", null, false], [0.006, 3, "", null, false], [300.0, 0, "mm", null, false], [12.56, 2, "m3", null, false], [0.08, 2, "인", "보통인부", false], [0.101, 3, "", null, false], [0.072, 3, "인", "석공", false], [0.053, 3, "인", "특별인부", false], [0.013, 3, "인", "보통인부", false], [150.0, 0, "m2", null, false], [0.18, 2, "", null, false], [0.01, 2, "", null, false], [200.0, 0, "", null, false], [30.86, 2, "매", null, false], [0.006666666666666667, 9, "인", "조경공", true], [30.86, 2, "", "보통인부", false], [0.03086, 9, "인", "보통인부", true], [0.2, 1, "m", null, false], [4.0, 1, "인", "포장공", false], [4.0, 0, "", null, false], [2.0, 1, "인", "보통인부", false], [431.0, 0, "", null, false], [1.2, 1, "", null, false], [517.0, 0, "", null, false], [600.0, 0, "m", null, false], [0.31, 2, "EA", null, false], [0.0016666666666666668, 9, "인", "특별인부", true], [0.0016666666666666668, 9, "인", "보통인부", true], [3.0, 0, "Ton", null, false], [15.0, 0, "km", null, false], [5.0, 0, "분", null, false], [10.0, 0, "분", null, false], [20.0, 0, "분", null, false], [5.5, 1, "", null, false], [7.28, 2, "", null, false], [450.0, 0, "M", null, false], [3000.0, 0, "", null, false], [0.0022, 4, "인", "특별인부", false], [0.0022, 4, "인", "보통인부", false], [0.15, 2, "", null, false], [0.21, 2, "", null, false], [0.24, 2, "", null, false], [3.0, 0, "개", null, false], [0.05, 2, "", null, false], [0.008, 9, "인", "보통인부", true], [0.03, 2, "", null, false], [0.003, 3, "", null, false], [300.0, 0, "m2", null, false], [0.31, 2, "hr", null, false], [0.75, 2, "인", "철근공", false], [0.25, 2, "인", "보통인부", false], [8.0, 0, "kg", null, false], [1.92, 2, "인", "철근공", false], [0.77, 2, "인", "보통인부", false], [4.0, 0, "%", null, false], [100.0, 0, "%", null, false], [0.38, 2, "", null, false], [0.06, 9, "인", "형틀목공", true], [48.6, 1, "mm", null, false], [2.3, 1, "mm", null, false], [3.99, 2, "m", null, false], [7.0, 0, "%", null, false], [0.5, 1, "EA", null, false], [12.0, 0, "%", null, false], [2.08, 2, "EA", null, false], [0.04, 2, "EA", null, false], [3.0, 0, "인", "비계공", false], [55.0, 0, "m2", null, false], [2.6, 1, "", null, false], [4.6, 1, "", null, false], [0.01, 2, "본", null, false], [1.5, 1, "", null, false], [14.77, 2, "m3", null, false], [3.3, 1, "", null, false], [5.9, 1, "", null, false], [30.0, 0, "cm", null, false], [2.7, 1, "", null, false], [0.02, 2, "", null, false], [0.08, 2, "", null, false], [0.05, 2, "kg", null, false], [4.17, 2, "", null, false], [1.6, 1, "", null, false], [16.0, 0, "m3", null, false], [76.3, 1, "mm", null, false], [3.0, 1, "m", null, false], [12.0, 0, "본", null, false], [0.16666666666666666, 9, "인", "특별인부", true], [22.0, 0, "본", null, false], [0.09090909090909091, 9, "인", "특별인부", true], [0.08333333333333333, 9, "인", "보통인부", true], [0.045454545454545456, 9, "인", "보통인부", true], [7.05, 2, "인", "철공", false], [2.57, 2, "인", "용접공", false], [1.92, 2, "인", "특별인부", false], [1.28, 2, "인", "보통인부", false], [15.0, 0, "%", null, false], [2.0, 0, "개", null, false], [0.55, 2, "인", null, false], [0.18, 2, "인", null, false], [30.0, 0, "개", null, false], [0.06, 2, "인", "특별인부", false], [150.0, 0, "M", null, false], [20.0, 0, "개", null, false], [60.0, 0, "%", null, false], [5.0, 0, "인", null, false], [10.0, 0, "인", null, false], [18.0, 0, "%", null, false], [23.0, 0, "%", null, false], [0.13, 2, "", null, false], [4.667, 3, "%", null, false], [23.38, 2, "m3", null, false], [0.58, 2, "", null, false], [1.01, 2, "", null, false], [3.36, 2, "", null, false], [2.4, 1, "", null, false], [3.6, 1, "", null, false], [2.2, 1, "", null, false], [1500.0, 0, "mm", null, false], [0.5, 2, "인", "배관공(수도)", false], [0.08333333333333333, 9, "인", "배관공(수도)", true], [0.041666666666666664, 9, "인", "보통인부", true], [0.43, 2, "hr", null, false], [0.006, 3, "인", "도장공", false], [0.001, 3, "인", "보통인부", false], [0.015, 3, "인", "도장공", false], [0.161, 3, "", null, false], [0.008, 3, "", null, false], [0.02, 2, "인", "도장공", false], [0.004, 3, "인", "보통인부", false], [0.166, 3, "인", null, false], [2.0, 0, "hr", null, false], [3.0, 0, "", "형틀목공", false], [50.0, 0, "인", "형틀목공", false], [50.0, 0, "인", "보통인부", false], [0.06, 2, "", null, false], [0.12, 2, "", null, false], [55.0, 0, "인", "비계공", false], [0.05454545454545454, 9, "인", "비계공", true], [55.0, 0, "인", "보통인부", false], [9.0, 0, "", null, false], [1.125, 3, "", null, false], [20.56, 2, "m3", null, false], [30.28, 2, "m3", null, false], [1.75, 2, "", null, false], [11.95, 2, "m3", null, false], [0.8, 1, "", null, false], [59.82, 2, "m3", null, false], [986.0, 0, "", null, false], [429.0, 0, "", null, false], [451.0, 0, "", null, false], [0.0034482758620689655, 9, "인", null, true], [35.0, 0, "", null, false], [1.325, 3, "", null, false], [24.51, 2, "m3", null, false], [90.0, 0, "m3", null, false], [0.011111111111111112, 9, "인", null, true], [40.37, 2, "m3", null, false], [1.7, 1, "", null, false], [2.95, 2, "m3", null, false], [3.04, 2, "m3", null, false], [0.00909090909090909, 9, "인", null, true], [33.0, 0, "kg", null, false], [0.0231, 4, "인", "콘크리트공", false], [0.0154, 4, "인", "특별인부", false], [0.0077, 4, "인", "보통인부", false], [3.0, 0, "", "콘크리트공", false], [2.0, 0, "인", "콘크리트공", false], [3.0, 0, "", "보통인부", false], [63.0, 0, "", null, false], [55.0, 0, "인", "콘크리트공", false], [55.0, 0, "", null, false], [5.0, 0, "인", "보통인부", false], [670.0, 0, "m2", null, false], [500.0, 0, "mm", null, false], [0.13, 2, "인", "배관공(수도)", false], [0.021666666666666667, 9, "인", "배관공(수도)", true], [0.07, 2, "인", "보통인부", false], [0.011666666666666667, 9, "인", "보통인부", true], [0.18, 2, "hr", null, false], [252.0, 0, "", null, false], [0.8, 1, "분", null, false], [45.21, 2, "m3", null, false], [26.46, 2, "m3", null, false], [780.0, 0, "", null, false], [0.9, 1, "본", null, false], [9.0, 0, "본", null, false], [0.99, 2, "본", null, false], [4.0, 0, "", "형틀목공", false], [130.0, 0, "인", "형틀목공", false], [0.03076923076923077, 9, "인", "형틀목공", true], [130.0, 0, "인", "보통인부", false], [0.007692307692307693, 9, "인", "보통인부", true], [2.0, 0, "", "형틀목공", false], [150.0, 0, "인", "형틀목공", false], [0.013333333333333334, 9, "인", "형틀목공", true], [2.0, 0, "", "보통인부", false], [150.0, 0, "인", "보통인부", false], [0.013333333333333334, 9, "인", "보통인부", true], [0.03333333333333333, 9, "인", "형틀목공", true], [0.008333333333333333, 9, "인", "보통인부", true], [150.0, 0, "", null, false], [430.0, 0, "", null, false], [0.0448, 4, "본", null, false], [0.009, 3, "본", null, false], [0.0895, 4, "개", null, false], [0.0492, 4, "개", null, false], [0.1628, 4, "개", null, false], [0.0163, 4, "개", null, false], [0.0407, 4, "개", null, false], [0.0081, 4, "개", null, false], [6.0, 0, "", "비계공", false], [150.0, 0, "인", "비계공", false], [0.04, 9, "인", "비계공", true], [0.0773, 4, "개", null, false], [0.0041, 4, "개", null, false], [0.8, 1, "m3", null, false], [0.94, 2, "", null, false], [46.52, 2, "m3", null, false], [27.23, 2, "m3", null, false], [0.45, 2, "m", null, false], [0.017, 3, "인", "보통인부", false], [0.04, 2, "인", "특별인부", false], [0.042, 3, "인", "석공", false], [399.0, 0, "", null, false], [29.0, 0, "%", null, false], [40.0, 0, "인", "형틀목공", false], [40.0, 0, "인", "보통인부", false], [0.025, 9, "인", "보통인부", true], [35.0, 0, "인", "형틀목공", false], [35.0, 0, "인", "보통인부", false], [50.45, 2, "m3", null, false], [535.0, 0, "", null, false], [0.2, 1, "인", "보통인부", false], [2.14, 2, "", "벌목부", false], [0.00214, 9, "인", "벌목부", true], [0.51, 2, "", "보통인부", false], [0.00051, 9, "인", "보통인부", true], [2.71, 2, "hr", null, false], [22.0, 0, "", null, false], [47.0, 0, "", null, false], [12.38, 2, "인", "철공", false], [2.25, 2, "인", "보통인부", false], [3.38, 2, "인", "용접공", false], [4.5, 2, "인", "특별인부", false], [50.0, 0, "km", null, false], [40.0, 0, "km", null, false], [20.0, 0, "km", null, false], [1.04, 2, "m", null, false], [0.042, 3, "kg", null, false], [0.21, 2, "kg", null, false], [0.151, 3, "", null, false], [0.116, 3, "", null, false], [34.23, 2, "m3", null, false], [750.0, 0, "", null, false], [789.0, 0, "", null, false], [49.9, 1, "m3", null, false], [0.22, 2, "", null, false], [1.05, 2, "분", null, false], [3.48, 2, "m3", null, false], [54.43, 2, "m3", null, false], [0.19, 2, "", null, false], [71.0, 0, "%", null, false], [60.0, 0, "Ton", null, false], [2.48, 2, "", null, false], [0.95, 2, "", null, false], [2333.0, 0, "", null, false], [12.0, 0, "", null, false], [30.0, 0, "톤", null, false], [73.0, 0, "%", null, false], [1.36, 2, "", null, false], [10.0, 0, "Ton", null, false], [273.0, 0, "", null, false], [0.058, 3, "", null, false], [0.083, 3, "", null, false], [0.83, 2, "", "작업반장", false], [0.08299999999999999, 9, "인", "작업반장", true], [1.04, 2, "", "특별인부", false], [0.10400000000000001, 9, "인", "특별인부", true], [1.04, 2, "", "보통인부", false], [0.10400000000000001, 9, "인", "보통인부", true], [0.384, 3, "", null, false], [0.336, 3, "", null, false], [1.3, 2, "", "특별인부", false], [0.13, 9, "인", "특별인부", true], [1.3, 2, "", "보통인부", false], [0.13, 9, "인", "보통인부", true], [0.48, 2, "", null, false], [70.0, 0, "cm", null, false], [0.4, 1, "", null, false], [6.0, 0, "%", null, false], [2.65, 2, "", null, false], [21.36, 2, "m3", null, false], [20.89, 2, "m3", null, false], [2.3, 1, "", null, false], [2.49, 2, "m3", null, false], [5.0, 0, "", "형틀목공", false], [45.0, 0, "인", "형틀목공", false], [45.0, 0, "인", "보통인부", false], [23.39, 2, "m3", null, false], [0.3420265070542967, 9, "인", "보통인부", true], [832.0, 0, "", null, false], [0.71, 2, "", null, false], [17.22, 2, "m3", null, false], [15.04, 2, "", null, false], [0.5319148936170213, 9, "인", "보통인부", true], [3.4, 1, "m3", null, false], [0.01, 2, "인", "보통인부", false], [0.024, 3, "hr", null, false], [410.0, 0, "", null, false], [562.0, 0, "", null, false], [364.0, 0, "", null, false], [57.99, 2, "m3", null, false], [1.6, 1, "ton", null, false], [19.35, 2, "", null, false], [9.46, 2, "", null, false], [2.97, 2, "", null, false], [0.36, 2, "m3", null, false], [34.41, 2, "m3", null, false], [0.31, 2, "", null, false], [88.94, 2, "m3", null, false], [5.0, 0, "m", null, false], [8.0, 0, "m", null, false], [2.8, 1, "", "벌목부", false], [2.8, 9, "인", "벌목부", true], [0.66, 2, "", "보통인부", false], [0.66, 9, "인", "보통인부", true], [0.2, 1, "m3", null, false], [3.54, 2, "", null, false], [208.9, 1, "", null, false], [28.2, 1, "", null, false], [62.1, 1, "", null, false], [3.8, 1, "", null, false], [224.2, 1, "", null, false], [30.3, 1, "", null, false], [66.6, 1, "", null, false], [1.06, 2, "", "보통인부", false], [1.06, 9, "인", "보통인부", true], [15.0, 0, "sec", null, false], [11.34, 2, "m3", null, false], [0.7054673721340388, 9, "인", "보통인부", true], [16.2, 1, "m3", null, false], [513.0, 0, "", null, false], [856.0, 0, "", null, false], [170.0, 0, "m2", null, false], [0.0058823529411764705, 9, "인", "조경공", true], [4.0, 0, "인", "보통인부", false], [0.023529411764705882, 9, "인", "보통인부", true], [12.0, 0, "m3", null, false], [0.42, 2, "Ton", null, false], [1.9, 1, "Ton", null, false], [0.62, 2, "", null, false], [13.67, 2, "m3", null, false], [15.0, 0, "ton", null, false], [33.06, 2, "", null, false], [34.63, 2, "", null, false], [10.75, 2, "m3", null, false], [0.89, 2, "", null, false], [19.62, 2, "m3", null, false], [7.89, 2, "", null, false], [20.49, 2, "", null, false], [21.47, 2, "", null, false], [0.14, 2, "", null, false], [15.24, 2, "m3", null, false], [21.26, 2, "m3", null, false], [2.65, 2, "ton", null, false], [11.83, 2, "m3", null, false], [0.14, 2, "인", "석공", false], [0.05, 2, "인", "보통인부", false], [0.3, 1, "m3", null, false], [0.11, 2, "인", "석공", false], [0.04, 2, "인", "보통인부", false], [57.0, 0, "", null, false], [414.0, 0, "", null, false], [145.0, 0, "", null, false], [0.17, 2, "", null, false], [0.06, 2, "인", "석공", false], [0.02, 2, "인", "보통인부", false], [0.04, 2, "인", "배관공(수도)", false], [0.046, 3, "", null, false], [387.0, 0, "", null, false], [1.47, 2, "", null, false], [9.02, 2, "", null, false], [7.48, 2, "", null, false], [43.66, 2, "m3", null, false], [0.001, 3, "", null, false], [0.42, 2, "", null, false], [0.086, 3, "", null, false], [0.732, 3, "", null, false], [0.936, 3, "", null, false], [2.5, 1, "인", "석공", false], [0.007, 3, "m2", null, false], [39.1, 1, "", null, false], [0.192, 3, "kg", null, false], [0.12, 2, "m", null, false], [0.022, 3, "인", "석공", false], [0.088, 3, "", null, false], [0.075, 3, "m3", null, false], [23.8, 1, "", null, false], [18.3, 1, "", null, false], [40.42, 2, "", null, false], [0.56, 2, "", null, false], [1.318, 3, "", null, false], [957.0, 0, "", null, false], [8.0, 0, "Ton", null, false], [30.0, 0, "", null, false], [0.61, 2, "", null, false], [0.523, 3, "", null, false], [1.34, 2, "", null, false], [1.15, 2, "", null, false], [4.64, 2, "", null, false], [0.42, 2, "t", null, false], [0.5, 1, "t", null, false], [0.018, 3, "인", "보통인부", false], [0.07, 2, "hr", null, false], [0.01, 2, "hr", null, false], [0.096, 3, "hr", null, false], [0.089, 3, "매", null, false], [40.0, 0, "m2", null, false], [0.6666666666666666, 9, "인", "철근공", true], [0.2222222222222222, 9, "인", "보통인부", true], [6.5, 1, "Kg", null, false], [3.5, 1, "ton", null, false], [1.72, 2, "m3", null, false], [37.4, 1, "", null, false], [4.34, 2, "m3", null, false], [4.0, 0, "인", "포장공", false], [862.0, 0, "", null, false], [600.0, 0, "", null, false], [0.0031, 4, "", null, false], [36.0, 0, "", null, false], [181.0, 0, "", null, false], [43.5, 1, "", null, false], [36.3, 1, "", null, false], [18.02, 2, "m3", null, false], [0.4439511653718091, 9, "인", "보통인부", true], [415.0, 0, "", null, false], [692.0, 0, "", null, false], [0.06, 2, "인", "콘크리트공", false], [0.09, 2, "hr", null, false], [0.24, 2, "인", "콘크리트공", false], [13.0, 0, "mm", null, false], [4.0, 0, "ton", null, false], [0.75, 9, "인", "철근공", true], [8.0, 0, "Kg", null, false], [5.0, 0, "인", "철근공", false], [2.6, 1, "ton", null, false], [1.923076923076923, 9, "인", "철근공", true], [0.7692307692307692, 9, "인", "보통인부", true], [0.05, 2, "인", "비계공", false], [0.07, 2, "인", "콘크리트공", false], [0.1, 1, "hr", null, false], [48.6, 1, "", null, false], [4.0, 1, "", null, false], [5.9, 1, "본", null, false], [0.05, 2, "인", "형틀목공", false], [4.5, 1, "인", "특별인부", false], [0.2576, 4, "인", "특별인부", false], [0.1287, 4, "인", "보통인부", false], [0.0833, 4, "", null, false], [701.0, 0, "", null, false], [0.1, 1, "인", "형틀목공", false], [310.0, 0, "", null, false], [5.0, 0, "m3", null, false], [0.0045, 4, "m2", null, false], [0.02, 2, "m3", null, false], [0.21, 2, "인", "건축목공", false], [0.0024, 4, "m2", null, false], [0.005, 3, "m3", null, false], [0.16, 2, "인", "건축목공", false], [0.14, 2, "인", "보통인부", false], [2.5, 1, "kg", null, false], [10.0, 0, "kg", null, false], [18.0, 0, "kg", null, false], [0.2, 1, "kg", null, false], [2.0, 0, "인", "조경공", false], [0.6666666666666666, 9, "인", "조경공", true], [0.3333333333333333, 9, "인", "보통인부", true], [71.0, 0, "", null, false], [72.0, 0, "", null, false], [33.08, 2, "m3", null, false], [588.0, 0, "", null, false], [742.0, 0, "", null, false], [3.5, 1, "m3", null, false], [48.51, 2, "m3", null, false], [0.34, 2, "", null, false], [136.0, 0, "", null, false], [9.25, 2, "", null, false], [0.43, 2, "m3", null, false], [0.13, 2, "인", "석공", false], [0.64, 2, "", null, false], [0.35, 2, "m3", null, false], [1.65, 2, "", null, false], [38.27, 2, "m3", null, false], [0.20904102430101906, 9, "인", "보통인부", true], [508.0, 0, "", null, false], [641.0, 0, "", null, false], [61.43, 2, "m3", null, false], [960.0, 0, "", null, false], [316.0, 0, "", null, false], [0.6, 2, "", null, false], [3600.0, 0, "", null, false], [16.0, 0, "", null, false], [2500.0, 0, "", null, false], [2.5, 1, "", null, false], [0.018, 3, "", null, false], [0.07, 2, "hr/m3", null, false], [0.09, 2, "인", "콘크리트공", false], [0.31, 2, "hr/m3", null, false], [0.03, 2, "인", "콘크리트공", false], [0.31, 2, "인", "보통인부", false], [0.8, 1, ":작업효율", null, false], [0.00438, 5, "m3", null, false], [0.00288, 5, "m3", null, false], [0.03, 2, "KG", null, false], [4.0, 0, "EA", null, false], [0.0525, 9, "인", "건축목공", true], [0.0475, 9, "인", "보통인부", true], [0.00384, 5, "m3", null, false], [2.0, 0, "EA", null, false], [0.08, 9, "인", "건축목공", true], [0.07, 9, "인", "보통인부", true], [77.7, 1, "m2/hr", null, false], [5.0, 1, "m3/hr", null, false], [0.24, 2, "", "배관공(수도)", false], [6.0, 0, "m", null, false], [0.12, 2, "", "보통인부", false], [0.25, 2, "", null, false], [3.5, 1, "m3/hr", null, false], [0.006, 3, "본", null, false], [500.0, 0, "m/일", null, false], [0.002, 9, "인", null, true], [3000.0, 0, "l", null, false], [20.0, 0, "km/hr", null, false], [5.0, 0, "분(준비)", null, false], [10.0, 0, "분(흡입)", null, false], [4.0, 0, "분(대기)", null, false], [5500.0, 0, "", null, false], [30.0, 0, "l", null, false], [0.39, 2, "", "배관공(수도)", false], [0.19, 2, "", "보통인부", false], [11.0, 0, "", null, false], [25.0, 0, "", null, false], [0.1, 1, "m3/m2", null, false], [0.0022222222222222222, 9, "인", "보통인부", true], [225.0, 0, "m3/일", null, false], [0.33, 2, "kg", null, false], [0.11, 2, "개", null, false], [0.021, 3, "hr/m3", null, false], [0.009, 3, "hr/m3", null, false], [1.4, 2, "", null, false], [30.0, 0, "m2", null, false], [0.443, 3, "%", null, false], [0.16666666666666666, 9, "인", "형틀목공", true], [0.06666666666666667, 9, "인", "보통인부", true], [4.5, 1, "ton/일", null, false], [3.0, 0, "인", "철근공", false], [3.5, 1, "ton/일", null, false], [1.3333333333333333, 9, "인", "철근공", true], [0.4444444444444444, 9, "인", "보통인부", true], [480.0, 0, "", null, false], [0.055, 3, "", null, false], [4.0, 0, "", "보통인부", false], [0.36, 2, "", null, false], [1.17, 2, "", null, false], [52.0, 0, "%", null, false], [63.0, 0, "", "콘크리트공", false], [31.5, 9, "인", "콘크리트공", true], [63.0, 0, "", "보통인부", false], [31.5, 9, "인", "보통인부", true], [3.0, 0, "", "철근공", false], [4.5, 1, "", null, false], [6.0, 0, "", "철근공", false], [3.5, 1, "", null, false], [1.7142857142857142, 9, "인", "철근공", true], [0.5714285714285714, 9, "인", "보통인부", true], [6.5, 1, "", null, false], [5.0, 0, "", "철근공", false], [2.0, 9, "인", "철근공", true], [0.8, 9, "인", "보통인부", true], [8.0, 1, "", null, false], [1.03, 2, "", null, false], [0.327, 3, "", null, false], [0.038, 3, "", null, false], [50.0, 0, "", null, false], [0.004, 3, "", null, false], [0.0253, 4, "", null, false], [0.00055, 5, "", null, false], [0.00838, 5, "", null, false], [55.0, 0, "m/min", null, false], [70.0, 0, "m/min", null, false], [24.0, 0, "", null, false], [0.59, 2, "본", null, false], [0.06, 2, "%", null, false], [0.054, 3, "", null, false], [0.021, 3, "", null, false], [35.0, 0, "km/hr", null, false], [10.0, 0, "km/hr", null, false], [15.0, 0, "km/hr", null, false], [1.6, 2, "", null, false], [5.5, 9, "인", null, true], [0.26, 2, "", null, false], [0.1, 2, "", null, false], [0.2, 1, "인/m3", null, false], [0.0012, 4, "개", null, false], [0.0007, 4, "개", null, false], [0.0019, 4, "개", null, false], [0.013, 3, "hr/m3", null, false], [0.034, 3, "", null, false], [0.007, 3, "", null, false], [0.009, 3, "시간/m2", null, false], [0.2, 2, "", null, false], [46.0, 0, "", null, false], [0.00106, 9, "인", "보통인부", true], [0.00376, 5, "", null, false], [0.66, 2, "인", "보통인부", false], [10.0, 0, "본", null, false], [0.066, 9, "인", "보통인부", true], [0.25, 2, "시간", null, false], [0.5, 1, "m3/본", null, false], [50.0, 0, "m", null, false], [4.0, 1, "m", null, false], [0.51, 2, "인", null, false], [2.14, 2, "인", null, false], [0.0027, 4, "m3/개소", null, false], [0.0007, 4, "m3/개소", null, false], [0.16, 2, "인/개소", "건축목공", false], [0.14, 2, "인/개소", "보통인부", false], [0.15, 2, "인/m3", "콘크리트공", false], [0.27, 2, "인/m3", "보통인부", false], [5.4, 1, "m3/ hr", null, false], [0.003, 3, "매", null, false], [79.0, 0, "%", null, false], [25.0, 0, "m2", null, false], [0.16, 9, "인", "형틀목공", true], [6.5, 1, "kg/Ton", null, false], [3.5, 1, "Ton", null, false], [0.24, 2, "인", "배관공", false], [6.0, 0, "m/본", null, false], [0.04, 9, "인", "배관공", true], [0.25, 2, "시간/본", null, false], [0.32, 2, "인", "배관공", false], [0.05333333333333334, 9, "인", "배관공", true], [0.3, 1, "시간/본", null, false], [0.075, 3, "인/m2", "작업반장", false], [0.108, 3, "인/m2", "특별인부", false], [0.108, 3, "인/m2", "보통인부", false], [0.352, 3, "시간/m2", null, false], [6.0, 1, "m3/hr", null, false], [0.05, 3, "인", "특별인부", false], [2.0, 1, "m", null, false], [0.025, 9, "인", "특별인부", true], [0.036, 3, "인", "보통인부", false], [0.018, 9, "인", "보통인부", true], [0.17, 2, "인/m2", null, false], [0.575, 3, "", null, false], [2000.0, 0, "", null, false], [120.0, 0, "", null, false], [2300.0, 0, "kg/m3", null, false], [0.3, 1, "인/ m3", null, false], [0.07, 3, "hr", null, false], [0.05, 2, "인/m2", null, false], [0.02, 2, "인/m2", null, false], [0.12, 2, "시간/m2", null, false], [0.3, 1, "인/m3", null, false], [575.0, 0, "kg/m2", null, false], [0.002, 3, "kg/m2", null, false], [0.0586, 4, "kg/m2", null, false], [0.0011, 4, "인/m2", "특별인부", false], [0.01676, 5, "인/m2", "보통인부", false], [3.0, 0, "인", "포장공", false], [3.0, 0, "인", "보통인부", false], [350.0, 0, "m/일", null, false], [3000.0, 0, "L", null, false], [0.005714285714285714, 9, "인", "보통인부", true], [0.002857142857142857, 9, "인", "특별인부", true], [0.722, 3, "", null, false], [0.9, 1, "분", null, false], [1.9, 2, "", null, false], [75.0, 0, "", null, false], [4.0, 0, "본/일", null, false], [0.7, 2, "분", null, false], [0.13, 2, "인/m2", null, false], [0.04, 2, "인/m2", null, false], [0.25, 2, "시간/m2", null, false], [0.09, 2, "인/m3", "콘크리트공", false], [0.02, 2, "인/m3", "보통인부", false], [0.31, 2, "Hr/m3", null, false], [0.042, 3, ">", null, false], [0.21, 2, ">", null, false], [0.151, 3, "<인>>", null, false], [0.116, 3, "<인>>", null, false], [1.04, 2, " m", null, false], [2.0, 1, "인", "특별인부", false], [12.0, 0, "본/일", null, false], [22.0, 0, "본/일", null, false], [0.009, 3, "", null, false], [0.018, 3, "인/m3", null, false], [0.39, 2, "인", "배관공", false], [0.065, 9, "인", "배관공", true], [0.35, 2, "시간/본", null, false], [13.0, 0, "개소", null, false], [1.7, 2, "", null, false], [10.0, 0, "입 구", null, false], [15.0, 0, "입 구", null, false], [10.0, 0, "시 점", null, false], [5.0, 1, "", null, false], [3.4, 1, "", null, false], [0.7, 2, "", null, false], [0.07, 2, "", null, false], [4500.0, 0, "", null, false], [910.0, 0, "", null, false], [710.0, 0, "", null, false], [0.9, 2, "", null, false], [0.46, 2, "", null, false], [50.0, 0, "", "포장공", false], [250.0, 9, "인", "포장공", true], [50.0, 0, "", "보통인부", false], [250.0, 9, "인", "보통인부", true], [2.0, 0, "", "철근공", false], [1.0, 9, "인", "보통인부", true], [3.0, 1, "인", "콘크리트공", false], [0.31, 2, "Kg", null, false], [0.04, 2, "개", null, false], [0.004, 3, "인", "화약취급공", false], [0.014, 3, "", null, false], [1.425, 3, "", null, false], [2.0, 1, "", null, false], [77.7, 1, "", null, false], [30.0, 0, "km/hr", null, false], [1.5, 1, "분", null, false], [2.73, 2, "", null, false], [0.15, 2, "분", null, false], [110.0, 0, "", null, false], [0.00909090909090909, 9, "인", "특별인부", true], [0.00909090909090909, 9, "인", "보통인부", true], [17.0, 0, "", null, false], [720.0, 0, "", null, false], [27.4, 1, "km(포 장)", null, false], [0.5, 2, "", null, false], [1.43, 2, "ton", null, false], [4.5, 1, "톤", null, false], [0.025, 3, "ton", null, false], [0.4, 2, "", null, false], [5.0, 9, "인", null, true], [0.004, 9, "인", "특별인부", true], [0.004, 9, "인", "보통인부", true], [3.0, 1, "(인/일)", null, false], [23.0, 0, "(m3/일)", null, false], [2.0, 0, "인", null, false], [0.055, 9, "인", null, true], [0.006666666666666667, 9, "인", null, true], [4.0, 1, "인", null, false], [0.02666666666666667, 9, "인", null, true], [0.1, 9, "인", null, true], [0.025, 9, "인", null, true], [0.11428571428571428, 9, "인", null, true], [0.02857142857142857, 9, "인", null, true], [0.08, 9, "인", null, true], [2.0, 1, "인", null, false], [0.04, 9, "인", null, true], [0.91, 2, "", null, false], [190.0, 0, "", null, false], [0.0016666666666666668, 9, "인", null, true], [40.0, 0, "ea", null, false], [0.025, 9, "인", "용접공", true], [3.0, 0, "", "특별인부", false], [0.075, 9, "인", "특별인부", true], [0.05, 9, "인", "보통인부", true], [200.0, 0, "m", null, false], [10.0, 0, "열", null, false], [4.0, 0, "", "특별인부", false], [0.02, 9, "인", "특별인부", true], [0.01, 9, "인", "보통인부", true], [360.0, 0, "", null, false], [5.2, 1, "", null, false], [0.011111111111111112, 9, "인", "특별인부", true], [0.005555555555555556, 9, "인", "보통인부", true], [0.00055, 5, "인", "특별인부", false], [0.00838, 5, "인", "보통인부", false], [12.0, 0, "분", null, false], [2.0, 0, "대", null, false], [125.0, 0, "", null, false], [33.0, 0, "", null, false], [0.008, 9, "인", null, true], [24.0, 0, "톤", null, false], [1.1, 1, "톤/회 적용", null, false]] \ No newline at end of file diff --git a/main.py b/main.py index e9f2b698c..cb2670172 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ import asyncio import logging import os import time +import threading from contextlib import asynccontextmanager from pathlib import Path from typing import Any @@ -97,6 +98,7 @@ from E03_StabSection.E03_StabSection_Router_Prob import router as e03_stab_prob_ from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Hopyo_Router import router as m02_hopyo_router +from M02_MasterTemplete import M02_San_Basis_Index as san_basis_index from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router @@ -281,6 +283,8 @@ async def lifespan(app: FastAPI): cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) temp_cleanup_task = asyncio.create_task(cleanup_expired_temp_uploads_loop()) + # 793-3a 근거 제안 M01 값 역색인 — 뒤에서 한 번 지어 둠(M01 지문 바뀌면 다음 부름에서 다시) + threading.Thread(target=san_basis_index.warm, daemon=True).start() yield diff --git a/resources/tester/test_793_3a_san_basis.py b/resources/tester/test_793_3a_san_basis.py new file mode 100644 index 000000000..df521c8aa --- /dev/null +++ b/resources/tester/test_793_3a_san_basis.py @@ -0,0 +1,183 @@ +"""793-3a 지킴 — 근거 제안 엔진(`M02_San_Basis*`) · 실무 여덟 파일 산근 459 블록. + +판정 수 ≥ 분석 648-D2 합친 길(기초 짚음 260 · 옵션 전부 52 · 품 검산 맞음 141 · 없음 94) · +손 확인 견본(합판거푸집 넷 · 철근 Type-Ⅱ 가공 + 조립 · 레미콘 소량감률 50 · 터파기 #51 · +야면석소운반 GC001140 석재류 · 불량 · 큰돌쌓기 ÷10 근접) · 지어 넣은 값 0 · 보정 자료 = 여덟 파일 · +저장본 길(임시 폴더). 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip) · +STmate 숨은 G 식 = 올림 길과 같은 `M02_San_Basis_Store.hidden`. +""" + +from __future__ import annotations + +import json +import time +from collections import Counter +from functools import lru_cache + +import helper_m02_hopyo_golden as golden +import pytest + +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_San_Basis as sb +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_Store as store +from M02_MasterTemplete import M02_San_Book as san_book + +WAYS = { + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "V", + "검산", + "검산(같은 품 여럿)", + "근접", + "근접(같은 품 여럿)", +} + + +@lru_cache(maxsize=1) +def books() -> list[tuple]: + """[(파일, HP 꼴 책 + 숨은식)] — 없는 파일이면 skip.""" + out = [] + for tag, path in golden.FILES.items(): + book = golden.read(tag) if path.exists() else None + if book is None: + pytest.skip(f"실무 엑셀 없음 {tag}") + sheets = san_book.sheets(path.name, path.read_bytes()) if book["꼴"] == "STmate" else {} + out.append((tag, {**book, "숨은식": store.hidden(book, sheets)})) + return out + + +@lru_cache(maxsize=1) +def results() -> tuple[dict, float]: + sb.ctx() + t0 = time.time() + got = {} + for tag, book in books(): + for blk in book["단산"]: + got[f"{tag}#{blk['번']}"] = store.one(book, int(blk["번"])) + return got, time.time() - t0 + + +def test_counts_at_least_d2(): + got, sec = results() + kind = Counter(r["꼴"] for r in got.values()) + tier = Counter(r["단"] for r in got.values()) + same = sum( + 1 + for r in got.values() + if r["꼴"].startswith("확정") + and "다름" not in (st := [sb.checked(s) for s in sb.chosen_units(r)]) + and "같음" in st + ) + print(f"\n블록 {len(got)} · 꼴 {dict(kind)} · 단 {dict(tier)} · 품 같음 {same} · {sec:.1f} 초") + assert len(got) == 459 + assert sum(v for k, v in kind.items() if k.startswith("확정")) >= 260 + assert kind["확정·전부"] >= 52 + assert same >= 141 + assert tier["없음"] <= 94 and kind["없음"] <= 94 + + +def _first(bid: str) -> dict: + return results()[0][bid]["근거후보"] + + +def test_plywood_four_same(): + for bid in ("북부청임도#24", "북부청사방#66", "거창#41", "동부청영월#1841"): + (c,) = _first(bid) + assert (c["기초"], c["단"], c["품검산"]) == ("GC000211", "확정", "같음"), bid + assert {c["옵션"][k] for k in ("사용횟수", "유형")} == {"4회", "보통"}, bid + assert {"높이감률", "구멍땜"} <= set(c["입력필요"]), bid # 글에 없는 감률 = 입력 필요 + + +def test_rebar_type2_cut_and_join(): + got = {c["기초"]: c for c in _first("북부청임도#72")} + assert set(got) == {"GC000206", "GC000207"} + assert got["GC000206"]["옵션"]["Type"] == got["GC000207"]["옵션"]["Type"] == "Type-Ⅱ" + assert all(c["단"] == "확정" for c in got.values()) + + +def test_remicon_small_50(): + (c,) = _first("북부청임도#21") + assert c["기초"] == "GC000198" and c["단"] == "확정" + assert c["옵션"]["소량감률"] == 50 and c["옵션길"]["소량감률"] == "B5" + + +def test_excavation_51(): + (c,) = _first("북부청임도#51") + assert (c["기초"], c["단"], c["옵션"]["Type"], c["옵션"]["깊이"]) == ( + "GC000089", + "확정", + "TypeⅢ", + "5m 이하", + ) + + +def test_rubble_carry_gc001140(): + r = results()[0]["동부청영월#1466"] + c = r["근거후보"][0] + assert r["단"] == "사람" and c["기초"] == "GC001140" + assert c["옵션"] == {"재료갈래": "석재류", "운반로": "불량"} + assert set(c["옵션길"].values()) == {"V"} + + +def test_big_stone_near_unit_basis(): + r = results()[0]["북부청임도#32"] + (c,) = r["근거후보"] + assert (r["단"], c["기초"], c["단"], c["품검산"]) == ("근접", "GF000226", "근접", "다름") + assert {t for t, _ in c["까닭"]} == {"단위 기준 차이"} and "÷10" in c["까닭"][0][1] + + +def test_no_made_up_values(): + """옵션마다 출처 꼬리표 · 입력 필요 · 0 간주 칸은 옵션에 없음 · + 없음 단은 근거후보 없이 보강 까닭만.""" + for bid, r in results()[0].items(): + for c in r["근거후보"]: + assert set(c["옵션"]) <= set(c.get("옵션길") or {}), bid + assert set((c.get("옵션길") or {}).values()) <= WAYS, bid + assert not set(c["옵션"]) & set(c["입력필요"]), bid + assert all(v is not None for v in c["옵션"].values()), bid + assert c["단"] in ("확정", "근접", "사람") + if r["단"] == "없음": + assert r["근거후보"] == [] and r["보강"]["까닭"], bid + + +def test_corpus_is_eight_files(): + """보정 자료 = 여덟 파일 상수(일반 뺌) — 뽑기 규칙을 바꾸면 다시 지어 넣음.""" + preps = [ + bc.prep(blk, book["꼴"], (book["숨은식"] or {}).get(str(blk["번"]))) + for _, book in books() + for blk in book["단산"] + ] + shipped = json.loads(bi.CORPUS.read_text(encoding="utf-8")) + assert sorted(map(json.dumps, bc.corpus(preps))) == sorted(map(json.dumps, shipped)) + + +def test_store_round_trip(tmp_path): + """임시 폴더 HP → 뒤 셈 저장본 · 목록 · 블록 하나 · 끝내기.""" + _, book = books()[0] + small = {**book, "단산": [b for b in book["단산"] if b["번"] in (21, 51)]} + row = hopyo_store.save(tmp_path, "시험.xls", small, {"id": 0, "이름": "시험"}) + key = row["키"] + assert store.solved(tmp_path, key) is None + got = store.solve_all(tmp_path, key) + assert set(got) == {"21", "51"} and store.solved(tmp_path, key) == json.loads(json.dumps(got)) + assert store.listing(tmp_path, key)["셈중"] is False + assert store.block(tmp_path, key, 51)["근거후보"][0]["기초"] == "GC000089" + with pytest.raises(Exception): + store.block(tmp_path, key, 999) + store.forget(tmp_path, key) + assert store.solved(tmp_path, key) is None + judge.forget(tmp_path, key) + + +def test_hidden_g_lines(): + book = {"꼴": "STmate", "단산": [{"번": "3", "행": [2, 4]}]} + rows = [[None] * 7, [None] * 6 + ["_x0007_L=20"], [None] * 6 + ["_x0007_"], [None] * 6 + ["x"]] + assert store.hidden(book, {store.SHEET: rows}) == {"3": ["L=20"]} + assert store.hidden({"꼴": "엑셀꼴", "단산": []}, {}) == {}