- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
218 lines
14 KiB
Python
218 lines
14 KiB
Python
"""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 '<mark> 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
|