"""Review report draft (Korean Markdown) from gate results, drawing tables and approval records. Chapters follow the requirements review (기획 v0.1.01 §3.7): 개요, 오류·불일치, 개선방안, 추가 사항, 누락, 철근재료표(원본 vs 모델), 철근상세, plus 검증 방법. Verdict vocabulary is fixed (오류·불일치·누락·확인 필요); automatic entries use only 불일치 (approved planned FAIL) and 확인 필요 (everything else). Final verdicts are confirmed by a person, so the report is always issued as a draft. """ import json import re import time from pathlib import Path from engine import reader from engine.pipeline import GATES, gate_ids VOCAB = ("오류", "불일치", "누락", "확인 필요") REBAR_GATES = {"G2": "기둥", "G3": "확대기초", "G4": "코핑"} def _rj(p, default=None): p = Path(p) return json.loads(p.read_text(encoding="utf-8")) if p.exists() else default def _cell(v): return str(v).replace("|", "/").replace("\n", " ") def _table(head, rows): out = ["| " + " | ".join(head) + " |", "|" + "|".join(" :--- " for _ in head) + "|"] out += ["| " + " | ".join(_cell(c) for c in r) + " |" for r in rows] return out def collect(pier_dir, run_dir): pier_dir, run_dir = Path(pier_dir), Path(run_dir) d = {"info": _rj(pier_dir / "pier.json", {"name": pier_dir.name, "type": "T형"}), "audit": _rj(pier_dir / "drawings" / "audit.json", []), "approvals": _rj(run_dir / "approvals.json", {}), "expected": _rj(pier_dir / "expected.json", {}), "params": _rj(pier_dir / "params.json", {}), "gates": {}, "results": {}, "autocheck": _rj(pier_dir / "autocheck.json", {}), "confirm": _rj(pier_dir / "confirm.json", {})} for g in gate_ids(): d["gates"][g] = _rj(run_dir / f"{g}_gate.json") d["results"][g] = _rj(run_dir / f"{g}.json") texts = _rj(pier_dir / "drawings" / "texts.json", []) tables = reader.table_candidates(texts) if texts else [] cands = reader.field_candidates(tables) d["schedule"] = [c for c in cands if c["target"].startswith("schedule.")] d["diffs"] = [r for r in reader.proposals(cands, d["params"], d["expected"]) if not r["same"]] if cands else [] return d def _n(v): """Number for a table cell: '-' when missing, no trailing .0 for integral values.""" if v is None: return "-" return int(v) if isinstance(v, float) and v.is_integer() else v def _bar_counts(results): """Model bar count per mark from the checker results (items ' bar count...').""" out = {} for g in REBAR_GATES: for r in (results.get(g) or {}).get("results", []): m = re.match(r"^(\S+) bar count", r["item"]) if m and "actual" in r: out[m.group(1)] = (int(r["actual"]), int(r["expected"]), r["result"], g) return out def build(pier_dir, run_dir, author="", today=None, stale=(), out_of_scope=()): """stale: gates whose result predates the current inputs (shown as void, not counted as done).""" d = collect(pier_dir, run_dir) for g in stale: d["gates"][g] = None d["results"][g] = None today = today or time.strftime("%Y-%m-%d") name, gates, appr = d["info"]["name"], d["gates"], d["approvals"] done = [g for g in gate_ids() if gates[g]] all_ok = all(gates[g] and gates[g]["verdict"] == "PASS" and appr.get(g, {}).get("decision") == "승인" for g in gate_ids() if g not in out_of_scope) L = [f"# {name} 교각 모델링 검토보고서 (초안)", "", f"> 앱이 게이트 결과·도면 표·승인 기록으로 자동 작성한 **초안**입니다. '오류'·'불일치' 판정과 3~5장의 서술은 " f"담당자가 확인·확정해야 합니다. 작성일 {today}{' · 작성 ' + author if author else ''}.", ""] # 1 overview L += ["## 1. 개요", "", f"- 교각: {name} ({d['info'].get('type', 'T형')}) · 좌표: 로컬(교각 중심 원점, X 교축직각, Y 교축, " f"Z = EL×1000, mm) · 산출: .3dm", f"- 게이트 진행: {len(done)}/{5 - len(out_of_scope)} 실행{(' (범위 밖: ' + ', '.join(out_of_scope) + ')') if out_of_scope else ''}, " + ("**전 게이트 PASS·승인**" if all_ok else "**미완료 — 실행·승인되지 않은 게이트가 있음**"), ""] if d["audit"]: L += ["### 1.1 입력 도면", ""] + _table(["구분", "DWG", "SHA-256(앞 16자)"], [[a.get("label", ""), a["source"], a["source_sha256"][:16]] for a in d["audit"]]) + [""] rows = [] for g, gname, _ in GATES: r, a = gates[g], appr.get(g, {}) if not r: rows.append([g, gname, "범위 밖" if g in out_of_scope else ("입력 변경 — 무효(재실행 필요)" if g in stale else "미실행"), "-", "-", "-"]) continue s = r["summary"] rows.append([g, gname, r["verdict"], f"{s['passed']}/{s['total']}", f"{a.get('decision', '-')} {a.get('by', '')} {a.get('at', '')}".strip(), a.get("note", "") or "-"]) L += ["### 1.2 게이트 결과", ""] + _table(["게이트", "내용", "판정", "검사 PASS", "승인", "의견"], rows) + [""] # 2 errors / mismatches finds = [] for g in done: for pf in gates[g]["planned_fails"]: ex = next((x for x in d["expected"].get("planned_fail", []) if x["gate"] == g and (x.get("item") == pf["item"] or (x.get("item_prefix") and pf["item"].startswith(x["item_prefix"])))), {}) finds.append(["불일치", g, pf["item"], pf["reason"], f"예외 승인: {ex.get('approved', '-')}"]) for item in gates[g]["unplanned_fails"]: finds.append(["확인 필요", g, item, "계획되지 않은 FAIL", "게이트 미승인 사유"]) for r in d["diffs"]: finds.append(["확인 필요", "-", ".".join([r["file"]] + r["path"]), f"도면 표 값 {json.dumps(r['candidate'], ensure_ascii=False)} ≠ 입력값 {json.dumps(r['current'], ensure_ascii=False)}", r["src"]]) L += ["## 2. 도면 오류·불일치", "", "자동 등재 기준: 승인된 계획 FAIL → '불일치', 계획되지 않은 FAIL과 도면 표 값·입력값 차이 → '확인 필요'.", ""] L += (_table(["판정", "게이트", "항목", "내용", "근거"], finds) if finds else ["자동으로 찾은 항목이 없습니다."]) + [""] # 3-5 human chapters with automatic hints L += ["## 3. 개선방안", "", "[담당자 작성] 2장 항목별로 도면 수정·설계자 확인 방안을 적습니다.", "", "## 4. 추가되어야 할 사항", "", "[담당자 작성] 도면에 없어 추정·가정으로 모델링한 사항(입력 묶음의 `[추론]` 표기)을 정리합니다.", ""] inferred = sorted({k for k, v in d["params"].items() if "[추론]" in json.dumps(v, ensure_ascii=False)}) if inferred: L += [f"- 자동 확인: `[추론]` 표기가 있는 입력 묶음 — {', '.join(inferred)}", ""] L += ["## 5. 누락사항", "", "[담당자 작성] 도면에서 빠진 치수·표·상세를 적습니다.", ""] nomodel = [c["target"][9:] for c in d["schedule"] if c["target"][9:] not in d["params"].get("rebar_marks", {})] if nomodel: L += [f"- 자동 확인: 재료표에 있으나 모델 대상이 아닌 마크 — {', '.join(sorted(set(nomodel)))} (범위 결정 확인)", ""] # 6 schedule vs model bc = _bar_counts(d["results"]) rows, seen = [], set() for c in d["schedule"]: m, v = c["target"][9:], c["value"] if m in seen: continue seen.add(m) mc = bc.get(m) if m not in d["params"].get("rebar_marks", {}): model, judge = "모델 외", "-" elif not mc: model, judge = "미검사", "-" # its rebar gate has not run (or is void) else: model, judge = mc[0], ("일치" if mc[0] == v["n"] else "불일치") rows.append([m, v["shape"] or "-", v["dia"] or "-", _n(v["n"]), _n(v["L"]), model, judge, c["src"]]) L += ["## 6. 철근재료표 (원본 vs 모델)", "", "원본 = 도면 재료표 판독값(동일 기호 \" 해석 포함), 모델 = 게이트 검사의 본수 실측값.", ""] L += (_table(["마크", "형식", "직경", "재료표 수량", "재료표 L(mm)", "모델 본수", "대조", "근거"], rows) if rows else ["도면 재료표를 인식하지 못했습니다(도면 미등록 또는 표 형식 상이)."]) + [""] # 7 rebar detail (records) L += ["## 7. 철근상세 (기록)", "", "원길이·곡선길이·피복은 판정이 아닌 기록 값입니다(요구사항 §6.2).", ""] for g, part in REBAR_GATES.items(): res = d["results"].get(g) if not res: continue recs = [[r["item"], r.get("detail", "")] for r in res["results"] if r["result"] == "INFO" and ("length" in r["item"] or "cover" in r["item"])] L += [f"### 7.{list(REBAR_GATES).index(g) + 1} {part} ({g})", ""] + _table(["항목", "값"], recs) + [""] # 8 verification method rows = [] for g in done: r = gates[g] n = r["negative"] ok = lambda v, yes, no: "생략(일괄 실행)" if v is None else (yes if v else no) rows.append([g, ok(r["reload"]["ok"], "일치", "불일치"), " / ".join(f"{'수평' if k == 'negH' else '수직'} {'검출' if v['detected'] else '미검출'}" for k, v in n.items()), ok(r["unchanged"]["ok"], f"불변 ({r['unchanged']['compared']})", f"변경 ({r['unchanged']['compared']})")]) L += ["## 8. 검증 방법", "", "- 검사기는 생성 입력(params)이 아니라 도면 문자·표에서 옮긴 독립 기대값(expected)만 읽습니다.", "- 게이트마다 G1부터 새로 생성 → 저장 파일을 다시 열어 검사(재로드) → 객체를 수평·수직으로 옮겨 FAIL이 나는지 확인(음성 시험) → 앞 게이트 객체 지문 비교.", "- 일괄 실행(단일 Rhino 세션)으로 검사한 게이트는 중간 저장·재로드와 앞 게이트 비교를 하지 않으며, 표에 '생략(일괄 실행)'으로 적습니다. 모델은 마지막 G5만 저장합니다.", ""] + _table(["게이트", "재로드", "음성 시험", "앞 게이트 객체"], rows) + [""] ac = (d.get("autocheck") or {}).get("sections") or {} if ac: names = {"g": "일치", "w": "주의", "b": "불일치", "n": "근거 없음", "x": "해당 없음"} rows = [] for b, r in sorted(ac.items(), key=lambda kv: ("gwbnx".index(kv[1]["st"]) if kv[1]["st"] in "gwbnx" else 9, kv[0])): if r["st"] == "x" and b.split(":")[1].startswith("_"): continue c = d["confirm"].get(b) or {} human = f"{c['by']} {c['at']}" + (f" (앱 확인 {names.get(c.get('app_st'), '-')}, 사유: {c['why']})" if c.get("why") else "") if c else "미확정" rows.append([b, names.get(r["st"], r["st"]), r["summary"][:90], human]) n = {k: sum(1 for r in ac.values() if r["st"] == k) for k in "gwbnx"} L += ["### 8.1 입력 묶음 앱 확인 결과 요약", "", f"- 앱 확인(검사 버전 {d['autocheck'].get('version', '-')}, {d['autocheck'].get('at', '-')}): 일치 {n['g']} · 주의 {n['w']} · 불일치 {n['b']} · 근거 없음 {n['n']} · 해당 없음 {n['x']} (전체 묶음 {len(ac)}개).", "- 앱 확인은 도면 표·재료표 수량·고정 항목을 앱이 대조한 결과입니다. '일치'는 독립 근거와 값이 같다는 뜻이며 도면이 맞다는 보증이 아닙니다. 확정은 사람이 했고, 앱 확인이 일치가 아닌 묶음은 사유가 기록되어 있습니다.", ""] + _table(["묶음", "앱 확인", "근거", "사람 확정"], rows) + [""] L += ["## 변경 이력", "", "| 버전 | 일자 | 내용 |", "| :--- | :--- | :--- |", f"| v0.1.00 | {today} | 앱 자동 생성 초안 |", ""] return "\n".join(L), d def check(md, d): """Report self-check (pattern of check_report.py): chapters, vocabulary, schedule rows, gate table.""" need = ["## 1. 개요", "## 2. 도면 오류·불일치", "## 3. 개선방안", "## 4. 추가되어야 할 사항", "## 5. 누락사항", "## 6. 철근재료표", "## 7. 철근상세", "## 8. 검증 방법"] res = {"chapters_missing": [h for h in need if h not in md]} sec2 = md.split("## 2.")[1].split("## 3.")[0] words = re.findall(r"^\| ([^|]+?) \| (?:G\d|-) \|", sec2, re.M) res["vocab_bad"] = [w for w in words if w not in VOCAB] res["finding_rows"] = len(words) res["finding_rows_without_source"] = len([l for l in sec2.splitlines() if re.match(r"^\| (오류|불일치|누락|확인 필요) \|", l) and l.rstrip().endswith("| |")]) marks = {c["target"][9:] for c in d["schedule"]} sec6 = md.split("## 6.")[1].split("## 7.")[0] res["schedule_rows"] = len([l for l in sec6.splitlines() if re.match(r"^\| [A-Z]{1,2}\d", l)]) res["schedule_marks"] = len(marks) res["gate_rows"] = len(re.findall(r"^\| G\d \|", md.split("### 1.2")[1].split("## 2.")[0], re.M)) if "### 1.2" in md else 0 # a pier with rebar marks must have its schedule read (Ulsan 2026-10-13: 0 of 0 rows passed silently) res["schedule_unread"] = bool(d["params"].get("rebar_marks")) and not marks res["ok"] = (not res["chapters_missing"] and not res["vocab_bad"] and res["schedule_rows"] == res["schedule_marks"] and not res["schedule_unread"] and res["gate_rows"] == 5 and res["finding_rows_without_source"] == 0) return res