Files
Pier_v1/Code/교각모델링앱/engine/report.py
T
ahnyongjooandClaude Sonnet 5.5 964a841de1 chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- 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>
2026-10-28 11:20:18 +09:00

218 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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