"""App check ("앱 확인") of the input bundles — stage B of the 입력 묶음 확인·확정 개편 (2026-10-16). The app compares every bundle of params.json / expected.json with what it can read independently and reports, per bundle, one of: g 일치 / w 주의 / b 불일치 / n 근거 없음 / x 해당 없음. The app never confirms anything: a person confirms (D1), the app only supplies the evidence. "일치" means "the independent source and the value agree", not "the drawing is right" (기획서 4.3). Checks (C1~C8, 기획서 4.3): C1 출처 있음 the bundle (or its `_src` sibling) records where its values come from C2 도면 재대조 table candidates (ELEVATION / 솔플레이트 / 철근재료표) read from the drawing texts vs the current values [strong] C2b 표 밖 치수 재대조 anchors followed into the drawing (engine/anchors.py): A candidates equal / differ, B only warn; own-drawing anchors are no evidence [strong] C3 템플릿 원본 잔존 the bundle still equals the protected template's original and nothing independent supports it C4 내부 정합 bar counts of the generated lists vs the schedule counts in expected [counts only] C5 params ↔ expected 독립 the expected source notes are not copies of the params notes C6 범위·단위 sizes > 0, EL / z in a plausible range, no NaN C7 변수 등급 위반 F bundles / fixed fields unchanged against the template original (or an edit reason is logged) C8 재생성 일치 V3 bundles: regenerated list equals the current one — no regeneration script is connected yet (해당 없음, 독립성 낮음) C9 params↔expected 일관성 values that describe the same quantity in both files agree (engine/consistency.py; 2026-10-20) — finds stale values before a gate is run; not independent evidence (b when they differ, g otherwise, never a strength) C3b 필드 단위 원본 잔존 fields of a CHANGED bundle that still equal the pier's creation snapshot while elevation-like siblings changed (origin_values.json; 2026-10-20) Status per bundle: any failed check -> b; template original without independent support -> n; strong support (C2) -> g (w when a check warns); counts only -> w ("수량만"); F bundle unchanged -> g; nothing -> n; reference-only / human decision records -> x. Everything is derived from the data files and the drawing text index; no gate is run. """ import json import math import re from engine import consistency, reader, variability VERSION = "B3" ST_NAME = {"g": "일치", "w": "주의", "b": "불일치", "n": "근거 없음", "x": "해당 없음"} DECISION_KEYS = {("expected", "planned_fail"), ("expected", "rebar_palette"), ("expected", "tol_mm")} SIZE_KEYS = re.compile(r"^(size_[xy]|thickness|diameter|height|length|width_y|plan_length|top_length|half|count_[xy]|nx|ny|dia|" r"half_.*|bottom_flat_half|tip_half|top_half|soffit_rise)$") SIZE_IN = {"d": ("column", "piles"), "dx": ("footing", "lean"), "dy": ("footing", "lean")} # short names only where they are sizes NOT_SIZE = ("translation_checks", "pitch", "negative", "chain") # directional / check values may be negative or 0 Z_KEYS = re.compile(r"^(z|z_.*|el_x|z0|seat_z)$") Z_RANGE = (-20000.0, 400000.0) # mm, EL -20 m .. 400 m def chk(cid, name, st, text, pos="", strength=""): return {"id": cid, "name": name, "st": st, "text": text, "pos": pos, "strength": strength} def _walk(v, path=()): """(path, leaf) of every scalar in a nested value.""" if isinstance(v, dict): for k, x in v.items(): yield from _walk(x, path + (str(k),)) elif isinstance(v, list): for i, x in enumerate(v): yield from _walk(x, path + (str(i),)) else: yield path, v def _src_texts(v): """All source notes (`src`, `*_src`, `sources`) inside a bundle value, flattened to strings.""" out = [] def rec(x, key=""): if isinstance(x, dict): for k, y in x.items(): rec(y, k) elif isinstance(x, list): for y in x: rec(y, key) elif isinstance(x, str) and (key == "src" or key.endswith("_src") or key == "sources") and x.strip(): out.append(x.strip()) rec(v) return out def _count_bars(value, mark): return sum(1 for b in value.get("bars", []) if b.get("mark") == mark) def _planned(expected, gate, mark, what="bar count vs schedule"): for pf in expected.get("planned_fail", []): if pf.get("gate") == gate and pf.get("item", "").startswith(f"{mark} {what}"): return pf return None def count_pairs(params, expected): """Counts derived from the generated rebar lists vs the schedule counts in expected: {gate: [(label, mark, got, want)]}.""" out = {"G2": [], "G3": [], "G4": []} cr = params.get("column_rebar") or {} sn = (expected.get("G2") or {}).get("schedule_n") if cr.get("form") == "ring" and isinstance(sn, dict): try: out["G2"].append(("주철근", cr["main"]["mark"], cr["main"]["count"], sn.get(cr["main"]["mark"]))) for z in cr["hoops"]["zones"]: out["G2"].append(("띠철근", z["mark"], len(z["levels"]), sn.get(z["mark"]))) t = cr["ties"] out["G2"].append(("크로스타이", t["mark"], len(t["levels"]) * len(t["angles_deg"]), sn.get(t["mark"]))) except (KeyError, TypeError): out["G2"] = [] for gate, key in (("G3", "footing_rebar"), ("G4", "coping_rebar")): fr = params.get(key) or {} sch = (expected.get(gate) or {}).get("schedule") if not isinstance(sch, dict) or not fr: continue if fr.get("form") == "polylines": for m, v in sch.items(): if isinstance(v, list) and len(v) == 2 and m in {b.get("mark") for b in fr.get("bars", [])} | set(sch): out[gate].append(("철근 수", m, _count_bars(fr, m), v[1])) elif fr.get("form") == "mats" and gate == "G3": for g in fr.get("groups", []): out[gate].append(("위치 목록", g["mark"], len(g["pos"]), (sch.get(g["mark"]) or [None, None])[1])) h, v = fr.get("hoops"), fr.get("verticals") if h: out[gate].append(("띠철근 단 수", h["mark"], len(h["levels"]), (sch.get(h["mark"]) or [None, None])[1])) if v: n = len(v["points"]) if "points" in v else sum(1 for i, _ in enumerate(v["grid_x"]) for j, _ in enumerate(v["grid_y"]) if (i + j) % 2 == v["parity"]) out[gate].append(("수직근", v["mark"], n, (sch.get(v["mark"]) or [None, None])[1])) return {g: p for g, p in out.items() if p} def bundles_of_gate(gate): return {"G2": [("params", "column_rebar"), ("expected", "G2")], "G3": [("params", "footing_rebar"), ("expected", "G3")], "G4": [("params", "coping_rebar"), ("expected", "G4")]}[gate] def anchor_check(rows, missing, key_prefix): """C2b of one bundle from the anchor rows of its fields (rows of engine.anchors.follow joined with the current values).""" mine = [r for r in rows if r["field"].startswith(key_prefix) and not r["self_source"]] miss = [f for f in missing if f.startswith(key_prefix)] if not mine: return None a_diff = [r for r in mine if r["grade"] == "A" and not r["same"]] b_diff = [r for r in mine if r["grade"] == "B" and not r["same"]] a_same = [r for r in mine if r["grade"] == "A" and r["same"]] b_same = [r for r in mine if r["grade"] == "B" and r["same"]] anchored = len(mine) + len(miss) cover = (len(a_same) + len(b_same)) / anchored if anchored else 0 if a_diff: r = a_diff[0] d = "; ".join(f"{x['field'].split(':', 1)[1]}: 현재 {x['current']} ≠ 도면 {x['value']} ('{x['text']}' {x['dwg']})" for x in a_diff[:3]) return chk("C2b", "표 밖 치수 재대조", "b", f"A등급 후보 {len(a_diff)}건이 현재 값과 다르다 — {d}", pos=f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})", strength="S") if b_diff: r = b_diff[0] d = "; ".join(f"{x['field'].split(':', 1)[1]}: 현재 {x['current']} ≠ 도면 {x['value']}" for x in b_diff[:3]) return chk("C2b", "표 밖 치수 재대조", "w", f"B등급 후보 {len(b_diff)}건이 현재 값과 다르다(후보가 틀렸을 수도 있다) — {d}", pos=f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})") if a_same and cover >= 0.6: r = a_same[0] return chk("C2b", "표 밖 치수 재대조", "g", f"A등급 {len(a_same)}건·B등급 {len(b_same)}건이 현재 값과 같다 (앵커가 있는 {anchored}개 필드의 {cover:.0%})", pos=f"{r['dwg']} '{r['text']}' ({r['x']:.0f}, {r['y']:.0f})", strength="S") if a_same or b_same: return chk("C2b", "표 밖 치수 재대조", "w", f"앵커 필드 {anchored}개 중 {len(a_same) + len(b_same)}개만 같다 (A {len(a_same)} · B {len(b_same)}) — 대조 범위 {cover:.0%}") return None HEIGHT_TOKENS = {"z", "z0", "z_bottom", "z_top", "z_flat", "el_x", "datum_el", "step_top", "tier_z", "loop_z", "levels", "hook_leg_z", "straight_from", "c10_bottom", "c15_top", "c16_top", "top_right", "top_left", "tiers"} def height_like(path): """True for a leaf that is an elevation (EL in mm): z-like keys, level lists, the z of a point [x, z], the z of an outline point.""" last = path[-1] if last in HEIGHT_TOKENS or last.startswith("z_") or last.endswith(("_z", "_top", "_bottom")): return True if any(t in ("step_top", "tier_z", "loop_z", "levels") for t in path): return True if last == "1" and any(t in ("points", "hooks_up", "coping_outline", "top_right", "top_left") for t in path): return True if last == "0" and "c14_levels" in path: return True return False EL_MIN, EL_MAX = 10000.0, 60000.0 # mm: elevation-like numbers (EL 10 m .. 60 m) STALE_MIN_CHANGED = 3 # at least this many elevation-like siblings changed against the snapshot def stale_fields_check(which, key, value, origin_values, m): """C3b: in a bundle that was changed against the pier's creation snapshot, elevation-like numbers that still equal the snapshot value are suspicious (the drawing moved the other heights but this one was left behind, e.g. a P2 coordinate in a P3 point list). Warns (w), never fails.""" if not origin_values or which not in origin_values or key not in origin_values[which]: return chk("C3b", "필드 단위 원본 잔존", "x", "교각 생성 시점의 값 기록(origin_values.json)이 없어 필드 단위로 비교하지 못했다") old = dict(_walk(origin_values[which][key])) new = dict(_walk(value)) el = [p for p, v in new.items() if isinstance(v, (int, float)) and not isinstance(v, bool) and EL_MIN <= abs(v) <= EL_MAX and p in old and height_like(p)] if not el or old == new: return chk("C3b", "필드 단위 원본 잔존", "x" if old == new else "g", "묶음 전체가 생성 시점 그대로라 C3에서 본다" if old == new else "높이로 보이는 값이 없다") changed = [p for p in el if old[p] != new[p]] kept = [p for p in el if old[p] == new[p]] if len(changed) < STALE_MIN_CHANGED: return chk("C3b", "필드 단위 원본 잔존", "g", f"높이로 보이는 값 {len(el)}개 중 바뀐 것이 {len(changed)}개뿐이라 비교하지 않는다") if kept: names = ", ".join(".".join(p) + f" = {new[p]:g}" for p in kept[:4]) return chk("C3b", "필드 단위 원본 잔존", "w", f"바뀐 묶음인데 높이로 보이는 값 {len(el)}개 중 {len(kept)}개가 생성 시점 값 그대로다 — 도면과 대조: {names}") return chk("C3b", "필드 단위 원본 잔존", "g", f"높이로 보이는 값 {len(el)}개가 모두 생성 시점과 다르다") def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None, anchor_rows=None, origin_values=None): """-> {"params:key": {"st", "checks", "summary"}} for every bundle. meta[bundle] = variability.classify result + "origin" (True / False / None); origin_fixed[bundle] = fixed_view at copy time.""" origin_fixed = origin_fixed or {} edit_log = edit_log or [] template_values = template_values or {} arows, amiss = (anchor_rows or ([], []))[:2] docs = {"params": params, "expected": expected} # ---- C2: table candidates of the drawing texts vs the current values c2 = {} tables_ok = False if texts: tabs = reader.table_candidates(texts) cands = reader.field_candidates(tabs) tables_ok = bool(cands) for r in reader.proposals(cands, params, expected): c2.setdefault(f"{r['file']}:{r['path'][0]}", []).append(r) # ---- C9: params <-> expected consistency (rules of engine/consistency.py), per bundle of either file cons = consistency.check(params, expected) cons_by = {} for r in cons: cons_by.setdefault("params:" + consistency.bundles_of(r["p"]), []).append(r) cons_by.setdefault("expected:" + consistency.bundles_of(r["e"]), []).append(r) # ---- C4: counts per gate pair pairs = count_pairs(params, expected) out = {} for which in ("params", "expected"): for key, value in docs[which].items(): b = f"{which}:{key}" m = meta.get(b) or variability.classify(which, key, value) checks = [] if m.get("info"): out[b] = {"st": "x", "checks": [chk("C1", "참고 정보", "x", "게이트가 쓰지 않는 참고 정보라 확인하지 않는다")], "summary": "참고 정보"} continue # C1 srcs = _src_texts(value) + (_src_texts(params.get(key + "_src")) if which == "params" and key + "_src" in params else []) if key + "_src" in docs[which] and isinstance(docs[which][key + "_src"], str): srcs.append(docs[which][key + "_src"]) if m["grade"] == "F": checks.append(chk("C1", "출처 있음", "x", "고정(F) 항목: 값은 템플릿이 정한다")) elif srcs: checks.append(chk("C1", "출처 있음", "g", f"값의 출처 기록이 {len(srcs)}건 있다", pos=srcs[0][:80])) else: checks.append(chk("C1", "출처 있음", "n", "값의 출처 기록이 없다 (일반 출처: " + " · ".join(m["source"]) + ")")) # C2 rows = c2.get(b) if rows: bad = [r for r in rows if not r["same"]] src0 = rows[0]["src"] if bad: d = "; ".join(f"{'.'.join(str(x) for x in r['path'])}: 현재 {r['current']} ≠ 표 {r['candidate']}" for r in bad[:3]) checks.append(chk("C2", "도면 재대조", "b", f"표 후보 {len(rows)}건 중 {len(bad)}건이 다르다 — {d}", pos=bad[0]["src"], strength="S")) else: checks.append(chk("C2", "도면 재대조", "g", f"표 후보 {len(rows)}건이 현재 값과 모두 같다", pos=src0, strength="S")) elif not texts and "표" in m["source"]: checks.append(chk("C2", "도면 재대조", "n", "도면 문자 색인이 없어 표와 대조하지 못했다")) elif texts and "표" in m["source"] and not tables_ok: checks.append(chk("C2", "도면 재대조", "n", "도면에서 표를 인식하지 못했다")) else: checks.append(chk("C2", "도면 재대조", "x", "표 후보가 없는 묶음(표 밖 치수는 C단계 몫)")) # C2b ac = anchor_check(arows, amiss, f"{which}:{key}.") if arows else None if ac: checks.append(ac) # C4 gate = next((g for g, bl in ((g, bundles_of_gate(g)) for g in ("G2", "G3", "G4")) if (which, key) in bl), None) if gate and gate in pairs: bad, soft, ok_n = [], [], 0 for label, mark, got, want in pairs[gate]: if want is None: continue if got == want: ok_n += 1 continue pf = _planned(expected, gate, mark) (soft if pf else bad).append(f"{mark} {label} {got} ≠ 재료표 {want}" + (f" (계획 FAIL: {pf.get('approved', '')})" if pf else "")) if bad: checks.append(chk("C4", "내부 정합", "b", "수량이 재료표와 다르다 — " + "; ".join(bad[:3]), strength="C")) elif soft: checks.append(chk("C4", "내부 정합", "w", "수량이 재료표와 다르지만 계획된 예외다 — " + "; ".join(soft[:3]), strength="C")) else: checks.append(chk("C4", "내부 정합", "g", f"생성 목록 수량 {ok_n}건이 재료표 수량과 같다 (위치·좌표는 확인하지 않음)", strength="C")) else: checks.append(chk("C4", "내부 정합", "x", "수량 대조 대상이 아니다")) # C5 if which == "expected" and key in ("G1", "G2", "G3", "G4", "G5"): es = _src_texts(value) ps = {t for k in params for t in _src_texts(params[k])} if not es: checks.append(chk("C5", "params와 독립", "n", "expected에 출처 기록이 없어 독립 입력인지 알 수 없다")) elif any(len(t) > 12 and t in ps for t in es): checks.append(chk("C5", "params와 독립", "w", "expected의 출처 문구가 params와 같다 — 독립 입력인지 확인")) else: checks.append(chk("C5", "params와 독립", "g", "expected의 출처 문구가 params와 다르다")) else: checks.append(chk("C5", "params와 독립", "x", "해당 없음")) # C6 bad6, warn6 = [], [] for path, v in _walk(value): name = path[-1] if path else "" if isinstance(v, float) and (math.isnan(v) or math.isinf(v)): bad6.append(f"{'.'.join(path)} = {v}") elif isinstance(v, (int, float)) and not isinstance(v, bool): is_size = (SIZE_KEYS.match(name) or (name in SIZE_IN and len(path) > 1 and path[-2] in SIZE_IN[name])) and not any(p.startswith(NOT_SIZE) for p in path) if is_size and v <= 0: bad6.append(f"{'.'.join(path)} = {v} (0 이하)") elif Z_KEYS.match(name) and not (Z_RANGE[0] <= v <= Z_RANGE[1]): warn6.append(f"{'.'.join(path)} = {v} (EL 범위 밖)") if bad6: checks.append(chk("C6", "범위·단위", "b", "; ".join(bad6[:3]))) elif warn6: checks.append(chk("C6", "범위·단위", "w", "; ".join(warn6[:3]))) else: checks.append(chk("C6", "범위·단위", "g", "크기·EL이 상식 범위 안에 있다")) # C7 ofix = origin_fixed.get(b) logged = [e for e in edit_log if e.get("section") == b] if m["grade"] == "F": if m.get("origin") is None: tv = template_values.get(b) or {} now = json.dumps(value, sort_keys=True, ensure_ascii=False) same = sorted(t for t, v in tv.items() if v == now) if key == "pier_id": checks.append(chk("C7", "변수 등급 위반", "x", "교각 식별자라 비교하지 않는다")) elif same: checks.append(chk("C7", "변수 등급 위반", "g", f"템플릿 복사 기록은 없지만 보호 템플릿 {len(same)}종({', '.join(same[:3])})과 같은 값이다", strength="F")) elif tv: checks.append(chk("C7", "변수 등급 위반", "w", "템플릿 복사 기록이 없고 어느 보호 템플릿과도 같지 않다 — 새 형식인지 확인")) else: checks.append(chk("C7", "변수 등급 위반", "n", "템플릿 원본 기록이 없어 고정 항목을 비교하지 못했다")) elif m["origin"]: checks.append(chk("C7", "변수 등급 위반", "g", "고정(F) 항목이 템플릿 원본과 같다", strength="F")) elif logged: checks.append(chk("C7", "변수 등급 위반", "w", f"고정(F) 항목이 원본과 다르다 — 사유 기록: {logged[-1]['reason']}")) else: checks.append(chk("C7", "변수 등급 위반", "b", "고정(F) 항목이 원본과 다르고 사유 기록이 없다")) elif m["fixed_fields"] and ofix is not None and current_fixed is not None: diff = [p for p in set(ofix) | set(current_fixed.get(b, {})) if ofix.get(p) != current_fixed.get(b, {}).get(p)] if not diff: checks.append(chk("C7", "변수 등급 위반", "g", "고정 필드가 템플릿 원본과 같다")) elif logged: checks.append(chk("C7", "변수 등급 위반", "w", f"고정 필드({', '.join(sorted(diff))})가 원본과 다르다 — 사유: {logged[-1]['reason']}")) else: checks.append(chk("C7", "변수 등급 위반", "b", f"고정 필드({', '.join(sorted(diff))})가 원본과 다르고 사유 기록이 없다")) else: checks.append(chk("C7", "변수 등급 위반", "x", "고정 필드 비교 대상이 없다")) # C8 checks.append(chk("C8", "재생성 일치", "x", "생성 스크립트가 앱에 연결되어 있지 않다 (재생성 일치는 독립성이 낮아 근거로 쓰지 않는다)" if m["grade"] == "V3" else "V3 묶음이 아니다")) # C9 rel = cons_by.get(b, []) badc = [r for r in rel if not r["ok"]] if badc: d = "; ".join(f"{r['text']}: params {r['p']} = {r['pv']:g} ≠ expected {r['e']} = {r['ev']:g}" if isinstance(r["pv"], float) and isinstance(r["ev"], float) else f"{r['text']}: {r['pv']} / {r['ev']}" for r in badc[:3]) checks.append(chk("C9", "params↔expected 일관성", "b", f"서로 같아야 할 값 {len(rel)}건 중 {len(badc)}건이 다르다 — {d}")) elif rel: checks.append(chk("C9", "params↔expected 일관성", "g", f"서로 같아야 할 값 {len(rel)}건이 params와 expected에서 같다 (같은 도면 읽기에서 나왔다면 독립 근거는 아님)")) else: checks.append(chk("C9", "params↔expected 일관성", "x", "연동 값 규칙이 없는 묶음")) # C3b: fields of a changed bundle that still equal the creation snapshot checks.append(stale_fields_check(which, key, value, origin_values, m)) # C3 (needs C2 / C4 results) strong = any(c["id"] in ("C2", "C2b") and c["st"] == "g" for c in checks) if m["grade"] == "F": checks.append(chk("C3", "템플릿 원본 잔존", "x", "고정(F) 항목은 C7에서 본다")) elif m.get("origin") is None: checks.append(chk("C3", "템플릿 원본 잔존", "x", "템플릿 복사 기록이 없어 원본과 비교하지 못했다")) elif m["origin"]: checks.append(chk("C3", "템플릿 원본 잔존", "g" if strong else "n", "원본과 같지만 도면(표·표 밖 치수)과 일치한다" if strong else "현재 값이 템플릿 원본 그대로이고 새 도면 근거가 없다")) else: checks.append(chk("C3", "템플릿 원본 잔존", "g", "원본과 다른 값이 들어 있다")) order = ("C1", "C2", "C2b", "C3", "C3b", "C4", "C5", "C6", "C7", "C8", "C9") checks.sort(key=lambda c: order.index(c["id"])) st = combine(checks, m, (which, key) in DECISION_KEYS) out[b] = {"st": st, "checks": checks, "summary": summarize(st, checks)} return out def combine(checks, m, decision): by = {c["id"]: c for c in checks} if decision: return "x" if any(c["st"] == "b" for c in checks): return "b" if by["C3"]["st"] == "n": return "n" warn = any(c["st"] == "w" for c in checks) if m["grade"] == "F": c7 = by["C7"] return "w" if warn else ("g" if c7["st"] == "g" else "n") if any(c["strength"] == "S" and c["st"] == "g" for c in checks): return "w" if warn else "g" if any(c["strength"] == "C" and c["st"] in ("g", "w") for c in checks): return "w" return "n" def summarize(st, checks): """One sentence for the table: the check that decides the status.""" if st == "x": return "사람 결정 기록 또는 참고 정보 — 앱이 도면과 대조하지 않는다" pick = {"b": [c for c in checks if c["st"] == "b"], "w": [c for c in checks if c["st"] == "w"] or [c for c in checks if c["strength"] == "C" and c["st"] == "g"], "g": [c for c in checks if c["strength"] in ("S", "F") and c["st"] == "g"], "n": [c for c in checks if c["id"] in ("C3", "C7", "C2") and c["st"] == "n"]}[st] if pick: c = pick[0] return f"{c['id']} {c['text']}" + (" — 수량만 확인, 위치·좌표는 도면과 직접 대조" if st == "w" and c["strength"] == "C" and c["st"] == "g" else "") return "앱이 독립적으로 대조할 근거가 없다 — 도면과 직접 대조한 뒤 사유와 함께 확정" def counts(results, required): """Per-status counts of the required bundles.""" c = {"g": 0, "w": 0, "b": 0, "n": 0, "x": 0} for b in required: r = results.get(b) if r: c[r["st"]] += 1 return c