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>
This commit is contained in:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

@@ -0,0 +1,189 @@
"""Patch (2026-10-12, user request): batch confirmation of required sections and batch run + auto-approval of
gates G1..G5. Approvals are recorded under the name typed by the user, note "일괄 실행 자동 승인"; only PASS
gates are approved and the batch stops at the first FAIL / error / blocker."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:70])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("web/app.py", [
("""ACTIVE = {} # job id -> thread of this server process""",
"""ACTIVE = {} # job id -> thread of this server process
BATCH = {} # pier name -> batch thread of this server process
AUTO_NOTE = "일괄 실행 자동 승인\""""),
(""" return redirect(request.form.get("next") or url_for("pier", name=name))
# ---------------- gates""",
""" return redirect(request.form.get("next") or url_for("pier", name=name))
def unconfirmed(p):
\"\"\"Required sections (all gates) that exist and are not confirmed, without duplicates.\"\"\"
out = []
for g in gate_ids():
for r in p.gate_requirements(g):
if r["exists"] and not r["confirmed"] and (r["file"], r["key"]) not in out:
out.append((r["file"], r["key"]))
return out
@app.post("/pier/<name>/confirm_all")
def confirm_all(name):
p = pier_or_404(name)
by = request.form.get("by", "").strip()
if not by:
flash("확정자 이름을 입력하세요.", "fail")
return redirect(url_for("pier", name=name))
need = unconfirmed(p)
for f, k in need:
p.confirm(f, k, by)
flash(f"필요한 묶음 {len(need)}개를 일괄 확정했습니다 ({by}).", "ok")
return redirect(url_for("pier", name=name))
# ---------------- gates"""),
("""@app.post("/pier/<name>/gate/<gate>/run")
def gate_run(name, gate):
p = pier_or_404(name)
if gate not in gate_ids():
abort(404)""",
"""def batch_state(p):
\"\"\"State of the batch run of this pier (실행 중 only while its thread lives in this server process).\"\"\"
b = store.rjson(p.run_dir / "batch.json")
if b and b["state"] == "실행 중":
t = BATCH.get(p.name)
if b.get("pid") != os.getpid() or not (t and t.is_alive()):
b = dict(b, state="중단됨")
return b
def _batch(p, by, gates_todo):
bp = p.run_dir / "batch.json"
log = []
def put(state):
store.wjson(bp, {"state": state, "by": by, "pid": os.getpid(), "at": store.now(), "log": log})
put("실행 중")
for g in gates_todo:
b = gate_blockers(p, g)
if b:
log.append(f"{g} 실행 불가: " + ", ".join(b))
return put("중단")
jid = uuid.uuid4().hex[:12]
store.wjson(p.run_dir / f"{g}_job.json", {"id": jid, "pid": os.getpid(), "state": "대기", "at": store.now(), "log": []})
ACTIVE[jid] = threading.current_thread()
log.append(f"{g} 실행 시작")
put("실행 중")
_gate_job(p, g, jid)
st = p.gate_status(g)
rep, job = st["report"] or {}, st["job"] or {}
if job.get("state") != "완료" or rep.get("job_id") != jid or rep.get("verdict") != "PASS":
log.append(f"{g} {rep.get('verdict') or job.get('state')} — 승인하지 않고 멈춤 (결과를 확인하세요)")
return put("중단")
p.set_approval(g, "승인", by, AUTO_NOTE)
s = rep["summary"]
log.append(f"{g} PASS {s['passed']}/{s['total']} (계획 FAIL {len(rep['planned_fails'])}) → 승인")
put("실행 중")
put("완료")
def anything_running(p):
if (batch_state(p) or {}).get("state") == "실행 중":
return True
return any(job_state(p.gate_status(g)["job"]) in ("대기", "실행 중") for g in gate_ids())
@app.post("/pier/<name>/gates/run_all")
def gates_run_all(name):
p = pier_or_404(name)
by = request.form.get("by", "").strip()
if not by:
flash("판정자 이름을 입력하세요.", "fail")
return redirect(url_for("gates", name=name))
if anything_running(p):
flash("이미 실행 중인 작업이 있습니다.", "fail")
return redirect(url_for("gates", name=name))
todo = [g for g in gate_ids() if not p.gate_status(g)["approved"]]
if not todo:
flash("모든 게이트가 이미 승인되어 있습니다.", "ok")
return redirect(url_for("gates", name=name))
b = gate_blockers(p, todo[0])
if b:
flash(f"{todo[0]} 실행 불가: " + ", ".join(b), "fail")
return redirect(url_for("gates", name=name))
store.wjson(p.run_dir / "batch.json", {"state": "실행 중", "by": by, "pid": os.getpid(), "at": store.now(),
"log": ["일괄 실행 대기"]})
t = threading.Thread(target=_batch, args=(p, by, todo), daemon=True)
BATCH[p.name] = t
t.start()
flash(f"{todo[0]}~{todo[-1]} 연속 실행을 시작했습니다. PASS인 게이트만 '{by}' 이름으로 자동 승인합니다.", "ok")
return redirect(url_for("gates", name=name))
@app.post("/pier/<name>/gate/<gate>/run")
def gate_run(name, gate):
p = pier_or_404(name)
if gate not in gate_ids():
abort(404)
if (batch_state(p) or {}).get("state") == "실행 중":
flash("일괄 실행 중에는 개별 실행을 할 수 없습니다.", "fail")
return redirect(url_for("gates", name=name))"""),
(""" for r in rows:
r["state"] = job_state(r["job"])
running = any(r["state"] in ("대기", "실행 중") for r in rows)""",
""" for r in rows:
r["state"] = job_state(r["job"])
batch = batch_state(p)
running = any(r["state"] in ("대기", "실행 중") for r in rows) or (batch or {}).get("state") == "실행 중\""""),
])
patch("web/app.py", [
(""" return render_template("gates.html", p=p, rows=rows, running=running)""",
""" return render_template("gates.html", p=p, rows=rows, running=running, batch=batch,
todo=[g for g in gate_ids() if not p.gate_status(g)["approved"]])"""),
(""" return render_template("pier.html", p=p, sections=secs, gates=gates,""",
""" return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p),"""),
])
patch("web/templates/pier.html", [
("""<h2>3. 게이트</h2>""",
"""<form method="post" action="{{ url_for('confirm_all', name=p.name) }}"
onsubmit="return confirm('게이트에 필요한 미확정 묶음 {{ need|length }}개를 모두 확정합니다. 값을 도면·판독서와 대조하셨습니까?');">
{% if need %}확정자 <input type="text" name="by" required>
<button>필요한 묶음 일괄 확정 ({{ need|length }}개)</button>
<span class="muted">게이트 G1~G5에 필요한 묶음 중 미확정인 것만 확정합니다.</span>
{% else %}<span class="ok">게이트에 필요한 묶음이 모두 확정되어 있습니다.</span>{% endif %}
</form>
<h2>3. 게이트</h2>"""),
])
patch("web/templates/gates.html", [
("""<h2>검토보고서 (초안)</h2>""",
"""<h2>연속 실행·자동 승인</h2>
{% if batch %}<p>최근 일괄 실행: <b class="{{ 'ok' if batch.state == '완료' else ('warn' if batch.state in ('실행 중', '중단됨') else 'fail') }}">{{ batch.state }}</b>
· 판정자 {{ batch.by }} · {{ batch.at }}</p>
<ul>{% for m in batch.log %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% if todo %}
<form method="post" action="{{ url_for('gates_run_all', name=p.name) }}"
onsubmit="return confirm('{{ todo[0] }}~{{ todo[-1] }}를 차례로 실행하고 PASS인 게이트만 입력한 이름으로 자동 승인합니다. 진행할까요?');">
판정자 <input type="text" name="by" required>
<button {% if running %}disabled{% endif %}>{{ todo[0] }}~{{ todo[-1] }} 연속 실행·자동 승인</button>
</form>
<p class="muted">PASS인 게이트만 승인합니다. FAIL·오류·실행 조건 미충족이면 그 게이트에서 멈추고 승인하지 않습니다.
승인 기록의 의견란에 '일괄 실행 자동 승인'이 남습니다. 결과는 아래 게이트별 표에서 확인하세요.</p>
{% else %}<p class="ok">모든 게이트가 승인되어 있습니다.</p>{% endif %}
<h2>검토보고서 (초안)</h2>"""),
])
print("patched")
@@ -0,0 +1,145 @@
"""Patch (2026-10-12, user request): batch run G1..G5 in ONE Rhino session with gate checks only — no per-gate
save, no reload; negative tests kept (moved and restored inside the session); the final G5 model is saved once.
Reload / unchanged are reported as skipped for batch-run gates. Individual gate runs keep save + reload; when the
previous gate has no fingerprint (it was batch-run), the unchanged check is skipped instead of raising."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:70])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("engine/gates.py", [
# 1. shared judgement of checks + negative tests
("""def run_gate(gate, params, expected, run_dir, log=print):""",
"""def _negative_steps(gate, pid, expected, run_dir):
\"\"\"Rhino steps: checker, then horizontal / vertical move -> checker -> undo (same document).\"\"\"
neg_h, neg_v = NEGATIVE[gate]
pert = RHINO_DIR / "perturb.py"
steps = check_steps(expected, run_dir, [gate])
for tag, mv in (("_negH", neg_h), ("_negV", neg_v)):
glb = {"PIER_ID": pid, "MOVES": [mv]}
steps.append({"name": f"{gate}:perturb{tag}", "script": pert, "globals": glb})
steps += check_steps(expected, run_dir, [gate], tag)
steps.append({"name": f"{gate}:undo{tag}", "script": pert, "globals": dict(glb, UNDO=True)})
return steps
def _judge(gate, X, run_dir):
\"\"\"Read checker + negative-test results: (base, unplanned, planned_seen, neg).\"\"\"
base = _load(run_dir / f"{gate}.json")
fails = _fails(base)
unplanned = [f["item"] for f in fails if not planned(X, gate, f["item"])]
planned_seen = [{"item": f["item"], "reason": planned(X, gate, f["item"])["reason"]}
for f in fails if planned(X, gate, f["item"])]
neg = {}
for tag, mv in zip(("negH", "negV"), NEGATIVE[gate]):
r = _load(run_dir / f"{gate}_{tag}.json")
new = [f["item"] for f in _fails(r) if f["item"] not in {x["item"] for x in fails}]
neg[tag] = {"move": mv, "new_fails": new[:10], "detected": len(_fails(r)) > len(fails)}
return base, unplanned, planned_seen, neg
def run_batch_session(gates, params, expected, run_dir, log=print):
\"\"\"Batch mode: build G1..G5 in ONE Rhino session; after each gate in `gates` is built, run its checker and
negative tests in the same document. No per-gate save and no reload; the final model is saved once as G5.3dm.
Writes <gate>_gate.json for each gate in `gates` (reload / unchanged = skipped) and returns {gate: report}.\"\"\"
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
X = _load(expected)
pid = X["pier_id"]
ids = gate_ids()
for g in gates: # results / models of these gates are replaced: drop the old ones
for suffix in ("_fp.json",) + ((".3dm",) if g != "G5" else ()):
(run_dir / f"{g}{suffix}").unlink(missing_ok=True)
glb = {"PARAMS_PATH": str(Path(params).resolve())}
steps = []
for g, _, script in GATES:
steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": glb})
if g in REBAR_GATES:
steps.append({"name": f"{g}:rebar_colours.py", "script": RHINO_DIR / "rebar_colours.py", "globals": glb})
if g in gates:
steps += _negative_steps(g, pid, expected, run_dir)
steps.append({"name": "display.py", "script": RHINO_DIR / "display.py", "globals": glb})
t0 = time.time()
log(f"[batch] one Rhino session: build G1..G5, check {', '.join(gates)} (no per-gate save / reload)")
res = run_job(steps, save=run_dir / "G5.3dm", units_mm_tolerance=TOLERANCE_MM)
secs = {}
for st in res["steps"]:
g = st["name"].split(":")[0]
secs[g] = secs.get(g, 0) + st["secs"]
out = {}
for g in gates:
base, unplanned, planned_seen, neg = _judge(g, X, run_dir)
verdict = "PASS" if not unplanned and all(v["detected"] for v in neg.values()) else "FAIL"
rep = {"gate": g, "checker": CHECKERS[g], "verdict": verdict, "summary": base["summary"],
"unplanned_fails": unplanned, "planned_fails": planned_seen, "negative": neg,
"reload": {"ok": None, "skipped": "일괄 실행(단일 세션) — 저장·재로드 안 함"},
"unchanged": {"ok": None, "compared": 0, "diffs": [], "missing": [], "against": None,
"skipped": "일괄 실행(단일 세션) — 앞 게이트 객체를 다시 만들지 않음"},
"model": str(run_dir / "G5.3dm") if g == "G5" else None, "mode": "batch",
"secs": round(secs.get(g, 0), 1), "session_secs": res["wall_secs"]}
(run_dir / f"{g}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
log(f"[{g}] {verdict} checks {base['summary']['passed']}/{base['summary']['total']} "
f"(planned FAIL {len(planned_seen)}, unplanned {len(unplanned)}) negative H/V "
f"{neg['negH']['detected']}/{neg['negV']['detected']} (batch)")
out[g] = rep
log(f"[batch] done in {round(time.time() - t0, 1)}s, objects {res['objects']}, model {run_dir / 'G5.3dm'}")
return out
def run_gate(gate, params, expected, run_dir, log=print):"""),
# 2. run_gate uses the shared helpers
(""" neg_h, neg_v = NEGATIVE[gate]
pert = RHINO_DIR / "perturb.py"
steps = check_steps(expected, run_dir, [gate])
for tag, mv in (("_negH", neg_h), ("_negV", neg_v)):
glb = {"PIER_ID": pid, "MOVES": [mv]}
steps.append({"name": f"perturb{tag}", "script": pert, "globals": glb})
steps += check_steps(expected, run_dir, [gate], tag)
steps.append({"name": f"undo{tag}", "script": pert, "globals": dict(glb, UNDO=True)})
steps.append""",
""" steps = _negative_steps(gate, pid, expected, run_dir)
steps.append"""),
(""" base = _load(run_dir / f"{gate}.json")
fails = _fails(base)
unplanned = [f["item"] for f in fails if not planned(X, gate, f["item"])]
planned_seen = [{"item": f["item"], "reason": planned(X, gate, f["item"])["reason"]}
for f in fails if planned(X, gate, f["item"])]
neg = {}
for tag, mv in (("negH", neg_h), ("negV", neg_v)):
r = _load(run_dir / f"{gate}_{tag}.json")
new = [f["item"] for f in _fails(r) if f["item"] not in {x["item"] for x in fails}]
neg[tag] = {"move": mv, "new_fails": new[:10], "detected": len(_fails(r)) > len(fails)}
""",
""" base, unplanned, planned_seen, neg = _judge(gate, X, run_dir)
"""),
# 3. previous gate without fingerprint (batch-run) -> unchanged skipped
(""" prev_fp = run_dir / f"{ids[k - 1]}_fp.json"
if not prev_fp.exists():
raise FileNotFoundError(f"run {ids[k - 1]} first (needs {prev_fp.name})")
prev = _load(prev_fp)""",
""" prev_fp = run_dir / f"{ids[k - 1]}_fp.json"
if k > 0 and not prev_fp.exists():
unchanged.update(ok=None, skipped=f"{ids[k - 1]} 지문 없음(일괄 실행 결과) — 비교 생략")
elif k > 0:
prev = _load(prev_fp)"""),
(""" verdict = ("PASS" if not unplanned and all(v["detected"] for v in neg.values()) and reload_ok
and unchanged["ok"] else "FAIL")""",
""" verdict = ("PASS" if not unplanned and all(v["detected"] for v in neg.values()) and reload_ok
and unchanged["ok"] is not False else "FAIL")"""),
(""" "unchanged": unchanged, "model": str(model), "secs": round(time.time() - t0, 1),""",
""" "unchanged": unchanged, "model": str(model), "mode": "single", "secs": round(time.time() - t0, 1),"""),
("""from engine.pipeline import (CHECKERS, GATE_PREFIXES, RHINO_DIR, TOLERANCE_MM, build_steps, check_steps,
gate_ids)""",
"""from engine.pipeline import (CHECKERS, GATE_PREFIXES, GATES, REBAR_GATES, RHINO_DIR, TOLERANCE_MM, build_steps,
check_steps, gate_ids)"""),
])
print("engine patched")
@@ -0,0 +1,172 @@
from pathlib import Path
APP = Path(r"C:\Users\User\Desktop\바이브코딩\pier_T\Code\교각모델링앱")
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:60])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("web/app.py", [
("""import json
import sys
import threading
import traceback""", """import json
import os
import sys
import threading
import traceback
import uuid"""),
("""RHINO_LOCK = threading.Lock() # one Rhino job at a time""",
"""RHINO_LOCK = threading.Lock() # one Rhino job at a time
ACTIVE = {} # job id -> thread of this server process"""),
("""def _gate_job(p, gate):
jp = p.run_dir / f"{gate}_job.json"
inputs = p.inputs_sha()
log = []
def note(m):
log.append(m)
store.wjson(jp, {"state": "실행 중", "at": store.now(), "log": log})
with RHINO_LOCK:
try:
note("Rhino 작업 시작")
rep = run_gate(gate, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
rep["inputs_sha"] = inputs
store.wjson(p.run_dir / f"{gate}_gate.json", rep)
store.wjson(jp, {"state": "완료", "at": store.now(), "log": log})
except Exception:
store.wjson(jp, {"state": "오류", "at": store.now(), "log": log, "error": traceback.format_exc()})""",
"""def job_state(job):
\"\"\"A queued/running job counts only while its thread lives in this server process; a job left behind by a
stopped server (or test process) is shown as 중단됨 and may be run again.\"\"\"
if job and job["state"] in ("대기", "실행 중"):
t = ACTIVE.get(job.get("id"))
if job.get("pid") != os.getpid() or not (t and t.is_alive()):
return "중단됨"
return job and job["state"]
def _gate_job(p, gate, jid):
jp = p.run_dir / f"{gate}_job.json"
inputs = p.inputs_sha()
log = []
def put(state, **kw):
store.wjson(jp, dict({"id": jid, "pid": os.getpid(), "state": state, "at": store.now(), "log": log}, **kw))
def note(m):
log.append(m)
put("실행 중")
with RHINO_LOCK:
try:
note("Rhino 작업 시작")
rep = run_gate(gate, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
rep["inputs_sha"], rep["job_id"] = inputs, jid
store.wjson(p.run_dir / f"{gate}_gate.json", rep)
put("완료")
except Exception:
put("오류", error=traceback.format_exc())"""),
(""" job = p.gate_status(gate)["job"]
if job and job["state"] in ("대기", "실행 중"):
flash(f"{gate}는 이미 실행 중입니다.", "fail")
return redirect(url_for("gates", name=name))
# Mark the job as queued before the thread starts, so no request sees the previous run's "완료" state.
store.wjson(p.run_dir / f"{gate}_job.json", {"state": "대기", "at": store.now(), "log": []})
threading.Thread(target=_gate_job, args=(p, gate), daemon=True).start()""",
""" if job_state(p.gate_status(gate)["job"]) in ("대기", "실행 중"):
flash(f"{gate}는 이미 실행 중입니다.", "fail")
return redirect(url_for("gates", name=name))
# Mark the job as queued (with its id) before the thread starts, so no request sees the previous run's state.
jid = uuid.uuid4().hex[:12]
store.wjson(p.run_dir / f"{gate}_job.json",
{"id": jid, "pid": os.getpid(), "state": "대기", "at": store.now(), "log": []})
t = threading.Thread(target=_gate_job, args=(p, gate, jid), daemon=True)
ACTIVE[jid] = t
t.start()"""),
(""" rows = [dict(id=g, name=GATE_NAMES[g], blockers=gate_blockers(p, g), req=p.gate_requirements(g), **p.gate_status(g))
for g in gate_ids()]
running = any(r["job"] and r["job"]["state"] in ("대기", "실행 중") for r in rows)""",
""" rows = [dict(id=g, name=GATE_NAMES[g], blockers=gate_blockers(p, g), req=p.gate_requirements(g), **p.gate_status(g))
for g in gate_ids()]
for r in rows:
r["state"] = job_state(r["job"])
running = any(r["state"] in ("대기", "실행 중") for r in rows)"""),
])
patch("web/templates/gates.html", [
("""{% if g.job and g.job.state in ('대기', '실행 중') %}<p class="warn">{{ g.job.state }}: {{ g.job.log[-1] if g.job.log else '' }}</p>
{% elif g.job and g.job.state == '오류' %}<p class="fail">오류</p><pre>{{ g.job.error }}</pre>{% endif %}""",
"""{% if g.state in ('대기', '실행 중') %}<p class="warn">{{ g.state }}: {{ g.job.log[-1] if g.job.log else '' }}</p>
{% elif g.state == '중단됨' %}<p class="warn">중단됨: 이전 실행이 끝나지 않은 채 서버가 멈췄습니다. 다시 실행하세요.</p>
{% elif g.state == '오류' %}<p class="fail">오류</p><pre>{{ g.job.error }}</pre>{% endif %}"""),
])
patch("tools/test_web.py", [
("""def html(r):
return r.get_data(as_text=True)""",
"""def html(r):
return r.get_data(as_text=True)
def run_and_wait(c, P, g, limit=900):
\"\"\"POST run, then wait until the job with the id written at start-up is finished; returns (job, trace).\"\"\"
c.post(f"/pier/{P}/gate/{g}/run")
jid = (store.Pier(P).gate_status(g)["job"] or {}).get("id")
t0, trace = time.time(), []
while time.time() - t0 < limit:
job = store.Pier(P).gate_status(g)["job"] or {}
trace.append((round(time.time() - t0), job.get("id") == jid, job.get("state")))
if jid and job.get("id") == jid and job.get("state") in ("완료", "오류"):
return job, trace
time.sleep(3)
return None, trace"""),
(""" r = c.post(f"/pier/{P}/gate/{g}/run", follow_redirects=True)
t0 = time.time()
while True:
job = store.Pier(P).gate_status(g)["job"]
if job and job["state"] in ("완료", "오류"):
break
if time.time() - t0 > 900:
break
time.sleep(3)
st = store.Pier(P).gate_status(g)
rep = st["report"] or {}
check(f"{g} run via app -> PASS", job and job["state"] == "완료" and rep.get("verdict") == "PASS",
f"{rep.get('summary')} {time.time() - t0:.0f}s {job.get('error', '')[-200:] if job else ''}")""",
""" job, trace = run_and_wait(c, P, g)
rep = store.Pier(P).gate_status(g)["report"] or {}
check(f"{g} run via app -> PASS", bool(job) and job["state"] == "완료" and rep.get("verdict") == "PASS"
and rep.get("job_id") == job["id"], f"{rep.get('summary')} {trace[-1][0]}s {(job or {}).get('error', '')[-200:]}")"""),
(""" c.post(f"/pier/{P}/gate/G1/run")
t0 = time.time()
while time.time() - t0 < 600:
job = store.Pier(P).gate_status("G1")["job"]
if job and job["state"] in ("완료", "오류"):
break
time.sleep(3)
rep = store.Pier(P).gate_status("G1")["report"]
check("pile length +5 mm -> G1 FAIL (pile length)", rep["verdict"] == "FAIL" and "pile length" in rep["unplanned_fails"],
rep["unplanned_fails"])""",
""" job, trace = run_and_wait(c, P, "G1")
rep = store.Pier(P).gate_status("G1")["report"]
check("pile length +5 mm -> G1 FAIL (pile length)", bool(job) and rep.get("job_id") == job["id"]
and rep["verdict"] == "FAIL" and "pile length" in rep["unplanned_fails"],
f"{rep['unplanned_fails']} trace {trace[:2]}..{trace[-2:]}")"""),
(""" check("report: other modelled marks match", md.count("| 일치 |") == 35, md.count("| 일치 |"))""",
""" sec6 = md.split("## 6.")[1].split("## 7.")[0]
check("report: other modelled marks match (35)", sec6.count("| 일치 |") == 35, sec6.count("| 일치 |"))"""),
(""" # 8 a wrong input gives a FAIL gate""",
""" # 7b a job left "실행 중" by a stopped process must not block the gate forever
jp = TD / P / "runs" / "current" / "G1_job.json"
store.wjson(jp, {"id": "orphan", "pid": -1, "state": "실행 중", "at": store.now(), "log": ["x"]})
r = c.get(f"/pier/{P}/gates")
check("orphaned running job shown as 중단됨", "중단됨: 이전 실행" in html(r))
# 8 a wrong input gives a FAIL gate"""),
])
print("patched")
@@ -0,0 +1,94 @@
from pathlib import Path
p = Path(r"C:\Users\User\Desktop\바이브코딩\pier_T\Code\교각모델링앱\runner.py")
s = p.read_text(encoding="utf-8")
reps = [
# result written atomically, so the runner can act as soon as it appears
(""" json.dump(res, open(SPEC["result"], "w", encoding="utf-8"), ensure_ascii=False, indent=1)
if opened is not None:""",
""" tmp = SPEC["result"] + ".tmp"
json.dump(res, open(tmp, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
os.replace(tmp, SPEC["result"])
if opened is not None:"""),
("""import io, json, sys, time, traceback""", """import io, json, os, sys, time, traceback"""),
("""def run_job(steps, save=None, units_mm_tolerance=None, timeout=1800, open_path=None):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
\"\"\"""",
"""def _window_title(pid):
\"\"\"Main window title of a process (diagnosis of a stalled Rhino, e.g. a modal dialog).\"\"\"
try:
out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"],
capture_output=True, text=True, timeout=20)
return out.stdout.strip()
except Exception as e:
return f"? ({e})"
def _launch_and_wait(job, timeout, exit_grace):
\"\"\"Start Rhino on job.py; return when result.json exists. A Rhino that does not exit within `exit_grace`
seconds after writing the result is killed (2026-10-12: Rhino sometimes stalled on exit). Raises
RhinoJobError if no result appears within `timeout` (Rhino killed; its window title is reported).\"\"\"
rp = job / "result.json"
# Pass a raw command line: Rhino only honours /runscript="..." (quote after '='), while a list argument
# would be quoted as a whole ("/runscript=...") and silently ignored.
proc = subprocess.Popen(f'"{RHINO_EXE}" /nosplash /notemplate '
f'/runscript="_-RunPythonScript {job / "job.py"} _-Exit"')
t0 = time.time()
while not rp.exists():
if proc.poll() is not None:
raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}")
if time.time() - t0 > timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoJobError(f"Rhino gave no result within {timeout}s (window: '{title}'): {job}")
time.sleep(0.5)
try:
proc.wait(timeout=exit_grace)
return "exited"
except subprocess.TimeoutExpired:
title = _window_title(proc.pid)
proc.kill()
return f"killed after result (did not exit in {exit_grace}s, window: '{title}')"
def run_job(steps, save=None, units_mm_tolerance=None, timeout=600, open_path=None, retries=1, exit_grace=20):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
timeout: seconds to wait for the result; a stalled launch is killed and retried `retries` times.
\"\"\""""),
(""" t0 = time.time()
# Pass a raw command line: Rhino only honours /runscript="..." (quote after '='), while a list argument
# would be quoted as a whole ("/runscript=...") and silently ignored.
proc = subprocess.Popen(f'"{RHINO_EXE}" /nosplash /notemplate '
f'/runscript="_-RunPythonScript {job / "job.py"} _-Exit"')
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
raise RhinoJobError(f"Rhino job timed out after {timeout}s: {job}")
rp = job / "result.json"
if not rp.exists():
raise RhinoJobError(f"Rhino exited without a result: {job}")
res = json.loads(rp.read_text(encoding="utf-8"))
res["job_dir"], res["wall_secs"] = str(job), round(time.time() - t0, 1)""",
""" t0, attempts = time.time(), []
for attempt in range(retries + 1):
try:
exit_note = _launch_and_wait(job, timeout, exit_grace)
break
except RhinoJobError as e:
attempts.append(str(e))
if attempt == retries:
raise RhinoJobError(" | ".join(attempts))
res = json.loads((job / "result.json").read_text(encoding="utf-8"))
res["job_dir"], res["wall_secs"] = str(job), round(time.time() - t0, 1)
res["rhino_exit"], res["failed_attempts"] = exit_note, attempts"""),
]
for a, b in reps:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
print("patched")
@@ -0,0 +1,107 @@
"""Runner patch 2 (2026-10-12): separate start stall from long jobs; fix window-title decoding."""
from pathlib import Path
APP = Path(r"C:\Users\User\Desktop\바이브코딩\pier_T\Code\교각모델링앱")
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:60])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("runner.py", [
("""SPEC = json.load(open(r"%(spec)s", encoding="utf-8"))
doc = Rhino.RhinoDoc.ActiveDoc""",
"""SPEC = json.load(open(r"%(spec)s", encoding="utf-8"))
open(SPEC["started"], "w").write(time.strftime("%%H:%%M:%%S"))
doc = Rhino.RhinoDoc.ActiveDoc"""),
("""class RhinoJobError(RuntimeError):
pass
""",
"""class RhinoJobError(RuntimeError):
pass
class RhinoStartError(RhinoJobError):
\"\"\"Rhino did not start the job script in time (retryable).\"\"\"
"""),
(""" out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"],
capture_output=True, text=True, timeout=20)
return out.stdout.strip()""",
""" out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"],
capture_output=True, timeout=20)
return out.stdout.decode("mbcs", errors="replace").strip() # console code page (cp949 here)"""),
("""def _launch_and_wait(job, timeout, exit_grace):
\"\"\"Start Rhino on job.py; return when result.json exists. A Rhino that does not exit within `exit_grace`
seconds after writing the result is killed (2026-10-12: Rhino sometimes stalled on exit). Raises
RhinoJobError if no result appears within `timeout` (Rhino killed; its window title is reported).\"\"\"
rp = job / "result.json\"""",
"""def _launch_and_wait(job, start_timeout, run_timeout, exit_grace):
\"\"\"Start Rhino on job.py; return when result.json exists.
start_timeout: seconds until job.py writes its `started` marker (2026-10-12: a launch once stalled 23 min);
exceeded -> Rhino killed, RhinoStartError (retryable). run_timeout: seconds from start to result (long jobs
such as a full fingerprint take ~12 min); exceeded -> Rhino killed, RhinoJobError. A Rhino that does not
exit within `exit_grace` seconds after writing the result is killed (the result is already on disk).
The main window title is reported on every kill to help find the cause (e.g. a modal dialog).\"\"\"
rp, sp = job / "result.json", job / "started"
sp.unlink(missing_ok=True)"""),
(""" t0 = time.time()
while not rp.exists():
if proc.poll() is not None:
raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}")
if time.time() - t0 > timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoJobError(f"Rhino gave no result within {timeout}s (window: '{title}'): {job}")
time.sleep(0.5)""",
""" t0, t_start = time.time(), None
while not rp.exists():
if proc.poll() is not None:
raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}")
if t_start is None and sp.exists():
t_start = time.time()
if t_start is None and time.time() - t0 > start_timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoStartError(f"Rhino did not start the job within {start_timeout}s (window: '{title}'): {job}")
if t_start is not None and time.time() - t_start > run_timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoJobError(f"Rhino job ran over {run_timeout}s (window: '{title}'): {job}")
time.sleep(0.5)"""),
("""def run_job(steps, save=None, units_mm_tolerance=None, timeout=600, open_path=None, retries=1, exit_grace=30):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
timeout: seconds to wait for the result; a stalled launch is killed and retried `retries` times.
\"\"\"""",
"""def run_job(steps, save=None, units_mm_tolerance=None, open_path=None, start_timeout=600, run_timeout=3600,
retries=1, exit_grace=30):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
A launch that does not start the script within start_timeout is killed and retried `retries` times.
\"\"\""""),
(""" "result": str(job / "result.json")}""",
""" "result": str(job / "result.json"), "started": str(job / "started")}"""),
(""" exit_note = _launch_and_wait(job, timeout, exit_grace)
break
except RhinoJobError as e:""",
""" exit_note = _launch_and_wait(job, start_timeout, run_timeout, exit_grace)
break
except RhinoStartError as e:"""),
])
patch("tools/test_runner_stall.py", [
(""" run_job([{"name": "noop", "script": step}], timeout=3, retries=1)""",
""" run_job([{"name": "noop", "script": step}], start_timeout=3, retries=1)"""),
(""" n = str(e).count("gave no result within 3s")""",
""" n = str(e).count("did not start the job within 3s")"""),
("""1. timeout=3 s with 1 retry:""", """1. start_timeout=3 s with 1 retry:"""),
])
print("patched")
@@ -0,0 +1,105 @@
"""Patch (2026-10-13, Ulsan P1): per-pier gate scope in the web app and the report.
Gates outside params["scope"] are not listed / run / required; the previous gate of a gate is the previous gate
in scope; optional sections (params.piles for a pier without piles) are not required for confirmation."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:70])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("web/store.py", [
(""" def gate_requirements(self, gate):
need = COMMON + GATE_SECTIONS[gate]
return [dict(file=f, key=k, **self.section_state(f, k)) for f, k in need]""",
""" def gate_requirements(self, gate):
need = COMMON + GATE_SECTIONS[gate]
P = self.load("params")
need = [(f, k) for f, k in need if not ((f, k) in OPTIONAL and k not in P)]
return [dict(file=f, key=k, **self.section_state(f, k)) for f, k in need]
def scope(self):
\"\"\"Gates modelled for this pier (params["scope"], default all five).\"\"\"
sc = self.load("params").get("scope")
return [g for g in ("G1", "G2", "G3", "G4", "G5") if not sc or g in sc]"""),
("""GATE_SECTIONS = {""", """OPTIONAL = {("params", "piles")} # a pier on a spread footing has no piles (Ulsan P1)
GATE_SECTIONS = {"""),
])
patch("web/app.py", [
(""" gates = [dict(id=g, name=GATE_NAMES[g], **p.gate_status(g)) for g in gate_ids()]""",
""" gates = [dict(id=g, name=GATE_NAMES[g], **p.gate_status(g)) for g in p.scope()]"""),
(""" out = []
for g in gate_ids():
for r in p.gate_requirements(g):""",
""" out = []
for g in p.scope():
for r in p.gate_requirements(g):"""),
(""" k = gate_ids().index(gate)
if k > 0 and not p.gate_status(gate_ids()[k - 1])["approved"]:
out.append(f"{gate_ids()[k - 1]} 미승인")""",
""" ids = p.scope()
if gate not in ids:
return [f"{gate}는 이 교각의 범위 밖"]
k = ids.index(gate)
if k > 0 and not p.gate_status(ids[k - 1])["approved"]:
out.append(f"{ids[k - 1]} 미승인")"""),
(""" todo = [g for g in gate_ids() if not p.gate_status(g)["approved"]]
if not todo:""",
""" todo = [g for g in p.scope() if not p.gate_status(g)["approved"]]
if not todo:"""),
(""" for g in gate_ids()]
for r in rows:""",
""" for g in p.scope()]
for r in rows:"""),
(""" todo=[g for g in gate_ids() if not p.gate_status(g)["approved"]])""",
""" todo=[g for g in p.scope() if not p.gate_status(g)["approved"]],
out_of_scope=[g for g in gate_ids() if g not in p.scope()])"""),
(""" if not any(p.gate_status(g)["report"] for g in gate_ids()):""",
""" if not any(p.gate_status(g)["report"] for g in p.scope()):"""),
(""" stale = {g for g in gate_ids() if p.gate_status(g)["stale"]}
out = report_files.write(p.dir, p.run_dir, request.form.get("by", "").strip(), stale)""",
""" stale = {g for g in p.scope() if p.gate_status(g)["stale"]}
out = report_files.write(p.dir, p.run_dir, request.form.get("by", "").strip(), stale,
[g for g in gate_ids() if g not in p.scope()])"""),
(""" todo=[g["id"] for g in gates if not g["approved"]], running=anything_running(p),""",
""" todo=[g["id"] for g in gates if not g["approved"]], running=anything_running(p),
out_of_scope=[g for g in gate_ids() if g not in p.scope()],"""),
])
patch("web/templates/gates.html", [
("""<h2>일괄 승인 (필요하면 실행)</h2>""",
"""{% if out_of_scope %}<p class="muted">이 교각의 범위 밖 게이트: {{ out_of_scope|join(', ') }} (실행·승인 대상 아님)</p>{% endif %}
<h2>일괄 승인 (필요하면 실행)</h2>"""),
])
patch("web/templates/pier.html", [
("""<h2>3. 게이트</h2>""",
"""<h2>3. 게이트</h2>
{% if out_of_scope %}<p class="muted">범위 밖: {{ out_of_scope|join(', ') }} — 이 교각에서는 모델링하지 않습니다.</p>{% endif %}"""),
])
patch("engine/report_files.py", [
("""def write(pier_dir, run_dir, author="", stale=()):""", """def write(pier_dir, run_dir, author="", stale=(), out_of_scope=()):"""),
(""" md, d = report.build(pier_dir, run_dir, author, today, stale)""",
""" md, d = report.build(pier_dir, run_dir, author, today, stale, out_of_scope)"""),
])
patch("engine/report.py", [
("""def build(pier_dir, run_dir, author="", today=None, stale=()):""",
"""def build(pier_dir, run_dir, author="", today=None, stale=(), out_of_scope=()):"""),
(""" all_ok = all(gates[g] and gates[g]["verdict"] == "PASS" and appr.get(g, {}).get("decision") == "승인"
for g in gate_ids())""",
""" 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)"""),
(""" rows.append([g, gname, "입력 변경 — 무효(재실행 필요)" if g in stale else "미실행", "-", "-", "-"])""",
""" rows.append([g, gname, "범위 밖" if g in out_of_scope else
("입력 변경 — 무효(재실행 필요)" if g in stale else "미실행"), "-", "-", "-"])"""),
])
print("patched")
@@ -0,0 +1,32 @@
"""Patch (2026-10-14, Okseong P1 G3): builders receive the global GATE (gate id), so the generic centreline builder
rebar_polylines.py can serve G3 (params footing_rebar) as well as G4 (params coping_rebar).
Backups: backup/2026-10-14_okseong_g3/. Other builders ignore the extra global."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, reps):
s = path.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "engine" / "pipeline.py", [(
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": glb})''',
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})''')])
patch(APP / "engine" / "gates.py", [(
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": glb})''',
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})''')])
patch(APP / "engine" / "rhino" / "rebar_polylines.py", [
('''Ids: <pier>-R-<mark>-<occ> (pipe) and <pier>-RC-<mark>-<occ> (centreline).
"""''',
'''Ids: <pier>-R-<mark>-<occ> (pipe) and <pier>-RC-<mark>-<occ> (centreline).
Section by gate (2026-10-14, Okseong P1): GATE "G3" -> params footing_rebar, otherwise coping_rebar.
"""'''),
('''SEC = P["coping_rebar"]''',
'''SEC = P["footing_rebar" if globals().get("GATE") == "G3" else "coping_rebar"]'''),
])
print("patched")
@@ -0,0 +1,113 @@
"""Patch (2026-10-14, Okseong P1 G2): optional inner ring / per-zone hoop radius / 90-135 ties in
engine/rhino/column_rebar_ring.py and the matching optional checks in engine/rhino/checks/g2_ring.py.
Absent options -> the Ulsan code paths are unchanged. Backups: backup/2026-10-14_okseong_g2/."""
import shutil
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
BK = APP / "backup" / "2026-10-14_okseong_g2"
BK.mkdir(parents=True, exist_ok=True)
B = APP / "engine" / "rhino" / "column_rebar_ring.py"
C = APP / "engine" / "rhino" / "checks" / "g2_ring.py"
for f in (B, C):
if not (BK / f.name).exists():
shutil.copy2(f, BK / f.name)
def patch(path, reps):
s = path.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(B, [
('''includes the coupler).
"""''',
'''includes the coupler).
Optional (Okseong P1, 2026-10-14; absent -> unchanged): main.inner {mark, radius} second ring at the same angles
with the hook bent inwards; zone.radius per hoop zone; ties.shape "90-135" {leg, tail, y_lift, design_length}:
straight on the X / Y diameters, 90-deg leg down at one end, 135-deg hook at the other, ends swapped per level,
the Y tie y_lift above the X tie.
"""'''),
(''' f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; 배근도(1) 정면도 갈고리 R{R:.0f}·{m['hook_leg']:.0f}; 단면 DIVISION {m['count']}")
n += 1''',
''' f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; 배근도(1) 정면도 갈고리 R{R:.0f}·{m['hook_leg']:.0f}; 단면 DIVISION {m['count']}")
n += 1
if "inner" in m: # second ring, hook bent inwards (Okseong P1)
ri = m["inner"]["radius"]
z0 = m["hook_leg_z"]
leg = rg.LineCurve(pt(ri - R - m["hook_leg"], th, z0), pt(ri - R, th, z0))
bend = rg.ArcCurve(rg.Arc(pt(ri - R, th, z0),
pt(ri - R + R * math.sin(math.radians(45)), th, z0 + R - R * math.cos(math.radians(45))),
pt(ri, th, z0 + R)))
vert = rg.LineCurve(pt(ri, th, z0 + R), pt(ri, th, top))
c = rg.Curve.JoinCurves([leg, bend, vert], TOL)[0]
add_bar(c, m["inner"]["mark"], f"{i + 1:02d}", m["straight_len"] + math.pi / 2 * R + m["hook_leg"],
f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; inner ring r {ri:.0f}, hook inwards")
n += 1'''),
(''' mark, typ = zone["mark"], zone["type"]
for j, z in enumerate(zone["levels"]):''',
''' mark, typ = zone["mark"], zone["type"]
r = zone.get("radius", hp["radius"]) # per zone (Okseong P3 1,150 / P4 1,147)
for j, z in enumerate(zone["levels"]):'''),
(''' tie = cr["ties"]
half = tie["straight"] / 2''',
''' tie = cr["ties"]
if tie.get("shape") == "90-135": # Okseong P1 (2026-10-14), as column_rebar_straight.py
rb = min_bend_radius(MARK[tie["mark"]][0])
for q, z in enumerate(tie["levels"]):
for axis, name in ((0, "X"), (1, "Y")):
zt = z + (tie["y_lift"] if axis else 0)
u = rg.Vector3d(1, 0, 0) if axis == 0 else rg.Vector3d(0, 1, 0)
s = -1 if q % 2 else 1
a = rg.Point3d(0, 0, zt) - u * (s * tie["straight"] / 2)
b = rg.Point3d(0, 0, zt) + u * (s * tie["straight"] / 2)
d = -u * s * math.cos(math.radians(45)) + rg.Vector3d(0, 0, -1) * math.sin(math.radians(45))
pl = rg.PolylineCurve([a + rg.Vector3d(0, 0, -tie["leg"]), a, b, b + d * tie["tail"]])
c = rg.Curve.CreateFilletCornersCurve(pl, rb, TOL, math.radians(1)) or pl
add_bar(c, tie["mark"], f"L{q + 1:02d}{name}", tie["design_length"], hosts(zt),
f"EL {zt / 1000:.3f}; {name} (Y +{tie['y_lift']}, 90/135 교번 [추론])")
n += 1
doc.Views.Redraw()
print(json.dumps({"bars": n}))
return
half = tie["straight"] / 2'''),
])
patch(C, [
(''' check(f"{m} radius (mid point)", G["r_hoop"], math.hypot(p.X, p.Y), tol=0.5)''',
''' check(f"{m} radius (mid point)", G.get("r_hoop_by_mark", {}).get(m, G["r_hoop"]), math.hypot(p.X, p.Y), tol=0.5)
if "inner" in M: # Okseong P1: inner ring (2026-10-14)
im = M["inner"]
tops = [max((o.Geometry.PointAtStart, o.Geometry.PointAtEnd), key=lambda q: q.Z) for o in cl[im["mark"]]]
lows = [min((o.Geometry.PointAtStart, o.Geometry.PointAtEnd), key=lambda q: q.Z) for o in cl[im["mark"]]]
check(f"{im['mark']} inner ring radius (worst)", 0, max(abs(math.hypot(q.X, q.Y) - im["radius"]) for q in tops) if tops else 1e9, tol=0.5)
flag(f"{im['mark']} hooks bent inwards", bool(lows) and all(math.hypot(q.X, q.Y) < im["radius"] for q in lows))'''),
(''' tz = {}
for o in cl[tie_mark]:
tz.setdefault(round(o.Geometry.GetBoundingBox(True).Max.Z, 1), []).append(o)
lv = sorted(tz)
flag(f"tie levels {len(TIE_LEVELS)} x {TIE['per_level']}", len(lv) == len(TIE_LEVELS)
and all(len(v) == TIE["per_level"] for v in tz.values()), f"{len(lv)} levels")
check(f"tie levels vs chain + {TIE['offset']} (worst)", 0,
max(abs(a - (b + TIE["offset"])) for a, b in zip(lv, TIE_LEVELS)) if lv else 1e9)''',
''' if TIE.get("y_lift"): # Okseong P1: X tie at the level, Y tie lifted
occ_ = lambda o: o.Attributes.GetUserString("occurrence")
lx = sorted(round(o.Geometry.GetBoundingBox(True).Max.Z, 1) for o in cl[tie_mark] if occ_(o).endswith("X"))
ly = sorted(round(o.Geometry.GetBoundingBox(True).Max.Z, 1) for o in cl[tie_mark] if occ_(o).endswith("Y"))
flag(f"tie X / Y levels {len(TIE_LEVELS)} each", len(lx) == len(ly) == len(TIE_LEVELS), f"X {len(lx)} Y {len(ly)}")
check("tie X levels vs chain (worst)", 0, max(abs(a - b - TIE["offset"]) for a, b in zip(lx, TIE_LEVELS)) if lx else 1e9)
check(f"tie Y = X + {TIE['y_lift']} (worst)", 0, max(abs(b - a - TIE["y_lift"]) for a, b in zip(lx, ly)) if ly else 1e9)
else:
tz = {}
for o in cl[tie_mark]:
tz.setdefault(round(o.Geometry.GetBoundingBox(True).Max.Z, 1), []).append(o)
lv = sorted(tz)
flag(f"tie levels {len(TIE_LEVELS)} x {TIE['per_level']}", len(lv) == len(TIE_LEVELS)
and all(len(v) == TIE["per_level"] for v in tz.values()), f"{len(lv)} levels")
check(f"tie levels vs chain + {TIE['offset']} (worst)", 0,
max(abs(a - (b + TIE["offset"])) for a, b in zip(lv, TIE_LEVELS)) if lv else 1e9)'''),
])
print("patched")
@@ -0,0 +1,128 @@
"""Ulsan P2 (2026-10-15): footing rebar generator / checker extensions for a rotated foundation, rectangular hoops and explicit vertical
bar positions. Backups are made by the caller (backup/2026-10-15_울산P2전/). Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
GEN = APP / "engine" / "rhino" / "footing_rebar_mats.py"
CHK = APP / "engine" / "rhino" / "checks" / "g3_mats.py"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "Ulsan P2" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
GEN_PAIRS = [
(''' legs of length `leg` towards +`leg_axis` ("X"|"Y") at both ends (shape I with b, SD400 catalogue)
"""''', ''' legs of length `leg` towards +`leg_axis` ("X"|"Y") at both ends (shape I with b, SD400 catalogue)
Optional (Ulsan P2, 2026-10-15; absent -> unchanged):
footing.rot_deg all bars are built in the foundation's own axes and turned counter-clockwise about the pier centre
hoops.half_x / half_y rectangular hoop (default: square `half`)
verticals.points [[x, y], ...] explicit positions instead of the checkerboard; leg_bottom / leg_top leg lengths per end
(default `leg`); design length = a + leg_bottom + leg_top
"""'''),
('''def min_r(dia):''', '''ROT = math.radians(P["footing"].get("rot_deg", 0.0))
def turn(pts):
"""Foundation axes -> pier axes (counter-clockwise ROT about the origin)."""
if not ROT:
return pts
c, s = math.cos(ROT), math.sin(ROT)
return [(x * c - y * s, x * s + y * c, z) for x, y, z in pts]
def min_r(dia):'''),
(''' add_bar(poly(pts, MARK[g["mark"]][0]), g["mark"], f"{g['axis'].lower()}{v:+.0f}",''',
''' add_bar(poly(turn(pts), MARK[g["mark"]][0]), g["mark"], f"{g['axis'].lower()}{v:+.0f}",'''),
(''' s = hp["half"]
for zz in hp["levels"]:
z = FB + zz
pts = [(-s, 0, z), (-s, -s, z), (s, -s, z), (s, s, z), (-s, s, z), (-s, 0.0, z)]
add_bar(poly(pts, MARK[hp["mark"]][0]),''', ''' sx, sy = hp.get("half_x", hp.get("half")), hp.get("half_y", hp.get("half"))
for zz in hp["levels"]:
z = FB + zz
pts = [(-sx, 0, z), (-sx, -sy, z), (sx, -sy, z), (sx, sy, z), (-sx, sy, z), (-sx, 0.0, z)]
add_bar(poly(turn(pts), MARK[hp["mark"]][0]),'''),
(''' for i, x in enumerate(vt["grid_x"]):
for j, y in enumerate(vt["grid_y"]):
if (i + j) % 2 != vt["parity"]:
continue
z0, z1 = FB + vt["z0"], FB + vt["z0"] + vt["a"]
lx, ly = (vt["leg"], 0) if vt.get("leg_axis", "X") == "X" else (0, vt["leg"])
pts = [(x + lx, y + ly, z0), (x, y, z0), (x, y, z1), (x + lx, y + ly, z1)]
add_bar(poly(pts, MARK[vt["mark"]][0]), vt["mark"], f"x{x:+.0f}y{y:+.0f}", vt["a"] + 2 * vt["leg"], vt.get("src", ""))
n += 1''', ''' if "points" in vt:
where = [tuple(q) for q in vt["points"]]
else:
where = [(x, y) for i, x in enumerate(vt["grid_x"]) for j, y in enumerate(vt["grid_y"]) if (i + j) % 2 == vt["parity"]]
lb, lt = vt.get("leg_bottom", vt.get("leg", 0)), vt.get("leg_top", vt.get("leg", 0))
ux, uy = (1, 0) if vt.get("leg_axis", "X") == "X" else (0, 1)
for x, y in where:
z0, z1 = FB + vt["z0"], FB + vt["z0"] + vt["a"]
pts = [(x + ux * lb, y + uy * lb, z0), (x, y, z0), (x, y, z1), (x + ux * lt, y + uy * lt, z1)]
add_bar(poly(turn(pts), MARK[vt["mark"]][0]), vt["mark"], f"x{x:+.0f}y{y:+.0f}", vt["a"] + lb + lt, vt.get("src", ""))
n += 1'''),
]
CHK_PAIRS = [
('''Checks (C9):''', '''Optional (Ulsan P2, 2026-10-15; absent -> unchanged): G1.footing.rot_deg (bars are checked after turning their centrelines back by the
foundation rotation), hoops.half_x / half_y, verticals {exclude {x, y} (grid points with |x| <= x and |y| <= y are absent), legs {bottom, top}}.
Checks (C9):'''),
('''results = []
def check(item''', '''ROT = X["G1"]["footing"].get("rot_deg", 0.0)
results = []
def bbx(o):
"""Bounding box of the bar centreline in the foundation's own axes."""
g = o.Geometry
if ROT:
g = g.DuplicateCurve()
g.Transform(rg.Transform.Rotation(-math.radians(ROT), rg.Vector3d.ZAxis, rg.Point3d.Origin))
return g.GetBoundingBox(True)
def check(item'''),
(''' bb = o.Geometry.GetBoundingBox(True)
leg_down''', ''' bb = bbx(o)
leg_down'''),
(''' hz = sorted(round(o.Geometry.GetBoundingBox(True).Min.Z - FB, 1) for o in cl[hp["mark"]])''',
''' hz = sorted(round(bbx(o).Min.Z - FB, 1) for o in cl[hp["mark"]])'''),
(''' bb = cl[hp["mark"]][0].Geometry.GetBoundingBox(True)
check(f"{hp['mark']} centreline size X [derived]", 2 * hp["half"], bb.Max.X - bb.Min.X)
check(f"{hp['mark']} centreline size Y [derived]", 2 * hp["half"], bb.Max.Y - bb.Min.Y)''',
''' bb = bbx(cl[hp["mark"]][0])
check(f"{hp['mark']} centreline size X [derived]", 2 * hp.get("half_x", hp.get("half")), bb.Max.X - bb.Min.X)
check(f"{hp['mark']} centreline size Y [derived]", 2 * hp.get("half_y", hp.get("half")), bb.Max.Y - bb.Min.Y)'''),
(''' pts = {(round(o.Geometry.GetBoundingBox(True).Min.X), round(o.Geometry.GetBoundingBox(True).Min.Y)) for o in cl[vt["mark"]]}
exp = {(round(x), round(y)) for i, x in enumerate(gx) for j, y in enumerate(gy) if (i + j) % 2 == vt["parity"]}
flag(f"{vt['mark']} positions = checkerboard of {len(gx)}x{len(gy)} grid [추론]", pts == exp, f"{len(pts)} / {len(exp)}")''',
''' pts = {(round(bbx(o).Min.X), round(bbx(o).Min.Y)) for o in cl[vt["mark"]]}
if "exclude" in vt: # Ulsan P2: grid minus the central rectangle
ex = vt["exclude"]
exp = {(round(x), round(y)) for x in gx for y in gy if not (abs(x) <= ex["x"] and abs(y) <= ex["y"])}
flag(f"{vt['mark']} positions = {len(gx)}x{len(gy)} grid minus |x|<={ex['x']} & |y|<={ex['y']} [추론]", pts == exp, f"{len(pts)} / {len(exp)}")
else:
exp = {(round(x), round(y)) for i, x in enumerate(gx) for j, y in enumerate(gy) if (i + j) % 2 == vt["parity"]}
flag(f"{vt['mark']} positions = checkerboard of {len(gx)}x{len(gy)} grid [추론]", pts == exp, f"{len(pts)} / {len(exp)}")'''),
(''' max(abs(o.Geometry.GetBoundingBox(True).Min.Z - FB - vt["z0"]) for o in cl[vt["mark"]]))
bb = cl[vt["mark"]][0].Geometry.GetBoundingBox(True)
check(f"{vt['mark']} vertical extent a", vt["a"], bb.Max.Z - bb.Min.Z)''',
''' max(abs(bbx(o).Min.Z - FB - vt["z0"]) for o in cl[vt["mark"]]))
bb = bbx(cl[vt["mark"]][0])
check(f"{vt['mark']} vertical extent a", vt["a"], bb.Max.Z - bb.Min.Z)
if "legs" in vt: # both legs run towards +Y from the same point: Y extent = the longer leg
check(f"{vt['mark']} leg extent along Y (longer leg)", max(vt["legs"].values()), bb.Max.Y - bb.Min.Y)'''),
]
if __name__ == "__main__":
patch(GEN, GEN_PAIRS)
patch(CHK, CHK_PAIRS)
print("patched", GEN.name, CHK.name)
@@ -0,0 +1,55 @@
"""Stage B (2026-10-16): web/store.py gets the app-check storage (autocheck.json) and the fixed-field snapshot in the origin record.
Backup of the stage-A state is made by the caller. Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "store.py"
s = P.read_text(encoding="utf-8")
assert "def run_autocheck" not in s, "already patched"
pairs = [
('from engine import variability # noqa: E402', 'from engine import variability, verify # noqa: E402'),
(''' origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()}}''',
''' origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()},
"fixed": {f"{w}:{k}": variability.fixed_view(v, (var["sections"].get(f"{w}:{k}") or {}).get("fixed_fields", []))
for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()
if (var["sections"].get(f"{w}:{k}") or {}).get("fixed_fields")}}'''),
(''' def section_state(self, which, key):''', ''' # ---- app check (stage B): autocheck.json is separate from confirm.json; the app never writes confirmations
def autocheck(self):
return rjson(self.dir / "autocheck.json", {})
def _autocheck_basis(self):
return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts())])
def autocheck_current(self):
a = self.autocheck()
return bool(a) and a.get("basis") == self._autocheck_basis()
def run_autocheck(self, force=False):
"""Run the app check of every bundle (no gate, no Rhino) and store the result; skipped when nothing changed."""
if not force and self.autocheck_current():
return self.autocheck()
P0, E0 = self.load("params"), self.load("expected")
meta = {f"{w}:{k}": self.section_meta(w, k) for w, d in (("params", P0), ("expected", E0)) for k in d}
cur_fixed = {b: variability.fixed_view((P0 if b.startswith("params:") else E0)[b.split(":", 1)[1]], m["fixed_fields"])
for b, m in meta.items() if m["fixed_fields"]}
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed)
sections = {b: dict(r, sha=sha((P0 if b.startswith("params:") else E0)[b.split(":", 1)[1]])) for b, r in res.items()}
out = {"version": verify.VERSION, "at": now(), "basis": self._autocheck_basis(), "sections": sections}
wjson(self.dir / "autocheck.json", out)
return out
def section_check(self, which, key):
"""Stored app-check result of a bundle: {"st", "checks", "summary", "stale"}; st "?" when never checked."""
r = (self.autocheck().get("sections") or {}).get(f"{which}:{key}")
if not r:
return {"st": "?", "checks": [], "summary": "앱 확인 전", "stale": False}
d = self.load(which)
return dict(r, stale=key not in d or r.get("sha") != sha(d[key]))
def section_state(self, which, key):'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched store.py")
@@ -0,0 +1,84 @@
"""Stage B (2026-10-16): pier page section 2 in Prism (summary strip, app-check column, evidence link, 일치 묶음 일괄 확정), prism.css link,
reason field on the section confirm form. The old 필요한 묶음 일괄 확정 form stays, folded away as legacy (stage D decides)."""
from pathlib import Path
T = Path(__file__).resolve().parents[2] / "web" / "templates"
# ---- base.html
p = T / "base.html"
s = p.read_text(encoding="utf-8")
assert "prism.css" not in s
a = "<title>{% block title %}T형 교각 모델링{% endblock %}</title>\n"
assert s.count(a) == 1
s = s.replace(a, a + '<link rel="stylesheet" href="{{ url_for(\'static\', filename=\'prism.css\') }}">\n')
p.write_text(s, encoding="utf-8")
# ---- pier.html: replace everything between the stage-A marker and the gates heading
p = T / "pier.html"
s = p.read_text(encoding="utf-8")
i = s.index("<!-- stage A 2026-10-16 -->")
j = s.index("<h2>3. 게이트</h2>")
new = '''<!-- stage A 2026-10-16 / stage B 2026-10-16 -->
{% from "_prism.html" import st_badge, human_badge %}
<div class="prism" id="bundles">
<div class="stack">
<section class="strip" aria-label="요약">
<p class="concl">필요 {{ nreq }}개 중 앱 확인 일치 {{ cnt.g }} · 주의 {{ cnt.w }} · 불일치 {{ cnt.b }} · 근거 없음 {{ cnt.n }} · 해당 없음 {{ cnt.x }} · 사람 확정 {{ human }}</p>
<div class="row">
<form method="post" action="{{ url_for('autocheck_run', name=p.name) }}" style="margin:0"><button class="btn" type="submit">전체 확인</button></form>
{% if nmatch %}<a class="btn primary" href="{{ url_for('confirm_matched', name=p.name) }}">일치 묶음 일괄 확정 ({{ nmatch }}개)</a>
{% else %}<span class="cap muted">확정할 '일치' 묶음이 없습니다.</span>{% endif %}
</div>
<p class="cap">앱 확인은 도면 표·재료표 수량·고정 항목을 앱이 대조한 결과이며 <b>앱은 확정하지 않습니다</b>. '일치'는 "독립 근거와 값이 같다"는 뜻이지 "도면이 맞다"는 뜻이 아닙니다.
<b>변수 등급</b> V1 값 · V2 목록 · V3 생성(화면 편집 차단) · F 고정(바꾸면 사유 필요).</p>
</section>
{% if origin_same %}
<div class="banner" role="alert"><span class="m" aria-hidden="true">▲</span><div>템플릿 원본 그대로인 필수 묶음 {{ origin_same|length }}개 — 템플릿을 복사한 뒤 한 번도 바뀌지 않았습니다. 새 도면과 대조(후보 반영·값 수정)하기 전에 확정하면 원본 교각의 모델이 PASS로 나올 수 있습니다:
{% for s in origin_same %}<a href="{{ url_for('evidence', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</div></div>
{% elif not has_origin %}<p class="cap muted">이 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로' 여부를 표시하지 않습니다.</p>{% endif %}
<div class="tw"><table class="t">
<caption>입력 묶음 {{ sections|length }}개 — 앱 확인이 나쁜 것부터</caption>
<thead><tr><th scope="col">묶음</th><th scope="col">변수 등급</th><th scope="col">값 출처</th><th scope="col">앱 확인</th><th scope="col">사람 확정</th></tr></thead>
<tbody>
{% for s in sections %}
<tr>
<td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not s.required %} <span class="cap muted">(필수 아님)</span>{% endif %}</td>
<td>{% if s.info %}<span class="muted">참고</span>{% else %}<span title="{{ s.meta.note }}">{{ s.meta.grade }} {{ grades[s.meta.grade] }}</span>{% endif %}</td>
<td>{% if s.meta.origin and s.required %}<span class="b warn"><span class="m" aria-hidden="true">▲</span>템플릿 원본 그대로</span>{% elif s.meta.origin %}<span class="muted">템플릿 원본 그대로</span>{% else %}{{ s.meta.source|join(' · ') }}{% endif %}</td>
<td>{{ st_badge(s.chk.st) }} <span class="cap">{{ s.chk.summary }}</span> <a class="cap" href="{{ url_for('evidence', name=p.name, which=s.file, key=s.key) }}">근거 보기</a></td>
<td>{{ human_badge(s) }}{% if s.by %} <span class="cap muted">{{ s.by }} · {{ s.at }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div>
{% if need %}
<details>
<summary class="muted">앱 확인과 상관없이 필요한 묶음을 모두 확정 (이전 방식 · 권장하지 않음)</summary>
<form method="post" action="{{ url_for('confirm_all', name=p.name) }}"
onsubmit="return confirm('게이트에 필요한 미확정 묶음 {{ need|length }}개를 모두 확정합니다. 앱 확인 결과와 상관없이 확정 기록이 찍힙니다. 값을 도면·판독서와 대조하셨습니까?');">
확정자 <input type="text" name="by" required>
<button>필요한 묶음 일괄 확정 ({{ need|length }}개)</button>
<span class="muted">게이트 G1~G5에 필요한 묶음 중 미확정인 것을 앱 확인 없이 모두 확정합니다(도장). 위의 '일치 묶음 일괄 확정'을 쓰세요.</span>
</form>
</details>
{% else %}<p class="ok">게이트에 필요한 묶음이 모두 확정되어 있습니다.</p>{% endif %}
'''
s = s[:i] + new + s[j:]
p.write_text(s, encoding="utf-8")
# ---- section.html: link to the evidence page and the reason field of the confirm form
p = T / "section.html"
s = p.read_text(encoding="utf-8")
a = ''' <input type="hidden" name="next" value="{{ url_for('section', name=p.name, which=which, key=key) }}">
<button>저장된 값으로 확정</button>'''
assert s.count(a) == 1
b = ''' <input type="hidden" name="next" value="{{ url_for('section', name=p.name, which=which, key=key) }}">
사유 <input type="text" name="why" size="50" placeholder="앱 확인이 '일치'가 아니면 필수">
<button>저장된 값으로 확정</button> <a href="{{ url_for('evidence', name=p.name, which=which, key=key) }}">근거 보기</a>'''
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
print("patched templates")
@@ -0,0 +1,162 @@
"""Stage B (2026-10-16): confirmation records carry the reason and the app-check status, routes for the app check, the evidence page and the
"일치 묶음 일괄 확정" (D4), the report gets the app-check summary. The old 필요한 묶음 일괄 확정 route stays (legacy, stage D decides).
Backups of the stage-A state are made by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "# stage B 2026-10-16" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
STORE = [
(''' return {"exists": True, "confirmed": ok, "by": c and c["by"], "at": c and c["at"],
"stale": bool(c) and not ok, "info": is_info(key)}''',
''' return {"exists": True, "confirmed": ok, "by": c and c["by"], "at": c and c["at"], "why": c and c.get("why"),
"app_st": c and c.get("app_st"), "stale": bool(c) and not ok, "info": is_info(key)}'''),
(''' def confirm(self, which, key, by):
d = self.load(which)
conf = self.confirmations()
conf[f"{which}:{key}"] = {"sha": sha(d[key]), "by": by, "at": now()}''',
''' def confirm(self, which, key, by, why="", app_st=""):
"""Human confirmation record (stage B 2026-10-16: + the reason `why` and the app-check status at that time `app_st`)."""
d = self.load(which)
conf = self.confirmations()
rec = {"sha": sha(d[key]), "by": by, "at": now()}
if why:
rec["why"] = why
if app_st:
rec["app_st"] = app_st
conf[f"{which}:{key}"] = rec'''),
]
APPPY = [
("from engine import recommend, reader, report_files, variability # noqa: E402",
"from engine import recommend, reader, report_files, variability, verify # noqa: E402"),
('''@app.post("/pier/<name>/section/<which>/<key>/confirm")
def section_confirm(name, which, key):
p = pier_or_404(name)
by = request.form.get("by", "").strip()
if not by:
flash("확정자 이름을 입력하세요.", "fail")
else:
p.confirm(which, key, by)
flash(f"{which}.{key} 확정 ({by})", "ok")
return redirect(request.form.get("next") or url_for("pier", name=name))''',
'''@app.post("/pier/<name>/section/<which>/<key>/confirm")
def section_confirm(name, which, key):
"""Confirm one bundle. A bundle the app check does not report as 일치 (주의 / 불일치 / 근거 없음) needs a reason (D3, stage B 2026-10-16)."""
p = pier_or_404(name)
by = request.form.get("by", "").strip()
why = request.form.get("why", "").strip()
p.run_autocheck()
st = p.section_check(which, key)["st"]
if not by:
flash("확정자 이름을 입력하세요.", "fail")
elif st in ("w", "b", "n") and not why:
flash(f"{which}.{key}는 앱 확인이 '{verify.ST_NAME[st]}'입니다. 사유를 입력해야 확정할 수 있습니다.", "fail")
else:
p.confirm(which, key, by, why, st)
flash(f"{which}.{key} 확정 ({by})" + (f" — 앱 확인 {verify.ST_NAME.get(st, st)}, 사유 기록" if why else ""), "ok")
return redirect(request.form.get("next") or url_for("pier", name=name))
# ---------------- app check (stage B 2026-10-16)
@app.post("/pier/<name>/autocheck")
def autocheck_run(name):
p = pier_or_404(name)
a = p.run_autocheck(force=True)
flash(f"앱 확인을 실행했습니다 ({len(a['sections'])}개 묶음). 앱은 확정하지 않습니다.", "ok")
return redirect(request.form.get("next") or url_for("pier", name=name))
@app.route("/pier/<name>/evidence/<which>/<key>")
def evidence(name, which, key):
p = pier_or_404(name)
if which not in ("params", "expected") or key not in p.load(which):
abort(404)
p.run_autocheck()
return render_template("evidence.html", p=p, which=which, key=key, chk=p.section_check(which, key), st=p.section_state(which, key),
meta=p.section_meta(which, key), grades=variability.GRADES, ST=verify.ST_NAME)
def matched_targets(p):
"""Required bundles the app check reports as 일치 and nobody has confirmed (current): the D4 bulk-confirm targets."""
p.run_autocheck()
out = []
for f, k in sorted(required(p)):
c, s = p.section_check(f, k), p.section_state(f, k)
if c["st"] == "g" and not c["stale"] and not s["confirmed"]:
out.append((f, k))
return out
def _targets_sha(p, targets):
return store.sha([[f, k, p.section_check(f, k)["sha"], p.section_check(f, k)["st"]] for f, k in targets])
@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])
def confirm_matched(name):
"""D4: bulk confirmation of the bundles the app check reports as 일치. The evidence table must have been opened (a marker holds the
hash of the target list), a name is required; anything the app does not report as 일치 is not touched."""
p = pier_or_404(name)
targets = matched_targets(p)
if request.method == "POST":
by = request.form.get("by", "").strip()
mark = store.rjson(p.dir / "confirm_view.json", {})
if not targets:
flash("확정할 일치 묶음이 없습니다.", "fail")
elif mark.get("sha") != _targets_sha(p, targets):
flash("근거표를 열어 끝까지 읽은 뒤에 확정할 수 있습니다(대상 목록이 바뀌었으면 다시 여세요).", "fail")
elif not by:
flash("확정자 이름을 입력하세요.", "fail")
else:
for f, k in targets:
p.confirm(f, k, by, "", "g")
flash(f"앱 확인 '일치' 묶음 {len(targets)}개를 {by}이(가) 확정했습니다. 확정 기록에는 입력한 이름만 남습니다.", "ok")
return redirect(url_for("pier", name=name))
return redirect(url_for("confirm_matched", name=name))
store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
rest = []
for f, k in sorted(required(p)):
if (f, k) in targets:
continue
s = p.section_state(f, k)
if not s["confirmed"]:
rest.append(dict(file=f, key=k, chk=p.section_check(f, k)))
return render_template("confirm_matched.html", p=p, targets=[dict(file=f, key=k, chk=p.section_check(f, k)) for f, k in targets],
rest=rest, ST=verify.ST_NAME)'''),
(''' secs = []
req = required(p)
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
required=(s["file"], s["key"]) in req))''',
''' secs = []
req = required(p)
p.run_autocheck() # stage B: skipped when nothing changed
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
required=(s["file"], s["key"]) in req, chk=p.section_check(s["file"], s["key"])))'''),
(''' origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16''',
''' origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16
cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16
for s in secs:
if s["required"]:
cnt[s["chk"]["st"] if s["chk"]["st"] in cnt else "n"] += 1
human = sum(1 for s in secs if s["required"] and s["confirmed"])
nmatch = len(matched_targets(p))'''),
(''' has_origin=bool(p.info.get("origin")), grades=variability.GRADES,''',
''' has_origin=bool(p.info.get("origin")), grades=variability.GRADES, cnt=cnt, human=human, nreq=len(req),
nmatch=nmatch, ST=verify.ST_NAME,'''),
]
if __name__ == "__main__":
patch(APP / "web" / "store.py", STORE)
patch(APP / "web" / "app.py", APPPY)
print("patched store.py, app.py")
@@ -0,0 +1,26 @@
"""Stage B (2026-10-16), second part: the "근거표를 끝까지 읽었다" marker is written by a separate POST (sent by the page when the evidence
table has been scrolled to its end), not by merely opening the page (D4)."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "app.py"
s = P.read_text(encoding="utf-8")
a = ''' store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
rest = []'''
b = ''' rest = []'''
assert s.count(a) == 1
s = s.replace(a, b)
a2 = '''@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])'''
b2 = '''@app.post("/pier/<name>/confirm_matched/read")
def confirm_matched_read(name):
"""Marker: the evidence table of the current target list was read to its end (the page posts this when the scroll box is at the bottom)."""
p = pier_or_404(name)
targets = matched_targets(p)
store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
return {"ok": True, "n": len(targets)}
@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])'''
assert s.count(a2) == 1
s = s.replace(a2, b2)
P.write_text(s, encoding="utf-8")
print("patched")
@@ -0,0 +1,35 @@
"""Stage B (2026-10-16): report chapter 8 gets "8.1 입력 묶음 앱 확인 결과 요약" (app-check status of every required bundle next to the
human confirmation, so the basis of each confirmation is on record). Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "engine" / "report.py"
s = P.read_text(encoding="utf-8")
assert "8.1 입력 묶음" not in s
pairs = [
(''' "gates": {}, "results": {}}
for g in gate_ids():''', ''' "gates": {}, "results": {}, "autocheck": _rj(pier_dir / "autocheck.json", {}), "confirm": _rj(pier_dir / "confirm.json", {})}
for g in gate_ids():'''),
(''' ""] + _table(["게이트", "재로드", "음성 시험", "앞 게이트 객체"], rows) + [""]
''', ''' ""] + _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) + [""]
'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched report.py")
@@ -0,0 +1,46 @@
"""Stage A of the 입력 묶음 확인·확정 개편 (2026-10-16): web/store.py gets the variability classes, the template-origin record and the
bundle metadata. Backup (backup/2026-10-16_묶음개편A전/) is made by the caller. Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "store.py"
s = P.read_text(encoding="utf-8")
assert "from engine import variability" not in s, "already patched"
pairs = [
("import hashlib\nimport json\nimport os\nimport re\nimport shutil\nimport threading\nimport time\nfrom pathlib import Path\n",
"import hashlib\nimport json\nimport os\nimport re\nimport shutil\nimport sys\nimport threading\nimport time\nfrom pathlib import Path\n"),
('APP = Path(__file__).resolve().parents[1]\nDATA =',
'APP = Path(__file__).resolve().parents[1]\nif str(APP) not in sys.path:\n sys.path.insert(0, str(APP))\nfrom engine import variability # noqa: E402\n\nDATA ='),
(' wjson(d / "pier.json", {"name": name, "type": ptype, "created": now(), "template": label})\n return Pier(name)',
''' P0, E0 = rjson(d / "params.json"), rjson(d / "expected.json")
var = rjson(src_dir / "variability.json") or variability.build(P0, E0)
wjson(d / "variability.json", var)
# origin record: hashes of every bundle as copied, so a bundle that is still identical later is "템플릿 원본 그대로"
origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()}}
wjson(d / "pier.json", {"name": name, "type": ptype, "created": now(), "template": label, "origin": origin})
return Pier(name)'''),
(' def section_state(self, which, key):',
''' def variability(self):
"""Variability classes of this pier's bundles (own variability.json, else classified from the current values)."""
v = rjson(self.dir / "variability.json")
if v and v.get("sections"):
return v
return variability.build(self.load("params"), self.load("expected"))
def section_meta(self, which, key):
"""Grade / source / fixed fields of a bundle and whether it is still the template's original value.
origin is None for piers created before the origin record existed (unknown, not claimed either way)."""
d = self.load(which)
m = dict(self.variability()["sections"].get(f"{which}:{key}") or variability.classify(which, key, d.get(key)))
if which == "params" and key in ("coping_rebar", "footing_rebar") and key in d:
m = variability.classify(which, key, d[key]) # V2 / V3 follows the current content (explicit bars list)
o = (self.info.get("origin") or {}).get("sha", {}).get(f"{which}:{key}")
m["origin"] = None if o is None or key not in d else (o == sha(d[key]))
return m
def section_state(self, which, key):'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched", P)
@@ -0,0 +1,49 @@
"""Stage A (2026-10-16): pier page columns (변수 등급 / 값 출처) and origin notice, section page grade + reason field."""
from pathlib import Path
T = Path(__file__).resolve().parents[2] / "web" / "templates"
pier, sec = T / "pier.html", T / "section.html"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "stage A" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(pier, [
('''<p class="muted">각 묶음을 열어 값을 도면과 대조·수정한 뒤 확정하세요. 확정 뒤 값이 바뀌면 자동으로 미확정이 됩니다.</p>
<div class="wrap"><table>
<tr><th>파일</th><th>묶음</th><th>상태</th><th>확정자</th><th>확정 시각</th></tr>''',
'''<!-- stage A 2026-10-16 -->
<p class="muted">각 묶음을 열어 값을 도면과 대조·수정한 뒤 확정하세요. 확정 뒤 값이 바뀌면 자동으로 미확정이 됩니다.
<b>변수 등급</b>: V1 값 · V2 목록 · V3 생성(화면 편집 차단) · F 고정(바꾸면 사유 필요). <b>값 출처</b>는 값이 보통 어디서 오는지입니다.</p>
{% if origin_same %}
<p class="warn">▲ 템플릿 원본 그대로인 필수 묶음 {{ origin_same|length }}개 — 템플릿을 복사한 뒤 한 번도 바뀌지 않았습니다. 새 도면과 대조(후보 반영·값 수정)하기 전에 확정하면 원본 교각의 모델이 PASS로 나올 수 있습니다:
{% for s in origin_same %}<a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</p>
{% elif not has_origin %}<p class="muted">이 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로' 여부를 표시하지 않습니다.</p>{% endif %}
<div class="wrap"><table>
<tr><th>파일</th><th>묶음</th><th>변수 등급</th><th>값 출처</th><th>상태</th><th>확정자</th><th>확정 시각</th></tr>'''),
(''' <td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.key }}</a></td>
''', ''' <td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.key }}</a></td>
<td>{% if s.info %}<span class="muted">참고</span>{% else %}<span title="{{ s.meta.note }}">{{ s.meta.grade }} {{ grades[s.meta.grade] }}</span>{% endif %}</td>
<td>{% if s.meta.origin and s.required %}<span class="warn">▲ 템플릿 원본 그대로</span>{% elif s.meta.origin %}<span class="muted">템플릿 원본 그대로</span>{% else %}{{ s.meta.source|join(' · ') }}{% endif %}</td>
'''),
])
patch(sec, [
('''<p class="muted">{% if which == 'expected' %}''', '''<!-- stage A 2026-10-16 -->
<p><b>{{ meta.grade }} {{ grades[meta.grade] }}</b> · 값 출처 {{ meta.source|join(' · ') }}{% if meta.origin %} · <span class="warn">▲ 템플릿 원본 그대로</span>{% endif %}
<span class="muted"> — {{ meta.note }}{% if meta.fixed_fields %} (고정 항목: {{ meta.fixed_fields|join(', ') }}){% endif %}</span></p>
{% if meta.grade == 'V3' %}<p class="fail">■ 생성 변수(V3)는 화면에서 직접 고칠 수 없습니다. 판독 스크립트를 다시 실행해 목록을 만든 뒤 반영하세요. 저장 단추는 거부됩니다.</p>
{% elif meta.grade == 'F' or meta.fixed_fields %}<p class="warn">▲ 이 묶음에는 고정(F) 항목이 있습니다. 바꾸면 같은 템플릿이 아닐 수 있으므로 <b>바꾸는 사유</b>를 입력해야 저장되고, 사유는 기록됩니다.</p>{% endif %}
<p class="muted">{% if which == 'expected' %}'''),
(''' <textarea name="value" spellcheck="false">{{ text }}</textarea>
<p><button>저장</button></p>''', ''' <textarea name="value" spellcheck="false">{{ text }}</textarea>
{% if meta.grade == 'F' or meta.fixed_fields %}<p>고정 항목을 바꾸는 사유 <input type="text" name="reason" size="60"></p>{% endif %}
<p><button>저장</button></p>'''),
])
print("patched templates")
@@ -0,0 +1,81 @@
"""Stage A (2026-10-16): web/app.py pier page metadata, F-field edit rule (D3) and V3 edit block; web/store.py edit log.
Backup in backup/2026-10-16_묶음개편A전/. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
A, S = APP / "web" / "app.py", APP / "web" / "store.py"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "# stage A 2026-10-16" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(S, [(
' def set_section(self, which, key, value):',
''' def log_fixed_edit(self, which, key, paths, reason):
"""Record of an edit of fixed (F) fields with its reason (D3) # stage A 2026-10-16"""
log = rjson(self.dir / "edit_log.json", [])
log.append({"at": now(), "section": f"{which}:{key}", "fixed_fields": paths, "reason": reason})
wjson(self.dir / "edit_log.json", log)
def set_section(self, which, key, value):''')])
patch(A, [
("from engine import recommend, reader, report_files # noqa: E402", "from engine import recommend, reader, report_files, variability # noqa: E402"),
(''' secs = []
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"])))
''', ''' secs = []
req = required(p)
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
required=(s["file"], s["key"]) in req))
origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16
'''),
(''' return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p),''',
''' return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p), origin_same=origin_same,
has_origin=bool(p.info.get("origin")), grades=variability.GRADES,'''),
(''' text = json.dumps(d[key], ensure_ascii=False, indent=1)
if request.method == "POST":
text = request.form["value"]
try:
p.set_section(which, key, json.loads(text))
flash("저장했습니다. 확정하려면 '확정'을 누르세요.", "ok")
return redirect(url_for("section", name=name, which=which, key=key))
except (ValueError, json.JSONDecodeError) as e:
flash(f"저장 안 됨: {e}", "fail")
return render_template("section.html", p=p, which=which, key=key, text=text, st=p.section_state(which, key))''',
''' text = json.dumps(d[key], ensure_ascii=False, indent=1)
meta = p.section_meta(which, key)
if request.method == "POST":
text = request.form["value"]
reason = request.form.get("reason", "").strip()
try:
new = store.expand_chains(json.loads(text))
if meta["grade"] == "V3":
raise ValueError("생성 변수(V3)는 화면에서 직접 고칠 수 없습니다. 판독 스크립트를 다시 실행해 목록을 만든 뒤 반영하세요")
fixed = ["(묶음 전체)"] if meta["grade"] == "F" and new != d[key] else variability.changed_fixed(d[key], new, meta["fixed_fields"])
if fixed and not reason:
raise ValueError(f"고정(F) 항목({', '.join(fixed)})을 바꾸면 같은 템플릿이 아닐 수 있습니다. 바꾸는 사유를 입력하세요")
p.set_section(which, key, new)
if fixed:
p.log_fixed_edit(which, key, fixed, reason)
flash("저장했습니다. 확정하려면 '확정'을 누르세요." + (" 고정 항목 변경 사유를 기록했습니다." if fixed else ""), "ok")
return redirect(url_for("section", name=name, which=which, key=key))
except (ValueError, json.JSONDecodeError) as e:
flash(f"저장 안 됨: {e}", "fail")
return render_template("section.html", p=p, which=which, key=key, text=text, st=p.section_state(which, key), meta=meta,
grades=variability.GRADES)'''),
('''def unconfirmed(p):''', '''def required(p):
"""Bundles any gate of the pier's scope needs (existing ones)."""
return {(r["file"], r["key"]) for g in p.scope() for r in p.gate_requirements(g) if r["exists"]}
def unconfirmed(p):'''),
])
print("patched app.py, store.py")
@@ -0,0 +1,84 @@
"""Stage B fixes after the first run on the real piers (2026-10-16): C6 false positives on directional values (pitch < 0, translation checks),
C7 for fixed bundles of piers without an origin record (compare with the protected templates), status summaries."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
V, S = APP / "engine" / "verify.py", APP / "web" / "store.py"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(V, [
('''SIZE_KEYS = re.compile(r"^(size_[xy]|thickness|diameter|d|height|length|width_y|plan_length|top_length|dx|dy|half|count_[xy]|nx|ny|pitch.*|dia|"
r"half_.*|bottom_flat_half|tip_half|top_half|soffit_rise|margin)$")''',
'''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'''),
(''' elif isinstance(v, (int, float)) and not isinstance(v, bool):
if SIZE_KEYS.match(name) and v <= 0:''',
''' 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:'''),
('''def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None):''',
'''def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None):'''),
(''' origin_fixed = origin_fixed or {}
edit_log = edit_log or []''', ''' origin_fixed = origin_fixed or {}
edit_log = edit_log or []
template_values = template_values or {}'''),
(''' if m["grade"] == "F":
if m.get("origin") is None:
checks.append(chk("C7", "변수 등급 위반", "n", "템플릿 원본 기록이 없어 고정 항목을 비교하지 못했다"))''',
''' 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", "템플릿 원본 기록이 없어 고정 항목을 비교하지 못했다"))'''),
('''def summarize(st, checks):
if st == "x":
return "사람 결정 기록 또는 참고 정보 — 앱이 도면과 대조하지 않는다"
key = [c for c in checks if c["st"] in ("b", "w", "n") and c["id"] != "C1"] or [c for c in checks if c["st"] == "g"]
return (key[0]["id"] + " " + key[0]["text"]) if key else ""''',
'''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 "앱이 독립적으로 대조할 근거가 없다 — 도면과 직접 대조한 뒤 사유와 함께 확정"'''),
])
patch(S, [
(''' res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed)''',
''' tvals = {} # fixed bundles: value in every protected template (for piers without origin record)
for t in templates():
td = template_dir(t["name"])
for w in ("params", "expected"):
tdoc = rjson(td / f"{w}.json", {})
for k, v in tdoc.items():
if meta.get(f"{w}:{k}", {}).get("grade") == "F":
tvals.setdefault(f"{w}:{k}", {})[t["name"]] = json.dumps(v, sort_keys=True, ensure_ascii=False)
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals)'''),
])
print("patched")
@@ -0,0 +1,67 @@
"""Stage C part 2 (2026-10-19): table cross-check of the anchor candidates. A field that has a table value (ELEVATION / 솔플레이트 / 철근재료표) is
confirmed by it: the anchor candidate equal to the table value becomes grade A ("표와 도면 문자 일치"), one that differs is lowered to grade C and
carries the table value. Measured on the real piers Ungok P3 / P4 (frame of the template): grade B was right in 68 % of the fields that have a table
value, mostly the schedule lengths of marks that share a value. Backup by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "engine" / "anchors.py", [
('''def chains(texts):''', '''def table_values(proposal_rows):
"""{field: value} of the table candidates (engine.reader.proposals rows); list candidates are expanded per index (schedule [L, n])."""
out = {}
for r in proposal_rows:
field = f"{r['file']}:" + ".".join(str(x) for x in r["path"])
c = r["candidate"]
if isinstance(c, (list, tuple)):
for i, v in enumerate(c):
if isinstance(v, (int, float)) and not isinstance(v, bool):
out[f"{field}.{i}"] = v
elif isinstance(c, (int, float)) and not isinstance(c, bool):
out[field] = c
return out
def cross_check(cand, table):
"""Confirm / correct anchor candidates with table values of the same field: equal -> grade A (table_confirmed), different -> grade C with
`table` = the table value. Fields without a table value keep their grade. Works on the dict returned by follow()."""
for f, c in cand.items():
t = table.get(f)
if t is None:
continue
c["table"] = t
if abs(abs(t) - abs(c["value"])) <= 1.5:
c["grade"], c["table_confirmed"] = "A", True
else:
c["grade"], c["table_confirmed"] = "C", False
return cand
def chains(texts):'''),
])
patch(APP / "web" / "store.py", [
(''' cand = anchors.follow(anc, texts, own)
docs = {"params": self.load("params"), "expected": self.load("expected")}''',
''' cand = anchors.follow(anc, texts, own)
docs = {"params": self.load("params"), "expected": self.load("expected")}
tabs = reader.table_candidates(texts)
anchors.cross_check(cand, anchors.table_values(reader.proposals(reader.field_candidates(tabs), docs["params"], docs["expected"])) if tabs else {})'''),
('from engine import anchors, variability, verify # noqa: E402', 'from engine import anchors, reader, variability, verify # noqa: E402'),
])
patch(APP / "web" / "templates" / "candidates.html", [
(''' <td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}</td>''',
''' <td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}
{% if r.table_confirmed %}<br><b>표 값과도 일치</b>{% elif r.table is defined %}<br><b>표 값 {{ r.table }}와 다름 — 표를 따르세요</b>{% endif %}</td>'''),
])
print("patched")
@@ -0,0 +1,40 @@
"""Stage C part 2 (2026-10-19): selection helpers on the candidate screen — tick every differing grade-A candidate of one file (params OR expected,
never both: the same drawing text may not go into both, P7)."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "templates" / "candidates.html"
s = P.read_text(encoding="utf-8")
a = ''' <div class="field"><label for="why">반영 사유'''
assert s.count(a) == 1 and "sel-a" not in s
b = ''' <div class="row"><span class="cap">빠른 선택(A등급 중 현재 값과 다른 것):</span>
<button class="btn sm" type="button" data-sel-a="expected">expected만</button>
<button class="btn sm" type="button" data-sel-a="params">params만</button>
<button class="btn sm quiet" type="button" data-sel-a="">선택 해제</button>
<span class="cap muted">params와 expected에 같은 도면 문자를 함께 반영할 수 없어 한쪽씩 고릅니다.</span></div>
''' + a
s = s.replace(a, b)
c = ''' {% endif %}
{% endif %}
{% if chains %}'''
assert s.count(c) == 1
d = ''' <script>
(function () {
/* sel-a */
var rows = document.querySelectorAll('#out-of-table tbody tr');
document.querySelectorAll('[data-sel-a]').forEach(function (btn) {
btn.addEventListener('click', function () {
var file = btn.getAttribute('data-sel-a');
rows.forEach(function (tr) {
var cb = tr.querySelector('input[type=checkbox][name=field]');
if (!cb) return;
var isA = tr.textContent.indexOf('등급 A') >= 0;
cb.checked = !!file && isA && cb.value.indexOf(file + ':') === 0;
});
});
});
})();
</script>
''' + c
s = s.replace(c, d)
P.write_text(s, encoding="utf-8")
print("patched candidates.html")
@@ -0,0 +1,88 @@
"""Stage C (2026-10-19), part 1: web/store.py learns / copies anchors, follows them in a pier's drawing and offers field get / set helpers.
Backup by the caller (backup/2026-10-19_C단계전/). Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "store.py"
s = P.read_text(encoding="utf-8")
assert "def anchor_rows" not in s, "already patched"
pairs = [
('from engine import variability, verify # noqa: E402', 'from engine import anchors, variability, verify # noqa: E402'),
# save_template: learn anchors when the source pier has an indexed drawing
(''' wjson(d / "variability.json", variability.build(rjson(sdir / "params.json"), rjson(sdir / "expected.json")))
return d''', ''' wjson(d / "variability.json", variability.build(rjson(sdir / "params.json"), rjson(sdir / "expected.json")))
texts = rjson(sdir / "drawings" / "texts.json", [])
if texts:
shas = [a.get("source_sha256") for a in rjson(sdir / "drawings" / "audit.json", []) if a.get("source_sha256")]
wjson(d / "anchors.json", anchors.learn(rjson(sdir / "params.json"), rjson(sdir / "expected.json"), texts, shas))
return d'''),
# create_pier: carry the anchors (template) or learn them from the copied pier's own drawing
(''' wjson(d / "variability.json", var)
''', ''' wjson(d / "variability.json", var)
anc = rjson(src_dir / "anchors.json")
if not anc and (src_dir / "drawings" / "texts.json").exists():
shas = [a.get("source_sha256") for a in rjson(src_dir / "drawings" / "audit.json", []) if a.get("source_sha256")]
anc = anchors.learn(P0, E0, rjson(src_dir / "drawings" / "texts.json", []), shas)
if anc:
wjson(d / "anchors.json", anc)
'''),
(''' # ---- app check (stage B)''', ''' # ---- anchors (stage C, 2026-10-19): out-of-table dimension candidates
def anchor_rows(self):
"""Candidates followed from the pier's anchors in its drawing: ({rows}, missing, meta). rows: one per anchored field that was found again;
missing: anchored fields without a candidate. Nothing is written."""
anc, texts = rjson(self.dir / "anchors.json"), self.texts()
if not anc or not texts:
return [], [], {"anchors": bool(anc), "texts": bool(texts), "coverage": None, "self_source": False}
own = [a.get("source_sha256") for a in rjson(self.drawings / "audit.json", []) if a.get("source_sha256")]
cand = anchors.follow(anc, texts, own)
docs = {"params": self.load("params"), "expected": self.load("expected")}
rows, missing = [], []
for field in anc["fields"]:
cur = get_field(docs, field)
if cur is None:
continue
c = cand.get(field)
if not c:
missing.append(field)
continue
which, path = field.split(":", 1)
rows.append(dict(c, field=field, file=which, path=path, current=cur, same=abs(abs(cur) - abs(c["value"])) < 1.5 and (cur >= 0) == (c["value"] >= 0)))
cov = rows[0]["coverage"] if rows else None
return rows, missing, {"anchors": True, "texts": True, "coverage": cov, "self_source": bool(rows and rows[0]["self_source"])}
# ---- app check (stage B)'''),
('''def sha(obj):''', '''def get_field(docs, field):
"""Numeric value at "params:coping.profile_xz.0.1" (list indices allowed), else None."""
which, path = field.split(":", 1)
x = docs[which]
for p in path.split("."):
if isinstance(x, list):
x = x[int(p)] if p.isdigit() and int(p) < len(x) else None
elif isinstance(x, dict):
x = x.get(p)
else:
return None
if x is None:
return None
return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None
def set_field(docs, field, value):
which, path = field.split(":", 1)
x, parts = docs[which], path.split(".")
for p in parts[:-1]:
x = x[int(p)] if isinstance(x, list) else x[p]
last = parts[-1]
value = int(value) if float(value).is_integer() and isinstance(x[int(last) if isinstance(x, list) else last], int) else float(value)
if isinstance(x, list):
x[int(last)] = value
else:
x[last] = value
def sha(obj):'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched store.py")
@@ -0,0 +1,78 @@
"""Stage C (2026-10-19), part 2: the app check gets C2b "표 밖 치수 재대조" from the followed anchors (engine/verify.py) and store.run_autocheck
passes the rows. Backup by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "C2b" not in s.replace("# C2b-patched", "") or path.name == "store.py", f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "engine" / "verify.py", [
(''' C3 템플릿 원본 잔존''', ''' C2b 표 밖 치수 재대조 anchors followed into the drawing (engine/anchors.py): A candidates equal / differ, B only warn; own-drawing anchors are no evidence [strong]
C3 템플릿 원본 잔존'''),
('''def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None):''',
'''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
def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None, anchor_rows=None):'''),
(''' template_values = template_values or {}''', ''' template_values = template_values or {}
arows, amiss = (anchor_rows or ([], []))[:2]'''),
(''' # C4
gate = next(''', ''' # C2b
ac = anchor_check(arows, amiss, f"{which}:{key}.") if arows else None
if ac:
checks.append(ac)
# C4
gate = next('''),
(''' order = ("C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8")''', ''' order = ("C1", "C2", "C2b", "C3", "C4", "C5", "C6", "C7", "C8")'''),
(''' strong = any(c["id"] == "C2" and c["st"] == "g" for c in checks)''', ''' strong = any(c["id"] in ("C2", "C2b") and c["st"] == "g" for c in checks)'''),
(''' checks.append(chk("C3", "템플릿 원본 잔존", "g" if strong else "n",
"원본과 같지만 도면 표와 일치한다" if strong else "현재 값이 템플릿 원본 그대로이고 새 도면 근거가 없다"))''',
''' checks.append(chk("C3", "템플릿 원본 잔존", "g" if strong else "n",
"원본과 같지만 도면(표·표 밖 치수)과 일치한다" if strong else "현재 값이 템플릿 원본 그대로이고 새 도면 근거가 없다"))'''),
])
patch(APP / "web" / "store.py", [
(''' res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals)''',
''' arows, amiss, _ameta = self.anchor_rows()
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals, (arows, amiss))'''),
(''' return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts())])''',
''' return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts()), anchors.VERSION,
sha(rjson(self.dir / "anchors.json", {}).get("fields", {}))])'''),
])
print("patched verify.py, store.py")
@@ -0,0 +1,187 @@
"""Stage C (2026-10-19), part 3: candidate screen for out-of-table dimensions (anchors followed into the drawing), apply route, anchor loader for
piers without anchors, region image route, drawing chain check notice. Backup by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "web" / "app.py", [
("from engine import recommend, reader, report_files, variability, verify # noqa: E402",
"from engine import anchors, recommend, reader, render, report_files, variability, verify # noqa: E402"),
(''' return render_template("candidates.html", p=p, tables=tables, rows=rows, els=reader.el_list(texts),
dumps=lambda v: json.dumps(v, ensure_ascii=False))''',
''' arows, amiss, ameta = p.anchor_rows()
rank = {"A": 0, "B": 1, "C": 2}
arows.sort(key=lambda r: (r["same"], rank[r["grade"]], r["field"])) # differing candidates first, best grade first
chains = anchors.chains(texts)
return render_template("candidates.html", p=p, tables=tables, rows=rows, els=reader.el_list(texts),
dumps=lambda v: json.dumps(v, ensure_ascii=False), arows=arows, amiss=amiss, ameta=ameta,
chains=chains, chain_bad=[c for c in chains if not c["ok"]],
tpl_anchors=[t["name"] for t in store.templates() if (store.template_dir(t["name"]) / "anchors.json").exists()])
@app.post("/pier/<name>/anchors/apply")
def anchors_apply(name):
"""Apply selected out-of-table candidates. Rules: the same candidate must not go into params and expected together (P7), a grade C candidate
needs a reason, only differing rows are applied; the change is logged and the changed bundles need a new confirmation."""
p = pier_or_404(name)
rows, _m, _meta = p.anchor_rows()
chosen = set(request.form.getlist("field"))
sel = [r for r in rows if r["field"] in chosen and not r["same"] and not r["self_source"]]
why = request.form.get("why", "").strip()
key = lambda r: (r["text"], r["x"], r["y"], r["value"])
both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
if not sel:
flash("반영할 후보를 고르세요(이미 같은 값은 반영하지 않습니다).", "fail")
elif both:
flash("같은 도면 문자에서 나온 후보를 params와 expected에 함께 반영할 수 없습니다. 두 값이 같은 근거에서 나오면 검사가 무의미해집니다. 한쪽만 고르세요.", "fail")
elif any(r["grade"] == "C" for r in sel) and not why:
flash("C등급 후보는 반영 사유를 입력해야 합니다.", "fail")
else:
docs = {"params": p.load("params"), "expected": p.load("expected")}
for r in sel:
store.set_field(docs, r["field"], r["value"])
p.save("params", docs["params"])
p.save("expected", docs["expected"])
log = store.rjson(p.dir / "anchor_log.json", [])
log.append({"at": store.now(), "fields": [{"field": r["field"], "from": r["current"], "to": r["value"], "grade": r["grade"], "text": r["text"],
"pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"} for r in sel], "why": why})
store.wjson(p.dir / "anchor_log.json", log)
flash(f"표 밖 치수 후보 {len(sel)}건을 반영했습니다. 바뀐 묶음은 다시 확정해야 합니다.", "ok")
return redirect(url_for("candidates", name=name))
@app.post("/pier/<name>/anchors/load")
def anchors_load(name):
"""Copy the anchors of a protected template into a pier that has none (the person says which series the drawing belongs to)."""
p = pier_or_404(name)
t = request.form.get("template", "")
try:
src = store.template_dir(t) / "anchors.json"
if not src.exists():
raise FileNotFoundError(t)
except (FileNotFoundError, ValueError):
flash("앵커가 있는 템플릿을 고르세요.", "fail")
return redirect(url_for("candidates", name=name))
if (p.dir / "anchors.json").exists():
flash("이 교각에는 이미 앵커가 있습니다.", "fail")
else:
shutil.copy2(src, p.dir / "anchors.json")
flash(f"템플릿 '{t}'의 앵커를 불러왔습니다. 앱 확인이 새 도면 기준으로 다시 실행됩니다.", "ok")
return redirect(url_for("candidates", name=name))
@app.route("/pier/<name>/view/<dwg>")
def view_drawing(name, dwg):
"""PNG of the drawing region around (x, y) so a candidate can be checked by eye."""
p = pier_or_404(name)
try:
x, y = float(request.args["x"]), float(request.args["y"])
half = min(max(float(request.args.get("half", 3000)), 500.0), 20000.0)
except (KeyError, ValueError):
abort(400)
ent = next((a for a in store.rjson(p.drawings / "audit.json", []) if a.get("label") == dwg), None)
dxf = p.drawings / "dxf" / ent["dxf"] if ent else None
if not dxf or not dxf.exists():
abort(404)
cache = p.drawings / "view_cache"
cache.mkdir(exist_ok=True)
out = cache / f"{dwg}_{x:.0f}_{y:.0f}_{half:.0f}.png"
if not out.exists():
render.render(str(dxf), x, y, half, str(out))
return send_file(out, mimetype="image/png")'''),
(''' cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16''',
''' chain_bad = [c for c in anchors.chains(p.texts()) if not c["ok"]] # stage C 2026-10-19: R2 check of the drawing's own chains
cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16'''),
(''' nmatch=nmatch, ST=verify.ST_NAME,''', ''' nmatch=nmatch, ST=verify.ST_NAME, chain_bad=chain_bad,'''),
])
# ---- templates
patch(APP / "web" / "templates" / "pier.html", [
(''' {% if origin_same %}
<div class="banner" role="alert">''', ''' {% if chain_bad %}
<div class="banner dont" role="alert"><span class="m" aria-hidden="true">■</span><div>도면 치수 사슬 {{ chain_bad|length }}건이 검산에 맞지 않습니다(개수 × 간격 ≠ 합계) — 도면 자체의 오류일 수 있습니다:
{% for c in chain_bad %}<span class="chip">{{ c.dwg }} {{ c.text }} (계산 {{ c.n * c.pitch }} mm)</span> {% endfor %}
<a href="{{ url_for('candidates', name=p.name) }}">표 후보값 화면에서 보기</a></div></div>
{% endif %}
{% if origin_same %}
<div class="banner" role="alert">'''),
])
patch(APP / "web" / "templates" / "candidates.html", [
('''<h2>후보값 비교 ({{ rows|length }}건 · 다름 {{ rows|rejectattr('same')|list|length }}건)</h2>''',
'''{% from "_prism.html" import grade_badge %}
<!-- stage C 2026-10-19 -->
<div class="prism" id="out-of-table">
<div class="stack">
<h2>표 밖 치수 후보 (앵커 따라가기)</h2>
{% if not ameta.anchors %}
<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>이 교각에는 앵커가 없습니다. 앵커는 같은 형식의 도면을 이미 읽은 템플릿에서 "각 값을 도면 어디서 읽었는가"를 기억한 것입니다.
같은 도면 틀(예: 운곡교 P1~P4)에 속한다면 템플릿을 골라 불러오세요.</div></div>
{% if tpl_anchors %}<form method="post" action="{{ url_for('anchors_load', name=p.name) }}" class="row" style="margin:0">
<label for="tpl">템플릿 앵커 불러오기</label>
<select id="tpl" name="template">{% for t in tpl_anchors %}<option>{{ t }}</option>{% endfor %}</select>
<button class="btn" type="submit">불러오기</button></form>{% endif %}
{% elif not ameta.texts %}<p class="cap muted">도면 문자 색인이 없습니다. 도면을 먼저 올리세요.</p>
{% else %}
{% set na = arows|selectattr('grade', 'equalto', 'A')|list|length %}{% set nb = arows|selectattr('grade', 'equalto', 'B')|list|length %}{% set nc = arows|selectattr('grade', 'equalto', 'C')|list|length %}
{% set nd = arows|rejectattr('same')|list|length %}
<section class="strip">
<p class="concl">후보 {{ arows|length }}건 — 현재 값과 다름 {{ nd }}건 · A {{ na }} · B {{ nb }} · C {{ nc }} · 후보를 못 찾음 {{ amiss|length }}건</p>
<p class="cap">도면 틀 일치율 {{ '%d' % ((ameta.coverage or 0) * 100) }} % (앵커를 만든 도면과 이 도면에 공통인 문자의 비율). 70 % 미만이면 틀이 다른 도면으로 보고 모든 후보를 C등급으로 낮춥니다.
A = 같은 치수가 여러 곳에서 같게 읽힘 · B = 한 곳 또는 일부만 · C = 근거 약함(목록만, 반영 시 사유 필요). 시험에서 A등급은 틀린 후보가 없었고 B등급은 약 15 %가 틀렸습니다.</p>
</section>
{% if ameta.self_source %}<div class="banner"><span class="m" aria-hidden="true">▲</span><div>이 앵커는 이 교각 자신의 도면에서 만들어졌습니다. 같은 도면을 다시 읽은 것이라 앱 확인의 근거로 쓰지 않습니다.</div></div>{% endif %}
{% if arows %}
<form method="post" action="{{ url_for('anchors_apply', name=p.name) }}" class="stack">
<div class="tw"><table class="t">
<caption>표 밖 치수 후보 — 다른 것부터, 등급 좋은 것부터</caption>
<thead><tr><th scope="col">반영</th><th scope="col">대상</th><th class="num" scope="col">현재</th><th class="num" scope="col">후보</th><th scope="col">등급</th><th scope="col">근거</th><th scope="col">도면 위치</th></tr></thead>
<tbody>
{% for r in arows %}
<tr{% if not r.same %} class="sel"{% endif %}>
<td>{% if not r.same and not r.self_source %}<input type="checkbox" name="field" value="{{ r.field }}" aria-label="{{ r.field }} 반영" style="width:1.25rem;height:1.25rem">{% endif %}</td>
<td><b>{{ r.field }}</b></td><td class="num">{{ r.current }}</td><td class="num">{{ r.value }}</td>
<td>{{ grade_badge(r.grade) }}{% if r.same %} <span class="cap muted">같음</span>{% endif %}</td>
<td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}</td>
<td><span class="chip">{{ r.dwg }} '{{ r.text }}' ({{ '%.0f' % r.x }}, {{ '%.0f' % r.y }})</span>
<a class="cap" href="{{ url_for('view_drawing', name=p.name, dwg=r.dwg, x=r.x, y=r.y) }}" target="_blank">도면 보기</a></td>
</tr>
{% endfor %}
</tbody>
</table></div>
<div class="field"><label for="why">반영 사유<span class="req">C등급 후보 반영에는 필수</span></label><input id="why" type="text" name="why" style="max-width:36rem"></div>
<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>같은 도면 문자에서 나온 후보를 params와 expected에 함께 반영할 수 없습니다(검사의 독립성). 한쪽만 고르고, 나머지는 도면에서 따로 확인하세요.</div></div>
<div class="row"><button class="btn primary" type="submit">선택한 후보 반영</button></div>
</form>
{% endif %}
{% endif %}
{% if chains %}
<h3>도면 치수 사슬 검산 (R2)</h3>
<p class="cap">{{ chains|length }}개 중 검산(개수 × 간격 = 합계)에 맞지 않는 것 {{ chain_bad|length }}개{% if chain_bad %}:{% endif %}
{% for c in chain_bad %}<span class="b bad"><span class="m" aria-hidden="true">■</span>{{ c.dwg }} {{ c.text }} → 계산 {{ c.n * c.pitch }} mm</span> <a class="cap" href="{{ url_for('view_drawing', name=p.name, dwg=c.dwg, x=c.x, y=c.y) }}" target="_blank">도면 보기</a> {% endfor %}</p>
{% endif %}
</div>
</div>
<h2>후보값 비교 ({{ rows|length }}건 · 다름 {{ rows|rejectattr('same')|list|length }}건)</h2>'''),
])
patch(APP / "web" / "templates" / "_prism.html", [
('''{% macro human_badge(s) -%}''', '''{% macro grade_badge(g) -%}
{%- set M = {'A': ['good', '●', '등급 A'], 'B': ['warn', '▲', '등급 B'], 'C': ['dash', '◇', '등급 C']} -%}
{%- set m = M.get(g, M['C']) -%}
<span class="b {{ m[0] }}"><span class="m" aria-hidden="true">{{ m[1] }}</span>{{ m[2] }}</span>
{%- endmacro %}
{% macro human_badge(s) -%}'''),
])
print("patched app.py, pier.html, candidates.html, _prism.html")
@@ -0,0 +1,24 @@
"""Stage C (2026-10-19), part 3b: the independence rule (P7) also holds across separate applications: a candidate read from a drawing text that
was already applied to params may not be applied to expected later (and the other way round)."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "app.py"
s = P.read_text(encoding="utf-8")
pairs = [
(''' both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
if not sel:''', ''' both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
done = store.rjson(p.dir / "anchor_log.json", []) # applied earlier: (file, text, x, y)
prior = {(f["file"], f["text"], f["x"], f["y"]) for e in done for f in e["fields"] if "file" in f}
both |= {key(r) for r in sel if ("expected" if r["file"] == "params" else "params", r["text"], r["x"], r["y"]) in prior}
if not sel:'''),
(''' log.append({"at": store.now(), "fields": [{"field": r["field"], "from": r["current"], "to": r["value"], "grade": r["grade"], "text": r["text"],
"pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"} for r in sel], "why": why})''',
''' log.append({"at": store.now(), "fields": [{"field": r["field"], "file": r["file"], "from": r["current"], "to": r["value"], "grade": r["grade"],
"text": r["text"], "x": r["x"], "y": r["y"], "pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"}
for r in sel], "why": why})'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched")
@@ -0,0 +1,38 @@
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
plan_old = R / "DOC/01_기획/기획_입력묶음확인확정개편_2026-10-19_v0.1.06.md"
plan_new = R / "DOC/01_기획/기획_입력묶음확인확정개편_2026-10-19_v0.1.07.md"
shutil.copy2(plan_old, R / "DOC/01_기획/backup" / plan_old.name)
s = plan_old.read_text(encoding="utf-8")
row = "| v0.1.06 | 2026-10-19 |"
i = s.index(row); j = s.index("\n", i)
s = s[:j+1] + "| v0.1.07 | 2026-10-19 | 심영표 | D단계(전 화면 Prism 교체, 사용설명서 v0.2.12, 전체 회귀, 운곡교 P3·P4 A등급 반영) 결과 6.5절 추가 | 사용자 \"D단계 착수, 운곡교 P3·P4 후보 반영\" |\n" + s[j+1:]
sec = """### 6.5 D단계 결과 (2026-10-19)
**① 전 화면 Prism 교체**: `web/templates/base.html`을 Prism 틀(위쪽 띠·본문)로 바꾸고 `web/static/prism-app.css`를 추가해, 옛 화면의 표·버튼·입력칸·안내 문구가 Prism 토큰(색·간격·Pretendard)을 따르게 했습니다(템플릿별 개별 수정 없이 공통 틀로 교체). 상태 표시는 색과 함께 글자·기호를 씁니다. 형식 불일치 경고는 계속 닫을 수 없습니다. 어두운 테마는 시험하지 않았습니다.
**② 사용설명서 v0.2.12**: 앱 확인·근거 보기·일치 묶음 일괄 확정·변수 등급·표 밖 치수 후보·도면 먼저 추천·6번째 템플릿을 반영하고, 이전 방식 일괄 확정 설명을 삭제했습니다.
**③ 운곡교 P3·P4 후보 반영(A등급, 파일 하나씩)**: `tools/apply_anchor_a.py`로 앱의 후보 반영 경로를 거쳐 expected 쪽 A등급 후보를 반영했습니다(P3 29필드, P4 34필드, 반영 전 params·expected·confirm.json은 `backup/before_anchor_A_*`에 보관, 기록은 `anchor_log.json`). params 쪽 A등급 후보는 같은 도면 문자를 expected에 이미 썼기 때문에 P7 규칙으로 막혀 반영하지 못했습니다. 그 결과 묶음 단위 앱 확인 불일치는 P3 10·P4 11로 그대로이고, 필드 단위 expected 차이 줄은 69→52(P3), 75→63(P4)로 줄었습니다. 남은 불일치는 A등급 후보가 아닌 값(표 후보·params 독립 판독이 필요한 값)입니다.
**④ 회귀**: fast 15/15 PASS, Rhino 그룹 결과는 OPEN #26에 기록합니다.
**남은 결정**: 표 후보(expected 쪽) 검토 후 반영 여부, params 독립 판독, 운곡교 P4 도면 판(OPEN #33).
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
plan_new.write_text(s, encoding="utf-8")
plan_old.unlink()
op = R / "context-migration/OPEN_미해결목록.md"
shutil.copy2(op, R / "context-migration/backup" / "OPEN_미해결목록_2026-10-19_D단계전.md")
t = op.read_text(encoding="utf-8")
a = "**남은 것**: D단계(기존 화면 Prism 교체, 사용설명서 v0.2.12, 전체 회귀). 운곡교 P3·P4의 후보 반영과 나머지 판독은 별도 Task"
assert t.count(a) == 1
t = t.replace(a, "**[갱신 2026-10-19] D단계 완료**: 전 화면 Prism 교체(`base.html`+`prism-app.css`), 사용설명서 v0.2.12, 전체 회귀, 운곡교 P3·P4 expected 쪽 A등급 반영(P3 29·P4 34필드, params 쪽은 P7로 막힘, 묶음 불일치 10/11 그대로, 필드 차이 69→52·75→63). **남은 것**: 표 후보 검토·반영과 params 독립 판독(별도 Task), 운곡교 P4 도면 판(#33)")
op.write_text(t, encoding="utf-8")
print("ok")
@@ -0,0 +1,168 @@
"""Manual v0.2.11 -> v0.2.12 (2026-10-19): the 입력 묶음 확인·확정 개편 (stage A-D). The v0.2.11 file is copied to backup/ by the caller first;
this script writes the new file next to it. Refuses to run when a replacement does not match exactly (the manual changed under it)."""
from pathlib import Path
DOC = Path(__file__).resolve().parents[4] / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-15_v0.2.11.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-19_v0.2.12.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:80]
s = s.replace(a, b)
rep("| v0.2.11 | 2026-10-15 | 심영표 | 새재1교 P1 코핑·받침좌 철근(G4) 반영: 1.1 형식 표, 7.1 항목 수, 9 한계 | 사용자 지시 \"진행해줘\" |",
"| v0.2.11 | 2026-10-15 | 심영표 | 새재1교 P1 코핑·받침좌 철근(G4) 반영: 1.1 형식 표, 7.1 항목 수, 9 한계 | 사용자 지시 \"진행해줘\" |\n"
"| v0.2.12 | 2026-10-19 | 심영표 | 입력 묶음 확인·확정 개편(A~D단계) 반영: 1.1·1.2(6번째 템플릿 `산외교울산P2형`, 앱 확인·표 밖 치수 후보), 4 화면 구성, 5.1(도면 먼저 추천)·5.3·5.4(확정 방법 전면 개정, 이전 방식 일괄 확정 삭제), 6.1·6.5 파일, 8, 9, 부록 A·B. 화면 디자인 Prism 적용 | 사용자 지시 \"D단계 착수해줘\" |")
# ---- 1.1
rep("| 3 | 입력 묶음 확인·확정 (\"이 값이 도면과 맞다\"는 기록) | 사용자 |",
"| 3 | 입력 묶음 **앱 확인**(표·수량·고정 항목 대조) → 사람이 근거를 보고 **확정** (\"이 값이 도면과 맞다\"는 기록) | 앱이 확인, 사용자가 확정 |")
rep("| 2 | 도면 표에서 읽은 값과 입력값 비교, 표 밖 치수는 문자 검색으로 찾기 | 사용자 + 앱 |",
"| 2 | 도면 표에서 읽은 값과 입력값 비교, 표 밖 치수는 후보(같은 도면 틀일 때)·문자 검색으로 찾기 | 사용자 + 앱 |")
rep("- 지원하는 T형 교각 형식은 다섯 가지입니다.", "- 지원하는 T형 교각 형식은 여섯 가지입니다. 도면을 먼저 올리면 가장 비슷한 형식을 추천해 줍니다(5.1절).")
rep("| `새재1교P1형` | 직접기초(말뚝 없음)", "| `산외교울산P2형` | 강관말뚝 Ø508 6×5 기초(**평면 37° 회전**) · 원형 기둥 Ø2.5 · 직선 경사 코핑(끝면 300 경사, 상면 130 단차, 평면 모서리 R500) · 받침 4개 · 철근 G2 링(주철근 68) · G3 기초(B7 320본은 추정) · G4 코핑·받침(철근별 중심선 372본) | 산외교 울산 P2 |\n| `새재1교P1형` | 직접기초(말뚝 없음)")
rep("- 도면 판독은 **반자동**입니다. 표 3종(받침 ELEVATION TABLE, 솔플레이트 TABLE, 철근재료표)만 자동으로 읽고, 나머지 치수는 사용자가 확인·입력합니다.",
"- 도면 판독은 **반자동**입니다. 표 3종(받침 ELEVATION TABLE, 솔플레이트 TABLE, 철근재료표)은 자동으로 읽습니다. 표 밖 치수는 같은 도면 틀(예: 운곡교 P1~P4)이면 **후보**로 찾아 주지만(5.3절), 철근 위치 목록·코핑 외곽 같은 값은 앱이 읽지 못하므로 사용자가 확인·입력합니다.\n"
"- **앱은 확정하지 않습니다.** 앱 확인 '일치'는 \"독립 근거(도면 표·재료표 수량 등)와 값이 같다\"는 뜻이지 \"도면이 맞다\"는 보증이 아닙니다.")
# ---- 4 화면 구성
i = s.index("## 4. 화면 구성")
j = s.index("## 5. 따라 하기")
s = s[:i] + """## 4. 화면 구성
화면은 Prism 디자인(Pretendard 글꼴)으로 통일되어 있습니다. 위쪽 띠에 앱 이름과, 교각을 연 뒤에는 그 교각의 메뉴(문자 검색·표 후보값·게이트)가 있습니다.
| 화면 | 들어가는 법 | 주요 버튼·기능 |
| :--- | :--- | :--- |
| 교각 목록 | 첫 화면 | 교각 선택, **도면으로 템플릿 추천**(도면을 먼저 올려 순위·근거 확인), **새 교각 만들기**, **삭제**(이름 입력 후), 아래쪽 **보호 템플릿** 표 |
| 도면으로 템플릿 추천 | 교각 목록 | 도면 올리기 → 템플릿 순위·추정 근거 → 6문항·각도 값 확인 → 판정 → 교각 만들기 |
| 교각 | 목록에서 교각 이름 클릭 | 1. 도면 **올리기·변환** / 2. **요약 띠**(앱 확인 결과 개수), **[전체 확인]**, **[일치 묶음 일괄 확정]**, 입력 묶음 표(변수 등급·값 출처·앱 확인·사람 확정) / 3. 게이트 현황, **일괄 승인** |
| 근거 보기 | 묶음 표의 [근거 보기] | 앱 확인 8종 검사별 결과·비교한 내용·도면 위치, 사유와 함께 **확정** |
| 일치 묶음 일괄 확정 | 교각 화면의 버튼 | 대상 묶음의 근거표(끝까지 내려야 활성화), 제외된 묶음과 사유, 확정자 입력 |
| 도면 문자 검색 | 위쪽 메뉴 | 글자·숫자 검색, 정규식 검색. 도면(D1~D3)·레이어·좌표 표시. 아래쪽 **핸들 조회** |
| 표 후보값 | 위쪽 메뉴 | **표 밖 치수 후보**(앵커, 등급 A/B/C, **도면 보기**), 도면 **치수 사슬 검산**, 도면 표에서 읽은 값과 현재 값 비교, **선택한 후보값 반영** |
| 게이트 | 위쪽 메뉴 | **일괄 승인**, 게이트별 **실행**·**승인**·**반려**, **모델 내려받기**, **보고서 초안 생성** |
| 묶음 편집 | 교각 화면의 묶음 이름 클릭 | 변수 등급·값 출처 표시, 값(JSON) **저장**(고정(F) 항목을 바꾸면 사유 필요, 생성(V3) 묶음은 저장 불가), **저장된 값으로 확정**(사유 칸 포함) |
판정 표시는 색과 글자·기호를 함께 씁니다. ● 일치·PASS(초록), ▲ 주의·계획 FAIL(황), ■ 불일치·FAIL(빨강), ◇ 근거 없음·미확정(점선 테두리), — 해당 없음(회색).
---
""" + s[j:]
# ---- 5.1
rep("1. 교각 목록 아래 **새 교각**에서 이름(예: `운곡교p2`)을 입력합니다.",
"**도면을 먼저 올려 추천받기(권장)**: 교각 목록의 **도면으로 템플릿 추천**에서 DWG를 올리고 [도면 읽고 추천 받기]를 누르면, 도면 문자에서 기초 형식(말뚝 유무)·코핑 하면 형상·받침 개수를 추정해 보호 템플릿을 일치 문항 수 순으로 보여 줍니다(추정 근거와 도면 위치 표시, 맞는 템플릿이 없으면 '판단 불가'). 아래 6문항(철근 형상·생성 방식은 사람이 답함)과 각도 값(코핑 skew·받침 회전·기초 회전)을 확인하면 판정이 나오고, 이름을 넣고 [교각 만들기]를 누르면 교각 폴더가 만들어지며 올린 도면이 그 안으로 옮겨집니다. 답이 템플릿과 다르면 [그래도 만들기]로 진행할 수 있고, 그 교각은 모든 화면 위에 닫을 수 없는 '형식 불일치 경고'가 표시됩니다.\n\n"
"**도면 없이 만들기**\n\n1. 교각 목록 아래 **새 교각**에서 이름(예: `운곡교p2`)을 입력합니다.")
rep("말뚝 없는 직접기초에 대좌 5개를 앞뒤 받침이 함께 쓰는 형식이면 `새재1교P1형`.", "말뚝 없는 직접기초에 대좌 5개를 앞뒤 받침이 함께 쓰는 형식이면 `새재1교P1형`, 강관말뚝 기초가 37° 회전한 직선 경사 코핑 형식이면 `산외교울산P2형`.")
rep("4. **[만들기]**를 누릅니다. 템플릿의 params·expected가 복사되어 출발점이 되고, 교각 목록의 '템플릿' 칸에 \"템플릿: 산외교울산P1형\"처럼 표시됩니다.",
"4. **[만들기]**를 누릅니다. 템플릿의 params·expected가 복사되어 출발점이 되고, 교각 목록의 '템플릿' 칸에 \"템플릿: 산외교울산P1형\"처럼 표시됩니다. 이때 변수 등급 파일(`variability.json`), 표 밖 치수 앵커(`anchors.json`), 묶음별 원본 해시(`pier.json`의 `origin`)도 함께 복사·기록됩니다.")
rep("> 주의: 템플릿 값은 **원본 교각의 도면 값**입니다. 복사한 값 그대로 게이트를 실행하면 원본 교각의 모델이 만들어질 뿐입니다. 반드시 5.3절대로 새 도면 값으로 바꾼 뒤 확정하세요.",
"> 주의: 템플릿 값은 **원본 교각의 도면 값**입니다. 복사한 값 그대로 게이트를 실행하면 원본 교각의 모델이 만들어질 뿐입니다. 앱은 복사한 뒤 한 번도 바뀌지 않은 묶음을 \"▲ 템플릿 원본 그대로\"로 표시하고 앱 확인을 '근거 없음'으로 두며, 이런 묶음은 일괄 확정 대상에서 빠집니다. 반드시 5.3절대로 새 도면 값으로 바꾼 뒤 확정하세요.")
# ---- 5.3
rep("1. **표 후보값**: 위쪽 메뉴 [표 후보값]에서 '다름'인 줄을 도면과 대조합니다.",
"0. **앱 확인 보기**: 교각 화면 2절 맨 위 요약 띠와 묶음 표의 「앱 확인」 열에서 어느 묶음이 일치·불일치·근거 없음인지 봅니다. 불일치 묶음의 [근거 보기]에서 무엇이 어떻게 다른지(예: \"현재 42686 ≠ 표 41747\") 알 수 있습니다.\n"
"1. **표 후보값**: 위쪽 메뉴 [표 후보값]에서 '다름'인 줄을 도면과 대조합니다.")
rep("2. **문자 검색**: 표 밖의 치수는 [도면 문자 검색]에서 찾습니다.",
" - **표 밖 치수 후보**: 같은 도면 틀(예: 운곡교 P1~P4)이면 [표 후보값] 맨 위 「표 밖 치수 후보」에 템플릿 원본 교각이 값을 읽은 문자 위치를 새 도면에서 따라간 후보가 나옵니다. 등급 **A**(같은 치수가 여러 곳에서 같게 읽힘, 표 값과도 일치하면 표시) · **B**(한 곳 또는 일부만, 약 15 %는 틀림) · **C**(근거 약함, 반영 시 사유 필요). [도면 보기]로 그 위치를 확대 이미지로 확인합니다. 도면 틀 일치율이 70 % 미만이면 모든 후보가 C로 내려갑니다. 앵커가 없는 교각은 같은 화면에서 템플릿을 골라 앵커를 불러옵니다. **[선택한 후보 반영]**은 선택한 줄에만 적용되고, 같은 도면 문자에서 나온 후보를 params와 expected에 함께(따로 해도) 반영할 수 없습니다. 「빠른 선택」으로 A등급 후보를 expected만/params만 한 번에 고를 수 있습니다.\n"
" - **치수 사슬 검산**: 도면의 `n@간격=합계` 표기를 검산해(개수 × 간격 = 합계) 맞지 않는 것을 교각 화면과 [표 후보값]에 경고합니다(도면 자체의 오류일 수 있음).\n"
"2. **문자 검색**: 표 밖의 치수는 [도면 문자 검색]에서 찾습니다.")
# ---- 5.4
i = s.index("### 5.4 확정")
j = s.index("### 5.5 게이트 실행과 승인")
s = s[:i] + """### 5.4 확정
확정은 **"내가 이 값을 도면과 대조했다"는 사람의 기록**입니다. 앱은 확정하지 않고, 확정에 쓸 근거를 모아 줍니다.
**앱 확인 결과** — 교각 화면 2절 맨 위 요약 띠: "필요 26개 중 앱 확인 일치 9 · 주의 4 · 불일치 0 · 근거 없음 10 · 해당 없음 3 · 사람 확정 0". [전체 확인]을 누르면 앱이 다시 확인합니다(값·도면이 바뀌면 자동으로도 다시 확인).
| 앱 확인 | 뜻 | 확정 방법 |
| :--- | :--- | :--- |
| ● 일치 | 독립 근거(도면 표·재료표 수량·표 밖 치수 A등급·템플릿 고정 값)와 값이 같다 | [일치 묶음 일괄 확정] 가능 |
| ▲ 주의 | 수량만 확인했거나, 계획된 예외·B등급 후보와 다름 등 | 근거 보기에서 **사유와 함께** 개별 확정 |
| ■ 불일치 | 값이 도면 표·후보와 다르다 | 값을 고치거나, 사유와 함께 개별 확정 |
| ◇ 근거 없음 | 템플릿 원본 그대로이거나 앱이 읽지 못하는 값(철근 위치·코핑 외곽·표 밖 치수) | 도면과 직접 대조한 뒤 **사유와 함께** 개별 확정 |
| — 해당 없음 | 참고 정보·사람 결정 기록(계획 FAIL, 철근 색 등) | 참고 정보는 확정 불필요, 결정 기록은 개별 확정 |
'일치'는 "독립 근거와 같다"는 뜻이지 "도면이 맞다"는 보증이 아닙니다. 철근 위치·좌표와 표 밖 치수는 아직 앱이 대조하지 못하는 부분이 많습니다.
**일치 묶음 일괄 확정**
1. 교각 화면의 **[일치 묶음 일괄 확정 (○개)]**를 누릅니다(대상이 없으면 버튼 대신 안내만 나옵니다).
2. 대상 묶음의 **근거표를 끝까지 내려** 읽습니다(내리기 전에는 확정자 입력·확정 단추가 막혀 있습니다). 제외된 묶음은 접힌 칸에서 사유를 볼 수 있습니다.
3. **확정자** 이름을 넣고 확정합니다. 앱 확인이 '일치'인 묶음만 확정되며, 확정 기록에는 입력한 이름과 당시 앱 확인 상태가 남습니다. 근거표를 읽은 뒤 대상 목록이 바뀌면(값 수정 등) 다시 읽어야 합니다.
**개별 확정(근거 보기)**: 묶음 이름 옆 **[근거 보기]**에서 앱 확인 8종(C1 출처·C2 표 대조·C2b 표 밖 치수·C3 템플릿 원본·C4 수량·C5 params 독립·C6 범위·C7 고정 항목·C8 재생성)의 결과와 비교 내용·도면 위치를 보고, 확정자와 **사유**를 넣어 확정합니다. 앱 확인이 일치가 아니면 사유가 없을 때 확정되지 않습니다. 묶음 편집 화면 아래의 [저장된 값으로 확정]도 같은 규칙입니다.
**변수 등급과 편집 규칙** (묶음 표의 「변수 등급」 열)
- **V1 값 변수**·**V2 목록 변수**: 도면마다 달라지는 값. 자유롭게 고칩니다.
- **V3 생성 변수**: 판독 스크립트가 만든 긴 목록(철근별 중심선 등). 화면에서 저장할 수 없습니다. 판독 스크립트를 다시 실행해 값을 만드세요.
- **F 고정**: 형식을 정의하는 값(`scope`, `builders`, `column.shape`, `coping.form` 등). 바꾸면 같은 템플릿이 아닐 수 있어 **사유를 입력해야** 저장되고, 사유는 `edit_log.json`에 기록됩니다.
**확정 대상**: 치수·철근·기대값 묶음과 **실행 설정 묶음**(`scope`·`builders`·`checkers`·`negative`)입니다. 결과를 바꾸므로 확정해야 게이트를 실행할 수 있습니다. 이 묶음이 없는 교각(운곡교 형식)은 기본값을 쓰며 확정이 필요 없습니다.
**확정 불필요(참고)**: `_schema`, `_meta`, `_note`, `○○_src` 같은 형식·출처 메모 묶음은 게이트가 쓰지 않습니다. 확정하지 않아도 됩니다.
- 확정 뒤 값을 고치면 그 묶음은 자동으로 '변경됨 · 재확정 필요'가 되고, 승인된 게이트도 '재실행 필요'가 됩니다.
- **이전 방식의 "필요한 묶음 일괄 확정"(앱 확인 없이 전부 확정)은 2026-10-19에 없어졌습니다.** 확정은 위의 두 방법뿐입니다.
""" + s[j:]
# ---- 6.1 / 6.5
rep("| `confirm.json` | 묶음별 확정 기록 (확정자, 시각) |",
"| `confirm.json` | 묶음별 확정 기록 (확정자, 시각, 사유, 확정 당시 앱 확인 상태) |\n"
"| `autocheck.json` | 앱 확인 결과 (묶음별 일치·주의·불일치 등과 검사 근거). 확정 기록과 별개이며 앱은 확정하지 않음 |\n"
"| `variability.json` | 묶음별 변수 등급(V1·V2·V3·F)과 값 출처 (템플릿에서 복사, 담당자 확정 전 초안) |\n"
"| `anchors.json` | 표 밖 치수 앵커(원본 교각이 값을 읽은 도면 문자 위치). 템플릿에서 복사 |\n"
"| `edit_log.json`, `anchor_log.json` | 고정 항목 변경 사유 기록, 표 밖 치수 후보 반영 기록 |\n"
"| `confirm_view.json` | 일치 묶음 일괄 확정에서 근거표를 끝까지 읽었다는 표식 |\n"
"| `drawings\\view_cache\\` | [도면 보기] 이미지 캐시 |")
rep("| `pier.json` | 교각 이름, 형식, 만든 날, 템플릿 |", "| `pier.json` | 교각 이름, 형식, 만든 날, 템플릿, 템플릿 복사 기록(`origin`), 템플릿 적합성 답(`fit_answers`·`fit_mismatch`) |")
rep("| `template.json` | 형식 설명, 원본 교각, 저장일, 입력 해시 |", "| `template.json` | 형식 설명, 원본 교각, 저장일, 입력 해시 |\n| `variability.json`, `anchors.json` | 묶음별 변수 등급, 표 밖 치수 앵커(원본 교각의 도면에서 학습) |")
# ---- 8
rep("| \"실행 불가: ○○ 미확정\" | 필요한 묶음이 미확정 | 교각 화면에서 일괄 확정 또는 해당 묶음 확정 |",
"| \"실행 불가: ○○ 미확정\" | 필요한 묶음이 미확정 | 교각 화면에서 [일치 묶음 일괄 확정] 또는 근거 보기에서 해당 묶음 확정(5.4절) |\n"
"| [일치 묶음 일괄 확정]이 없고 \"확정할 '일치' 묶음이 없습니다\" | 앱 확인이 '일치'인 미확정 묶음이 없음 | 남은 묶음은 근거 보기에서 사유와 함께 개별 확정 |\n"
"| \"사유를 입력해야 확정할 수 있습니다\" | 앱 확인이 일치가 아닌 묶음 | 도면과 대조한 내용을 사유에 적어 확정 |\n"
"| \"고정(F) 항목을 바꾸면 … 사유를 입력하세요\" | 고정 항목 변경 | 정말 형식이 같은지 확인하고 사유를 적음. 형식이 다르면 다른 템플릿으로 시작 |\n"
"| \"생성 변수(V3)는 화면에서 직접 고칠 수 없습니다\" | 철근별 중심선 목록 등 | 판독 스크립트를 다시 실행해 값을 만듦 |\n"
"| \"근거표를 열어 끝까지 읽은 뒤에 확정할 수 있습니다\" | 일괄 확정 근거표를 끝까지 내리지 않음, 또는 읽은 뒤 대상이 바뀜 | 일괄 확정 화면에서 근거표를 끝까지 내려 다시 확정 |\n"
"| 후보 화면에 \"이 교각에는 앵커가 없습니다\" | 템플릿 없이 만든 교각 | 같은 도면 틀의 템플릿을 골라 앵커 불러오기 |\n"
"| 후보가 전부 C등급 | 도면 틀 일치율 70 % 미만(다른 도면 틀) | 후보를 근거로 쓰지 말고 표·문자 검색으로 직접 확인 |\n"
"| 도면 치수 사슬 경고 | 도면의 `개수@간격=합계`가 맞지 않음 | 도면 자체의 오류일 수 있으니 [도면 보기]로 확인하고 설계자에게 문의 |")
# ---- 9
rep("- **확정·승인의 의미**: 확정과 승인은 사람의 검토 기록입니다. 일괄 기능은 버튼을 누른 사람의 이름으로 기록합니다.",
"- **확정·승인의 의미**: 확정과 승인은 사람의 검토 기록입니다. 일괄 기능(일치 묶음 일괄 확정, 일괄 승인)은 버튼을 누른 사람의 이름으로 기록합니다.\n"
"- **앱 확인의 한계**: '일치'는 독립 근거와 같다는 뜻입니다. 철근 위치 목록·코핑 외곽·표 밖 치수는 아직 대조하지 못해 '근거 없음'으로 남고, 철근 목록은 수량만 확인해 '주의'가 됩니다. C8(재생성 일치)은 스크립트가 연결되어 있지 않아 '해당 없음'입니다. 기존 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로'를 판단하지 않습니다.\n"
"- **표 밖 치수 후보의 한계**: 같은 도면 틀에서만 작동합니다(시험: 같은 틀 A등급 45건 중 오답 0, B등급 약 14 % 오답, 다른 틀은 A·B 후보 없음). 1 m 미만 값과 계산해서 얻는 값은 앵커가 없고, 철근 점·선과 콘크리트 외곽 점열은 시험 결과 채택하지 않았습니다. 자기 도면에서 만든 앵커는 근거로 쓰지 않습니다.")
rep("- 남은 개선 과제: 미해결 목록 #18(Rhino 실행 정체), #19(일반화), #22(울산 추정 사항), #23(Ramp-A 추정 사항), #24(옥성교) — `context-migration\\OPEN_미해결목록.md`",
"- **울산 P2 형식의 추정 사항**: 기초 회전 방향(반시계)·말뚝(속찬 원기둥)·솔플레이트(두꺼운 쪽 +Y)·대좌 fck는 사용자 확인 하에 추정을 적용했고, 기초 철근 B7 320본 배치와 B8 띠철근 길이 불일치는 설계자 확인 대상입니다(미해결 목록 #32, 판독서 `분석_산외교울산P2교각도면판독_2026-10-15_v0.1.02.md`).\n"
"- 남은 개선 과제: 미해결 목록 #18(Rhino 실행 정체), #19(일반화), #22(울산 추정 사항), #23(Ramp-A 추정 사항), #24(옥성교), #26(입력 묶음 개편 마무리), #32(울산 P2), #33(운곡교 P4 도면 사슬) — `context-migration\\OPEN_미해결목록.md`")
# ---- appendix
rep("| 보호 템플릿 저장·갱신 (교각 이름 대신 폴더 경로도 가능) | `python tools\\make_template.py <교각 이름> <템플릿 이름> \"<형식 설명>\"` |",
"| 보호 템플릿 저장·갱신 (교각 이름 대신 폴더 경로도 가능) | `python tools\\make_template.py <교각 이름> <템플릿 이름> \"<형식 설명>\"` |\n"
"| 템플릿 변수 등급 파일 만들기 | `python tools\\make_variability.py [--force]` |\n"
"| 템플릿 표 밖 치수 앵커 만들기 | `python tools\\make_anchors.py [--force]` |\n"
"| 실제 교각의 앱 확인 분포 보기 (data를 건드리지 않음) | `python tools\\report_autocheck.py [교각 ...]` |\n"
"| 앵커 후보를 표 값으로 교차 검증 | `python tools\\report_anchor_pier.py <교각> <템플릿> [--detail]` |\n"
"| 시험 일괄 실행 (Rhino 없는 시험 `fast`, 실제 Rhino 시험 `rhino`) | `python tools\\run_all_tests.py <fast\\|rhino\\|all>` |")
rep("| 확정 | 묶음 값을 도면과 대조했다는 기록 |",
"| 확정 | 묶음 값을 도면과 대조했다는 사람의 기록(앱은 확정하지 않음) |\n"
"| 앱 확인 | 앱이 묶음 값을 도면 표·수량·고정 항목과 대조한 결과(일치·주의·불일치·근거 없음·해당 없음) |\n"
"| 근거 보기 | 앱 확인 8종 검사의 결과와 비교 내용을 보는 화면 |\n"
"| 변수 등급 | V1 값 · V2 목록 · V3 생성(화면 편집 불가) · F 고정(바꾸면 사유 필요) |\n"
"| 템플릿 원본 그대로 | 템플릿을 복사한 뒤 한 번도 바뀌지 않은 묶음(새 도면 근거가 없는 상태) |\n"
"| 표 밖 치수 | 표 3종에 없는 치수·EL. 같은 도면 틀이면 앵커로 후보를 찾음 |\n"
"| 앵커 | 원본 교각이 값을 읽은 도면 문자의 위치(레이어·좌표) 기록 |\n"
"| 후보 등급 A·B·C | A 여러 곳이 같게 읽힘 · B 한 곳 또는 일부 · C 근거 약함 |")
DST.write_text(s, encoding="utf-8")
print("written", DST.name, len(s.splitlines()), "lines")
@@ -0,0 +1,86 @@
"""2026-10-19: the old "필요한 묶음 일괄 확정" (confirm_all: confirms every required bundle without looking at the app check) is removed by user
decision. Bundles are confirmed by 일치 묶음 일괄 확정 (D4) or one by one with a reason. The tests that used confirm_all now call
tools/testlib.confirm_required (one confirmation per required bundle, with a reason). Backups by the caller. Refuses to run twice."""
import re
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
T = APP / "tools"
def sub(path, pairs, must=True):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
if must:
assert s.count(a) >= 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
# ---- app.py: remove the route
p = APP / "web" / "app.py"
s = p.read_text(encoding="utf-8")
assert '@app.post("/pier/<name>/confirm_all")' in s, "already removed"
i = s.index('@app.post("/pier/<name>/confirm_all")')
j = s.index("# ---------------- gates", i)
s = s[:i] + s[j:]
p.write_text(s, encoding="utf-8")
# ---- pier.html: remove the folded legacy form, update the hints
p = APP / "web" / "templates" / "pier.html"
s = p.read_text(encoding="utf-8")
i = s.index("{% if need %}\n<details>")
j = s.index("{% else %}<p class=\"ok\">게이트에 필요한 묶음이 모두 확정되어 있습니다.</p>{% endif %}")
s = s[:i] + "{% if need %}<p class=\"muted\">게이트에 필요한 묶음 {{ need|length }}개가 아직 확정되지 않았습니다. 앱 확인이 '일치'인 묶음은 위의 [일치 묶음 일괄 확정]으로, 나머지는 '근거 보기'에서 사유와 함께 하나씩 확정합니다.</p>\n" + s[j:]
s = s.replace("먼저 위의 '필요한 묶음 일괄 확정'을 하세요. ", "먼저 위의 묶음을 확정하세요. ")
p.write_text(s, encoding="utf-8")
# ---- tests
imp = "from testlib import confirm_required # noqa: E402"
for name in ("test_web_batch", "test_web_ulsan", "test_web_ulsan_full", "test_ulsan_e2e", "test_okseong", "test_rampa_web", "test_confirm_required",
"test_pier_batch_button", "test_variability"):
path = T / f"{name}.py"
s = path.read_text(encoding="utf-8")
assert "confirm_required" not in s, name
# helper import after the last top-level import line of the header (before the first `ok = True` / def / check usage): put it after sys.path setup
m = re.search(r"^import app as webapp.*$", s, re.M) or re.search(r"^from app import.*$", s, re.M)
assert m, name
s = s[:m.end()] + "\nsys.path.insert(0, str(Path(__file__).resolve().parent))\n" + imp + s[m.end():]
path.write_text(s, encoding="utf-8")
print("imports added")
sub(T / "test_web_batch.py", [
(''' r = c.post(f"/pier/{P}/confirm_all", data={"by": ""}, follow_redirects=True)
check("bulk confirm needs a name", "확정자 이름" in html(r) and webapp.unconfirmed(store.Pier(P)) == need0)
r = c.post(f"/pier/{P}/confirm_all", data={"by": BY}, follow_redirects=True)
''', ''' r = c.post(f"/pier/{P}/section/{need0[0][0]}/{need0[0][1]}/confirm", data={"by": "", "why": "시험"}, follow_redirects=True)
check("confirm needs a name", "확정자 이름" in html(r) and webapp.unconfirmed(store.Pier(P)) == need0)
confirm_required(c, webapp, store, P, BY)
'''),
('check("bulk confirm: all required sections confirmed"', 'check("confirm: all required sections confirmed"'),
('check("bulk confirm: sections not required stay unconfirmed"', 'check("confirm: sections not required stay unconfirmed"'),
(' c.post(f"/pier/{P}/confirm_all", data={"by": BY})\n', ' confirm_required(c, webapp, store, P, BY)\n'),
])
for name, pat, rep in (
("test_web_ulsan", 'c.post(f"/pier/{P}/confirm_all", data={"by": BY})', "confirm_required(c, webapp, store, P, BY)"),
("test_web_ulsan_full", 'c.post(f"/pier/{P}/confirm_all", data={"by": BY})', "confirm_required(c, webapp, store, P, BY)"),
("test_ulsan_e2e", 'c.post(f"/pier/{P}/confirm_all", data={"by": BY})', "confirm_required(c, webapp, store, P, BY)"),
("test_okseong", 'c.post("/pier/옥성/confirm_all", data={"by": "시험"})', 'confirm_required(c, webapp, store, "옥성", "시험")'),
("test_rampa_web", 'c.post("/pier/램프/confirm_all", data={"by": "시험"})', 'confirm_required(c, webapp, store, "램프", "시험")'),
("test_confirm_required", 'c.post("/pier/울산/confirm_all", data={"by": "시험"})', 'confirm_required(c, webapp, store, "울산", "시험")'),
("test_pier_batch_button", 'c.post("/pier/버튼P/confirm_all", data={"by": "시험자"})', 'confirm_required(c, webapp, store, "버튼P", "시험자")'),
("test_variability", 'r = c.post("/pier/원본시험/confirm_all", data={"by": "시험자"})', 'confirm_required(c, webapp, store, "원본시험", "시험자")'),
):
sub(T / f"{name}.py", [(pat, rep)])
for name in ("test_okseong", "test_rampa_web", "test_confirm_required", "test_variability"):
sub(T / f"{name}.py", [("confirm_all clears", "confirming every required bundle clears"), ("after confirm_all", "after confirming every required bundle"),
("confirm_all still confirms required bundles", "confirming every required bundle one by one works")], must=False)
sub(T / "test_autocheck.py", [(
'''r = c.post("/pier/신규시험/confirm_all", data={"by": "시험자"})
check("legacy confirm_all still confirms the required bundles", not webapp.unconfirmed(store.Pier("신규시험")))''',
'''r = c.post("/pier/신규시험/confirm_all", data={"by": "시험자"})
check("the old 필요한 묶음 일괄 확정 route is gone (2026-10-19)", r.status_code in (404, 405), r.status_code)
confirm_required(c, webapp, store, "신규시험", "시험자")
check("every required bundle can still be confirmed one by one with a reason", not webapp.unconfirmed(store.Pier("신규시험")))'''),
("from engine import verify # noqa: E402", "from engine import verify # noqa: E402\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nfrom testlib import confirm_required # noqa: E402")])
print("patched")
@@ -0,0 +1,45 @@
"""2026-10-20: record the implementation of improvements #34 (2) (3) (4) (6): OPEN #34 status, planning document v0.1.09 section 6.7."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
OLD = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.08.md"
NEW = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.09.md"
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_개선구현전.md")
t = OPEN.read_text(encoding="utf-8")
a = "기획서 6.6절 참조 | 2026-10-20 | 2026-10-20 | 미해소 |"
assert t.count(a) == 1
t = t.replace(a, "기획서 6.6절 참조. **[갱신 2026-10-20] ②③④⑥ 구현 완료**(앱 확인 B3): ② C9 params↔expected 일관성(`engine/consistency.py`, 규칙 103~106건), ③ C3b 필드 단위 원본 잔존(교각 생성 시 `origin_values.json` 기록, 높이로 보이는 값만 비교), "
"④ 재료표 칸 하나에서만 따라간 앵커 후보를 C등급으로(`anchors.TABLE_RULE=\"single\"`, 확인값 기준 B등급 오답 10/65 → 2/39, A등급 영향 없음; 부작용: 운곡교 P3의 expected:G2·params:column_rebar 가 일치 → 주의/근거 없음으로 내려감), "
"⑥ 점 좌표 목록(`hooks_up` ⊂ `c14.points`) 검사는 C9에 포함. 시험 `tools/test_consistency.py`(P3·P4 최초 백업의 실제 오류 상태를 모두 잡음, 완성된 P1·P3·P4 거짓 불일치 0), 회귀 fast 16/16. 남은 것: ①⑤⑦~⑫ | 2026-10-20 | 2026-10-20 | 미해소 |")
OPEN.write_text(t, encoding="utf-8")
shutil.copy2(OLD, R / "DOC" / "01_기획" / "backup" / OLD.name)
s = OLD.read_text(encoding="utf-8")
row = "| v0.1.08 | 2026-10-20 |"
i = s.index(row)
j = s.index("\n", i)
s = s[:j + 1] + "| v0.1.09 | 2026-10-20 | 심영표 | 개선 ②③④⑥ 구현 결과 6.7절 추가 | 사용자 \"#34의 2·3·4·6번 순으로 진행\" |\n" + s[j + 1:]
sec = """### 6.7 개선 ②③④⑥ 구현 결과 (2026-10-20)
| 개선 | 구현 | 시험 결과 |
| :--- | :--- | :--- |
| ② params↔expected 일관성(C9) | `engine/consistency.py`: 같은 양을 나타내는 두 파일 값의 규칙 103~106건(기초·말뚝·기둥, 코핑 윗면 단·하면, 받침 피치·열 위치, 코핑 철근 단·C15·C16·루프·스터럽 하단, 후프·결속 사슬, 기초 철근 층·B9·B10, 재료표 사본). 다르면 해당 묶음이 불일치(b). 규칙의 경로가 없는 교각(새재1교 등)은 건너뜀 | 완성된 P1·P3·P4: 불일치 0. P3 최초 백업 상태(`before_stage2`): 54건 모두 검출, expected G1~G4 전부 불일치. P4 `before_table_apply`: 16건 검출. 다른 형식 4종: 거짓 불일치 0 |
| ③ 필드 단위 원본 잔존(C3b) | 교각 생성 시 `origin_values.json` 기록(이전 교각은 `tools/make_origin_values.py`로 초기 백업에서 재구성). 바뀐 묶음 안에서 높이로 보이는 값(10~60 m, z·단 높이·점 좌표의 z)이 기록값 그대로이면 주의(w) | P3 `before_hooks_up` 상태에서 `hooks_up` 좌표 4개를 정확히 지목. 완성된 상태: 경고 0(처음에는 길이 12400·12000 등을 높이로 오인해 경고가 많았으나 높이 키로 제한) |
| ④ 거짓 앵커 | `engine/anchors.py` `TABLE_RULE="single"`: 후보를 따라간 근거가 재료표 칸 **하나**뿐이면 C등급(`table_only`). 두 방식(`none`: 표 칸을 아예 제외)과 비교 | P3·P4 확인값 기준 B등급 오답: 끔 10/65 · single 2/39 · none 3/29. A등급 오답 0 유지. 부작용: P3에서 expected:G2(일치→주의)·params:column_rebar(일치→근거 없음) 2개 묶음의 근거가 줄어듦(표 칸 하나로 맞던 B등급 후보) |
| ⑥ 점 좌표 목록 | C9 규칙: `c14.hooks_up`의 각 점이 `c14.points`에 있어야 함 | 최초 오류(P3 `before_hooks_up`)의 4점 모두 검출, 점 하나를 옮기면 해당 항목만 깨짐 |
**검사 환경 정리**: 실제 사용 중 `운곡교p2`(휴지통으로 이동)에 의존하던 시험 5종(`test_open19`·`test_rampa`·`test_saejae`·`test_okseong`·`test_ulsan_p2`)과 `test_anchors`를 `tools/testlib.pier_dir`(휴지통 최신 복사본, 대소문자 구분)로 바꿈. `test_autocheck`는 P3가 완성 값을 갖게 되어 최초 백업(다른 교각 값) 상태로 시험하도록 바꿈. 회귀 fast 16/16 PASS(`test_consistency` 추가).
**남은 개선**: ① 읽기 도구 통합, ⑤ 일괄 실행 중단 표시, ⑦ 확정 사유 품질, ⑧ 임시 등록 관리, ⑨ 독립 근거 구분 표시, ⑩~⑫.
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
NEW.write_text(s, encoding="utf-8")
OLD.unlink()
print("ok")
@@ -0,0 +1,47 @@
"""2026-10-20: record improvements #34 (1) (5): OPEN #34 status, planning document v0.1.10 section 6.8."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
OLD = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.09.md"
NEW = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.10.md"
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_개선구현2전.md")
t = OPEN.read_text(encoding="utf-8")
a = "시험 `tools/test_consistency.py`(P3·P4 최초 백업의 실제 오류 상태를 모두 잡음, 완성된 P1·P3·P4 거짓 불일치 0), 회귀 fast 16/16. 남은 것: ①⑤⑦~⑫ | 2026-10-20 | 2026-10-20 | 미해소 |"
assert t.count(a) == 1
t = t.replace(a, "시험 `tools/test_consistency.py`(P3·P4 최초 백업의 실제 오류 상태를 모두 잡음, 완성된 P1·P3·P4 거짓 불일치 0), 회귀 fast 16/16. "
"**[갱신 2026-10-20] ①⑤ 구현 완료**: ① `engine/geomread.py`(읽기 규칙 통합, 완성된 P1·P3·P4 에서 읽은 값 = 현재 값, 초기 P4 백업 → 144건 제안이 검증된 값과 같음)와 화면 「도면에서 읽기」(고른 줄만 반영, 백업·`read_log.json`, 다른 도면 틀 거절, 추론 줄은 기본 미선택) 및 `tools/apply_pier_read.py` 가 같은 읽기를 씀, `tools/test_geomread.py`. "
"⑤ 일괄 실행이 끝까지 가지 못했을 때 게이트 화면이 이유(error/fail/blocked/killed)·이전 실행 결과 표시·중간 결과·무효 승인을 보여 줌(`batch.json` 에 `started`·`reason`), `tools/test_batch_notice.py`. 회귀 fast 18/18. 남은 것: ⑦~⑫ | 2026-10-20 | 2026-10-20 | 미해소 |")
OPEN.write_text(t, encoding="utf-8")
shutil.copy2(OLD, R / "DOC" / "01_기획" / "backup" / OLD.name)
s = OLD.read_text(encoding="utf-8")
row = "| v0.1.09 | 2026-10-20 |"
i = s.index(row)
j = s.index("\n", i)
s = s[:j + 1] + "| v0.1.10 | 2026-10-20 | 심영표 | 개선 ①⑤ 구현 결과 6.8절 추가 | 사용자 \"①(읽기 도구 통합), ⑤(일괄 실행 중단 표시)를 진행해줘\" |\n" + s[j + 1:]
sec = """### 6.8 개선 ①⑤ 구현 결과 (2026-10-20)
| 개선 | 구현 | 시험 결과 |
| :--- | :--- | :--- |
| ① 읽기 도구 통합 | `engine/geomread.py`: P3·P4 모델링 때 따로 만든 읽기 도구(코핑 외곽·철근 선·원, 기초·말뚝·기둥 글자, 받침 폴리라인, 재료표, 기초 철근 규칙)를 하나로 합친 순수 함수 `read()`(부작용 없음, 사본과 변경 목록 반환)와 `apply_selected()`. `engine/footing_rule.py`(기초 철근 규칙). 화면 「도면에서 읽기」(`/pier/<이름>/geomread`): 묶음별 현재/읽은 값/근거 표, 체크한 줄만 반영, 반영 전 `backup/before_geomread_*`, `read_log.json`, 추론 줄 기본 미선택, 도면 틀 일치율 70 % 미만 또는 필요한 선·글자가 없으면 이유를 알리고 읽지 않음. `tools/apply_pier_read.py` 는 같은 읽기의 명령줄 판 | `tools/test_geomread.py` PASS: 운곡교 P1·P3 에서 읽은 값 = 현재 값(변경 0), P4 는 추론 줄 1건만, 초기 P4 백업(P1 복사)에서 144건 제안이 검증된 P4 값과 모두 같음(표시 선택 `hidden_ids` 제외), 다른 도면 틀 2종 거절, 화면에서 고른 줄만 기록·백업·로그·확정 무효화 |
| ⑤ 일괄 실행 중단 표시 | `batch.json` 에 `started`·`reason`(error/fail/blocked, 서버 중단은 killed)·`error`·`gate`, 게이트 결과에 `at`. 게이트 화면: 중단 이유 안내, 결과 시각과 '이전 실행 결과' 표시, 중단되기 전에 계산된 검사기 원본(`<게이트>.json` 이 기록(`_gate.json`)보다 새로울 때)을 '중단된 실행의 임시 결과'로 표시(승인·보고서에 쓰이지 않음), 입력이 바뀐 뒤의 승인은 '승인 기록은 이전 입력 것(무효)' | `tools/test_batch_notice.py` PASS: 오류·FAIL·조건 미충족·서버 중단 안내, 이전 실행 결과 표시, 임시 결과, 무효 승인 표시, 완료된 실행에는 안내 없음, 실제 작업자(`_batch`)에 가짜 Rhino 를 연결해 세 중단 경로의 기록 확인 |
**선택과 한계**
- 읽기 결과를 params 와 expected 에 함께 채우는 것은 두 파일의 독립성을 떨어뜨림 → 화면 문구와 앱 확인 C9 설명으로 알림, 표(재료표) 값은 표 후보값 화면에서 따로 대조.
- 표시 선택 `display.hidden_ids` 는 교각 이름이 바뀌면 템플릿의 숨김을 새 이름으로 옮겨 제안(P4 는 사용자가 비움으로 정함).
- 읽기 규칙의 범위: 운곡교 도면 틀 1종, 교각 4개. 좌측 스터럽 윗면(추론)·c14 좌측 규칙·결속철근 끝 높이는 P1 규칙 가정.
- 환경: 일괄 실행 오류 시 중간 결과가 `<게이트>.json` 으로 남는 것을 이용(별도 저장 없음).
**남은 개선**: ⑦ 확정 사유 품질, ⑧ 임시 등록 관리, ⑨ 독립 근거 구분 표시, ⑩ P1 규칙 가정 단계 읽기, ⑪ 도면 해시·개정 번호, ⑫ 다른 도면 틀 일반성.
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
NEW.write_text(s, encoding="utf-8")
OLD.unlink()
print("ok")
@@ -0,0 +1,82 @@
"""2026-10-20: record the Ungok P3 / P4 real-use results and the app improvement list (OPEN #34 / #35, #26 / #33 update, planning document v0.1.08 section 6.6)."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
PLAN_OLD = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-19_v0.1.07.md"
PLAN_NEW = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.08.md"
# ---------------- OPEN
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_P3P4개선전.md")
t = OPEN.read_text(encoding="utf-8")
a = "**남은 것**: 표 후보 검토·반영과 params 독립 판독(별도 Task), 운곡교 P4 도면 판(#33)"
assert t.count(a) == 1
t = t.replace(a, "**[갱신 2026-10-20] 운곡교 P3·P4 실사용 완료**: 도면에서 읽은 값으로 params·expected 반영 후 G1~G5 모두 통과·승인(P3 578/585·P4 577/585, 계획 외 FAIL 0, 계획된 FAIL P3 7·P4 8), 도구 `tools/verify_pier_geometry.py`·`apply_pier_read.py` 추가 — 개선 목록은 #34, 설계자 확인 대상은 #35. **남은 것**: 앱 개선(#34), 설계자 확인(#35)")
# #33 update
b = "확인: 사용자가 보는 파일을 앱에 다시 올려(sha 비교) 오류가 개정본에서 고쳐졌는지 확인"
assert t.count(b) == 1
t = t.replace(b, b + ". **[갱신 2026-10-20]** 사용자가 P4 도면을 다시 올렸으나 해시가 같음(D2 `2ea47f8d81b1…`) → 같은 판. 오류는 그대로. 기초 철근 개수(재료표 65·34본)는 이 사슬과 무관하게 규칙 계산과 일치하므로 모델 값 선택에는 영향 없음(P4 G1~G5 통과)")
row34 = ("| 34 | 앱 개선 목록(운곡교 P3·P4 실사용에서 도출, 효과 큰 순): ① 도면 읽기 도구(`verify_pier_geometry.py`·`apply_pier_read.py`)를 앱 후보 화면에 통합, 템플릿 재현 검증 포함(새 교각당 수작업 100곳 이상 감소) "
"② params↔expected 일관성 검사(Rhino 실행 전, 게이트 1회 약 150초) ③ 묶음 안 필드 단위 '템플릿 원본 그대로 남음' 검사(P3: 앱 확인 불일치 0인데 게이트 40여 건 실패) "
"④ 표 칸에서 학습한 앵커의 거짓 후보 제거(재료표 행이 밀리면 '3.800' 같은 길이 칸을 사슬 끝으로 오인) ⑤ 일괄 실행이 마지막 단계(`display.py`)에서 중단될 때 중간 결과와 이전 승인 요약을 구분해 표시 "
"⑥ 점 좌표를 키로 쓰는 목록(`hooks_up` 등) 정합 검사(P3 G4 코핑 밖 점 56개의 원인) ⑦ 확정 사유 최소 품질(사유 'asf'·'12' 방지) ⑧ 임시 등록(계획된 FAIL) '설계자 확인 대기' 상태 관리와 더는 해당 없는 항목 자동 탐지(P2의 G3 B9 551/493 항목이 P3·P4에 남음) "
"⑨ 보고서에 독립 근거 구분 표시(PASS = 내부 일관성이며 도면 보증이 아님) ⑩ 후프 반경 마크별(표에서 읽음), 스터럽 좌측 윗면·c14 좌측 규칙 같은 P1 규칙 가정 단계의 도면 읽기 ⑪ 도면 파일 해시·개정 번호 기록과 비교 ⑫ 다른 도면 틀·형식(울산·새재1교 등)에서의 읽기 도구 일반성 확인. 기획서 6.6절 참조 | 2026-10-20 | 2026-10-20 | 미해소 | "
"[ref: DOC/01_기획/기획_입력묶음확인확정개편_2026-10-20_v0.1.08.md §6.6] |")
row35 = ("| 35 | 운곡교 P3·P4 설계자 확인 대상(모두 **임시 등록**, 사용자 승인 문구 '설계자 확인 전'): P3 — 재료표 C13 L 28.966(총중량 260.694÷9본과 일치)이 규칙 (max+min)/2 = 29.346과 380 mm 다름. "
"P4 — ① 재료표 B11 30본 대 도면 점 32본(우측 반 작도, 좌우 대칭 가정; P1 28/28·P3 32/32는 일치) ② B12 재료표 38본 대 작도 32본(P1 34/28·P3 38/32와 같은 F2 규칙) "
"③ C9 좌측 스터럽 윗면은 도면에 없어 재료표 C9 b 최대 2.812·최소 1.376 에서 역산한 40447(= 철근 단 1 높이)로 둠(P1·P3의 '소단 − 100' 규칙과 다름) ④ C13 L 28.966(P3와 동일, 개정판 공통 표기 가능성) ⑤ D2 사슬 `62@125=7.250`·`29@250=7.750`(#33). "
"P3·P4 승인 기록은 일괄 실행 자동 승인이며 보고서 3~5장은 담당자 작성 항목 | 2026-10-20 | 2026-10-20 | 미해소 | "
"[ref: Code/교각모델링앱/data/운곡교P3·P4/expected.json planned_fail, prompt/심영표/2026-10-19/Task_도면도형직접판독시험_2026-10-19_v0.1.00.md] |")
t = t.rstrip("\n") + "\n" + row34 + "\n" + row35 + "\n"
OPEN.write_text(t, encoding="utf-8")
# ---------------- planning document v0.1.08
shutil.copy2(PLAN_OLD, R / "DOC" / "01_기획" / "backup" / PLAN_OLD.name)
s = PLAN_OLD.read_text(encoding="utf-8")
row = "| v0.1.07 | 2026-10-19 |"
i = s.index(row)
j = s.index("\n", i)
s = s[:j + 1] + ("| v0.1.08 | 2026-10-20 | 심영표 | 운곡교 P3·P4 실사용 결과와 앱 개선점 6.6절 추가(OPEN #34·#35) | 사용자 \"앱의 개선점을 알려줘\" → \"진행해줘\" |\n") + s[j + 1:]
sec = """### 6.6 운곡교 P3·P4 실사용 결과와 앱 개선점 (2026-10-20)
**결과 요약**
| 교각 | 입력 | 게이트 결과 | 검사 | 계획된 FAIL | 계획 외 FAIL |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 운곡교 P3 | 도면에서 읽은 값으로 params·expected 갱신(77곳 + 표 후보 105건 + 2차 48곳) | G1~G5 모두 PASS·승인 | 578/585 | 7 (B12, C9~C13 원래 길이 5건, C13 규칙) | 0 |
| 운곡교 P4 | `apply_pier_read.py` 로 144곳 + 표 후보 118건 | G1~G5 모두 PASS·승인 | 577/585 | 8 (B11, B12, C9~C13 원래 길이 5건, C13 규칙) | 0 |
- 첫 실행 → 최종 실행의 계획 외 FAIL: P3 G1 34·G2 7·G3 5·G4 28 → 0, P4 G1 18·G2 10·G3 22·G5 10(허위 G4 PASS) → 0.
- 새 도구: `tools/verify_pier_geometry.py`(도면 값 55항목을 params 와 비교; P1·P3 55/55 로 재현 확인), `tools/apply_pier_read.py`(읽은 값을 params·expected 에 반영; P1·P3 `--dry` 로 재현 확인), `tools/infer_footing_rebar.py`(기초 철근 층 규칙; P1 재현), `tools/extract_coping_outline.py`.
- 읽을 수 있는 값: 표 3종(받침·솔플레이트·재료표), 치수 글자, 코핑 외곽·철근 단·C15·C16, 받침 위치·회전, 기초·말뚝·기둥, 기초 철근 층(규칙 + 재료표 개수 일치), 기둥 철근(후프·결속). 읽을 수 없거나 가정: 스터럽 좌측 윗면, c14 좌측 규칙, 결속철근 끝 높이, 설계 의도.
**앱 개선점 (OPEN #34, 효과 큰 순)**
| # | 개선 | 근거(실제 사례) |
| :--- | :--- | :--- |
| 1 | 도면 읽기 도구를 후보 화면에 통합, 템플릿 재현 검증 포함 | P3 수작업 77곳, P4 144곳 |
| 2 | params↔expected 일관성 검사(Rhino 실행 전) | 게이트 1회 약 150초, P3 실패의 대부분이 옛 값 혼재 |
| 3 | 필드 단위 '템플릿 원본 그대로 남음' 검사 | P3: 앱 확인 불일치 0인데 게이트 40여 건 실패 |
| 4 | 표 칸에서 학습한 앵커의 거짓 후보 제거 | P3·P4: 재료표 S1 길이 칸 '3.800' 을 G3 사슬 끝으로 오인 |
| 5 | 일괄 실행 중단 시 중간 결과·이전 승인 요약 구분 표시 | P4: `display.py` 오류 후 승인 요약이 이전 실행 것으로 남음 |
| 6 | 점 좌표를 키로 쓰는 목록 정합 검사 | P3: `hooks_up` 옛 좌표로 G4 코핑 밖 점 56개 |
| 7 | 확정 사유 최소 품질 | 사유 'asf', 'afs', '12' |
| 8 | 임시 등록 '설계자 확인 대기' 상태 관리, 해당 없음 항목 자동 탐지 | P2 의 G3 B9 551/493 항목이 P3·P4 에 남음 |
| 9 | 보고서에 독립 근거 구분 표시 | PASS = 내부 일관성(params·expected 가 같은 읽기에서 나온 값 다수) |
| 10 | P1 규칙 가정 단계(스터럽 좌측 윗면, c14 좌측 규칙)의 도면 읽기 | P4: 규칙이 틀려 재료표 역산값으로 교체 |
| 11 | 도면 파일 해시·개정 번호 기록·비교 | P4 D2 사슬 오류: 사용자 CAD 판과 올린 파일 불일치 의심 |
| 12 | 다른 도면 틀·형식(울산·새재1교)에서의 일반성 확인 | 교각 2개·템플릿 1개로만 확인 |
**설계자 확인 대상**(OPEN #35): P3 C13, P4 B11·B12·C9·C13, D2 사슬(#33). 모두 임시 등록이며 보고서에 '설계자 확인 전'으로 표시해야 한다.
**이번 작업에서 생긴 오류(재발 방지)**: `apply_pier_read.py` 가 pier_id 를 바꾸면서 `display.hidden_ids` 를 바꾸지 않아 P4 일괄 실행이 마지막 단계에서 중단됨 → 스크립트 수정(개선 3·6 과 같은 종류: 파생 값의 동시 갱신 누락).
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
PLAN_NEW.write_text(s, encoding="utf-8")
PLAN_OLD.unlink()
print("ok")
@@ -0,0 +1,49 @@
"""Manual v0.2.12 -> v0.2.13 (2026-10-20): app improvements #34-2/3/4/6 (C9 params<->expected consistency, C3b field-level 'still the creation value',
point-list rule, table-cell anchors). The v0.2.12 file moves to backup/ after this writes v0.2.13."""
import shutil
from pathlib import Path
DOC = Path(__file__).resolve().parents[4] / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-19_v0.2.12.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.13.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.12 | 2026-10-19 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.13 | 2026-10-20 | 심영표 | 앱 확인 검사 2종 추가(C9 params↔expected 일관성, C3b 필드 단위 원본 잔존)와 점 좌표 목록 검사, 표 칸 하나에서만 따라간 표 밖 치수 후보를 C등급으로 내림: 4 화면 구성, 5.3, 5.4, 6.1, 8, 9, 부록 B | OPEN #34 ②③④⑥ |\n" + s[j + 1:]
rep("| 근거 보기 | 묶음 표의 [근거 보기] | 앱 확인 8종 검사별 결과·비교한 내용·도면 위치, 사유와 함께 **확정** |",
"| 근거 보기 | 묶음 표의 [근거 보기] | 앱 확인 검사별 결과(C1~C9, C3b)·비교한 내용·도면 위치, 사유와 함께 **확정** |")
rep("앱 확인 8종(C1 출처·C2 표 대조·C2b 표 밖 치수·C3 템플릿 원본·C4 수량·C5 params 독립·C6 범위·C7 고정 항목·C8 재생성)",
"앱 확인 검사(C1 출처·C2 표 대조·C2b 표 밖 치수·C3 템플릿 원본·C3b 필드 단위 원본 잔존·C4 수량·C5 params 독립·C6 범위·C7 고정 항목·C8 재생성·C9 params↔expected 일관성)")
rep("| 근거 보기 | 앱 확인 8종 검사의 결과와 비교 내용을 보는 화면 |", "| 근거 보기 | 앱 확인 검사(C1~C9, C3b)의 결과와 비교 내용을 보는 화면 |\n| C9 일관성 | 같은 양을 나타내는 params 값과 expected 값이 서로 같은지 보는 검사. 게이트를 돌리기 전에 \"한쪽만 바뀌고 다른 쪽은 옛 값\"을 찾는다. 같은 도면 읽기에서 나온 값이면 서로 같은 것이 당연하므로 독립 근거는 아니다 |\n| C3b 필드 단위 원본 잔존 | 교각을 만들 때의 값 기록(origin_values.json)과 비교해, 바뀐 묶음 안에서 높이로 보이는 값 몇 개가 옛 값 그대로 남았는지 알려 주는 검사(주의) |")
# 5.4 after the check table note
rep("'일치'는 \"독립 근거와 같다\"는 뜻이지 \"도면이 맞다\"는 보증이 아닙니다. 철근 위치·좌표와 표 밖 치수는 아직 앱이 대조하지 못하는 부분이 많습니다.",
"'일치'는 \"독립 근거와 같다\"는 뜻이지 \"도면이 맞다\"는 보증이 아닙니다. 철근 위치·좌표와 표 밖 치수는 아직 앱이 대조하지 못하는 부분이 많습니다.\n\n"
"**검사 2종 추가 (2026-10-20)**\n"
"- **C9 params↔expected 일관성**: 새 도면 값을 넣다가 params만 고치고 expected는 옛 값으로 남기는 실수를 게이트 실행 전에 찾습니다(예: 코핑 윗면 단 높이, 받침 피치, 말뚝 피치·길이, 후프 사슬, 기초 철근 개수와 격자). 다르면 해당 묶음이 **불일치**가 되고 근거 보기에 \"params … = ○ ≠ expected … = ○\"가 나옵니다. 점 좌표로 대상을 지정하는 목록(`coping_rebar.c14.hooks_up`)이 실제 점(`c14.points`)과 맞는지도 같은 검사에서 봅니다. 새재1교·옥성교 같은 다른 형식은 해당하는 규칙만 적용됩니다.\n"
"- **C3b 필드 단위 원본 잔존**: 교각을 만들 때 기록한 값(`origin_values.json`)과 비교해, 이미 바뀐 묶음 안에서 높이로 보이는 값 중 옛 값 그대로 남은 것이 있으면 **주의**로 알려 줍니다(예: 새 도면의 높이로 바꾼 뒤에도 옛 좌표가 남은 점 목록). 이 기록이 없는 교각(앱이 기록을 만들기 전에 만든 교각)은 해당 없음이고, 필요하면 `python tools\\make_origin_values.py <교각> <백업 폴더>`로 초기 백업에서 만들 수 있습니다.")
# 5.3 anchors
rep("**[선택한 후보 반영]**은 선택한 줄에만 적용되고",
"재료표 칸 **하나**에서만 따라간 후보는 재료표 행이 밀려 거짓 후보가 되기 쉬워서(운곡교 P3·P4에서 재료표의 길이 칸 \"3.800\"을 철근 사슬 끝으로 오인) 자동으로 C등급으로 내립니다(운곡교 P3·P4 확인값 기준 B등급 오답 10/65 → 2/39). **[선택한 후보 반영]**은 선택한 줄에만 적용되고")
# 6.1 files
rep("| `anchors.json` | 표 밖 치수 앵커(원본 교각이 값을 읽은 도면 문자 위치). 템플릿에서 복사 |",
"| `anchors.json` | 표 밖 치수 앵커(원본 교각이 값을 읽은 도면 문자 위치). 템플릿에서 복사 |\n| `origin_values.json` | 교각을 만들 때의 params·expected 값 기록(C3b). 교각을 만들 때 앱이 자동으로 쓰며, 이전 교각은 `tools\\make_origin_values.py`로 초기 백업에서 만든다(\"reconstructed\"로 표시) |")
# 8 troubleshooting
rep("| 도면 치수 사슬 경고 | 도면의 `개수@간격=합계`가 맞지 않음 | 도면 자체의 오류일 수 있으니 [도면 보기]로 확인하고 설계자에게 문의 |",
"| 도면 치수 사슬 경고 | 도면의 `개수@간격=합계`가 맞지 않음 | 도면 자체의 오류일 수 있으니 [도면 보기]로 확인하고 설계자에게 문의 |\n"
"| 근거 보기에 \"C9 … params … ≠ expected …\" | 같은 양을 나타내는 두 파일 값이 다름 | 어느 쪽이 도면 값인지 확인하고 다른 쪽을 고침(묶음 편집). 고친 묶음은 다시 확정 |\n"
"| \"C3b … 생성 시점 값 그대로\" 주의 | 바뀐 묶음 안에서 높이 값 몇 개가 옛 값 그대로 | 해당 값이 새 도면에서도 같은 값이 맞는지 도면과 대조. 맞다면 사유를 적고 확정 |")
# 9 limits
rep("- **표 밖 치수 후보의 한계**:", "- **C9의 한계**: 두 파일의 값이 같다는 것만 확인합니다. 같은 도면 읽기에서 두 파일을 채웠다면 틀린 값도 서로 같을 수 있습니다.\n- **표 밖 치수 후보의 한계**:")
DST.write_text(s, encoding="utf-8")
(DOC / "backup").mkdir(exist_ok=True)
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
print("written", DST.name)
@@ -0,0 +1,48 @@
"""Manual v0.2.13 -> v0.2.14 (2026-10-20): improvements #34-1 (screen 도면에서 읽기) and #34-5 (batch run that did not finish). v0.2.13 moves to backup/."""
import shutil
from pathlib import Path
DOC = Path(__file__).resolve().parents[4] / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.13.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.14.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.13 | 2026-10-20 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.14 | 2026-10-20 | 심영표 | 화면 「도면에서 읽기」 추가(운곡교 도면 틀의 코핑·기초·말뚝·기둥·받침·철근 값을 도면에서 읽어 비교·반영), 일괄 실행이 끝까지 가지 못했을 때의 안내(중단 이유, 이전 실행 결과 표시, 중간 결과, 무효 승인 표시): 4 화면 구성, 5.3, 5.5, 6.1, 8, 9, 부록 A | OPEN #34 ①⑤ |\n" + s[j + 1:]
rep("| 표 후보값 | 위쪽 메뉴 |", "| 도면에서 읽기 | 위쪽 메뉴 | 운곡교 P1 도면 틀(P1~P4)에서 코핑·기초·말뚝·기둥·받침·철근 값을 **도면에서 읽어** 현재 값과 비교하고, **고른 줄만 반영**(반영 전 백업, 반영 기록 `read_log.json`) |\n| 표 후보값 | 위쪽 메뉴 |")
rep("### 5.3 값 확인\n\n입력 값은 두 종류입니다.",
"### 5.3 값 확인\n\n"
"**도면에서 읽기 (운곡교 도면 틀)**: 위쪽 메뉴 [도면에서 읽기]는 운곡교 P1 도면 틀(P1~P4)의 첫 번째 도면(D1)과 철근재료표에서 코핑 윗면 계단·철근 단 높이·C14 점, 기초·말뚝·기둥 값, 받침 위치·회전, 기초 철근 층(규칙 + 재료표 개수), 기둥 철근(후프·결속)을 읽어 현재 값과 비교합니다. "
"묶음별로 \"현재 / 읽은 값 / 근거\" 표가 나오고, **체크한 줄만** [선택한 줄 반영]으로 들어갑니다. 반영 전에 `params.json`·`expected.json`·`confirm.json`을 `backup\\before_geomread_…`에 보관하고 반영한 줄을 `read_log.json`에 남기며, 바뀐 묶음은 다시 확정해야 합니다.\n"
"- 도면 틀이 다르면(공통 문자 비율 70 % 미만, 예: 새재1교·옥성교) 읽지 않고 이유를 알려 줍니다.\n"
"- 읽기 규칙은 모두 완성된 교각(P1)에서 같은 값이 재현되는지 먼저 확인한 것입니다. 이미 완성된 교각(P1·P3·P4)에서는 \"읽은 값이 모두 현재 값과 같습니다\"가 나옵니다.\n"
"- **추론 줄**(좌측 스터럽 윗면 등 도면에 그려져 있지 않은 값)은 기본으로 선택되어 있지 않습니다. 재료표와 대조한 뒤 반영 여부를 정하세요.\n"
"- params와 expected를 같은 도면 읽기로 채우면 둘이 서로 같은 것은 독립 근거가 아닙니다(앱 확인 C9). 표(재료표) 값은 [표 후보값]에서 따로 대조하세요.\n"
"- \"확인이 필요한 점\"에는 도면 점 개수와 재료표 개수가 다른 경우(예: B11·B12), 단순 이동값과 도면 원이 다른 점 등이 나옵니다. 재료표와 작도가 다를 때 작도를 채택하려면 계획된 FAIL 등록이 필요합니다.\n\n"
"입력 값은 두 종류입니다.")
rep("**결과 표 읽는 법**",
"**일괄 실행이 끝까지 가지 못했을 때 (2026-10-20)**: 게이트 화면 위에 빨간 안내가 나옵니다.\n"
"- **이유**: 오류(원인 한 줄 표시), 어느 게이트의 FAIL, 실행 조건 미충족, 서버가 멈춤 중 하나로 알려 줍니다.\n"
"- **이전 실행 결과**: 이번 일괄 실행에서 갱신되지 않은 게이트는 \"이전 실행 결과\"로 표시하고 결과 시각을 함께 보여 줍니다. 그 결과와 승인을 이번 입력의 결과로 읽지 마세요.\n"
"- **임시 결과**: 중단되기 전에 계산된 게이트가 있으면 해당 게이트 표 위에 \"중단된 실행의 임시 결과\"(검사 PASS 수, FAIL 항목)를 따로 보여 줍니다. 계획된 예외를 구분하지 않은 검사기 원본이며 승인·보고서에는 쓰이지 않습니다.\n"
"- 입력이 바뀐 뒤의 옛 승인은 \"승인 기록은 이전 입력 것(무효)\"로 표시합니다.\n\n"
"**결과 표 읽는 법**")
rep("| `confirm.json` | 묶음별 확정 기록 (확정자, 시각, 사유, 확정 당시 앱 확인 상태) |",
"| `confirm.json` | 묶음별 확정 기록 (확정자, 시각, 사유, 확정 당시 앱 확인 상태) |\n| `read_log.json` | 「도면에서 읽기」로 반영한 기록(시각, 반영자, 건수, 백업 폴더, 줄 목록) |")
rep("| \"C3b … 생성 시점 값 그대로\" 주의 |",
"| 게이트 화면 \"이 일괄 실행은 끝까지 가지 못했습니다\" | 오류·FAIL·조건 미충족·서버 중단 | 안내에 적힌 이유를 해결하고 일괄 승인을 다시 실행. 표의 \"이전 실행 결과\"는 옛 결과 |\n| 「도면에서 읽기」가 \"읽지 않습니다\" | 도면 틀이 다르거나 D1에서 필요한 선·글자를 찾지 못함 | 운곡교 P1~P4 도면 틀이 아니면 값을 직접 입력하거나 [표 후보값]을 사용 |\n| \"C3b … 생성 시점 값 그대로\" 주의 |")
rep("- **C9의 한계**:", "- **도면에서 읽기의 한계**: 운곡교 도면 틀 한 종류와 교각 4개(P1·P3·P4와 템플릿)에서만 확인했습니다. 좌측 스터럽 윗면·c14 좌측 규칙·결속철근 끝 높이는 P1 규칙을 가정한 추론이고, 설계 의도(기초 회전 방향 등)는 읽지 못합니다.\n- **C9의 한계**:")
rep("| 템플릿 변수 등급 파일 만들기 | `python tools\\make_variability.py [--force]` |",
"| 템플릿 변수 등급 파일 만들기 | `python tools\\make_variability.py [--force]` |\n| 도면에서 읽은 값 보기·반영(화면 「도면에서 읽기」와 같은 읽기) | `python tools\\apply_pier_read.py <교각> [--dry]` |\n| 도면 값 55항목을 params와 비교 | `python tools\\verify_pier_geometry.py <교각>` |\n| 교각 생성 시점 값 기록을 초기 백업에서 만들기(C3b) | `python tools\\make_origin_values.py <교각> <백업 폴더 이름>` |")
DST.write_text(s, encoding="utf-8")
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
print("written", DST.name)
@@ -0,0 +1,87 @@
"""2026-10-20: manual v0.2.14 -> v0.2.15 (검토 요약 확정), planning document v0.1.10 -> v0.1.11 (6.9), OPEN #36. Old files move to backup/."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
DOC = R / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.14.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.15.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.14 | 2026-10-20 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.15 | 2026-10-20 | 심영표 | 「검토 요약 확정」 추가(한 화면에서 검토하고 한 번에 확정, 사유 자동 기록): 4 화면 구성, 5.4, 6.1, 8, 9 | 사용자 \"확정 판정에 시간이 너무 걸리고 사용자가 확인하기 어렵다\" → 권고안 진행 |\n" + s[j + 1:]
rep("| 일치 묶음 일괄 확정 | 교각 화면의 버튼 |",
"| 검토 요약 확정 | 교각 화면의 [검토 요약으로 확정] | 확정이 필요한 묶음을 **한 화면**에 모아 \"바뀐 것 · 앱이 확인한 것 · 앱이 확인하지 못한 것 · 값의 출처\"를 보여 주고, 사용자가 **도면과 대조한 묶음에 체크**한 뒤 이름을 넣어 **한 번에 확정**. 불일치 묶음은 확정 불가, 사유는 앱이 자동 기록 |\n| 일치 묶음 일괄 확정 | 교각 화면의 버튼 |")
rep("### 5.4 확정\n\n확정은 **\"내가 이 값을 도면과 대조했다\"는 사람의 기록**입니다. 앱은 확정하지 않고, 확정에 쓸 근거를 모아 줍니다.\n",
"### 5.4 확정\n\n확정은 **\"내가 이 값을 도면과 대조했다\"는 사람의 기록**입니다. 앱은 확정하지 않고, 확정에 쓸 근거를 모아 줍니다.\n\n"
"**권장: 검토 요약으로 확정 (2026-10-20)** — 교각 화면 2절의 [검토 요약으로 확정]을 누르면 확정이 필요한 묶음이 한 화면에 모입니다.\n"
"1. **불일치**(빨간 구역): 확정할 수 없습니다. 값을 고치거나 원인을 해결한 뒤 다시 확인하세요.\n"
"2. **주의·근거 없음**: 묶음마다 \"바뀐 것\"(교각을 만들 때 대비 몇 개 값이 어떻게 바뀌었는지), \"앱이 확인하지 못한 것\", \"값의 출처\"(도면에서 읽기·표 밖 치수 후보 반영 이력)가 나옵니다. **도면과 직접 대조한 묶음에만 체크**하세요. 체크하지 않은 묶음은 미확정으로 남습니다.\n"
"3. **사람 결정 기록**(예: 계획된 FAIL 목록): 내용을 읽고 체크합니다. 설계자 확인 전 임시 등록은 사유에 그렇게 적혀 있는지 보세요.\n"
"4. **앱 확인 일치**: 근거를 훑어보고 맨 아래 \"일치 ○개의 근거를 읽었습니다\"에 한 번 체크합니다.\n"
"5. 목록을 **끝까지 내리면** 확정자 이름과 공통 의견(선택)을 넣을 수 있고, [선택한 묶음 확정]을 누르면 체크한 묶음이 한 번에 확정됩니다.\n"
"- **사유는 앱이 자동으로 기록합니다**: \"[검토 요약 일괄 확정] 사용자가 도면과 대조했다고 표시함. 앱 확인 근거 없음 — … | 변경: … | 출처: … | 의견: …\". 확정 기록에는 그 시점의 앱 확인 상태가 함께 남아 보고서 8.1장에 올라갑니다.\n"
"- 목록이 바뀌었으면(값을 고침) \"끝까지 읽은 뒤에 확정할 수 있습니다\"가 나오므로 화면을 다시 여세요.\n"
"- 체크는 사용자의 선언입니다. 앱은 실제로 대조했는지 확인할 수 없으므로, 체크한 묶음은 도면과 직접 대조한 것이어야 합니다.\n\n"
"아래는 같은 확정을 다른 방법으로 하는 경우입니다.\n")
rep("| `confirm_view.json` | 일치 묶음 일괄 확정에서 근거표를 끝까지 읽었다는 표식 |",
"| `confirm_view.json`, `review_view.json` | 일치 묶음 일괄 확정, 검토 요약 확정에서 목록을 끝까지 읽었다는 표식(그때 목록의 해시) |")
rep("| \"C3b … 생성 시점 값 그대로\" 주의 |",
"| 검토 요약에서 \"끝까지 읽은 뒤에 확정할 수 있습니다\" | 목록을 끝까지 내리지 않았거나 읽은 뒤 값이 바뀌어 목록이 달라짐 | 화면을 다시 열어 끝까지 내림 |\n| 검토 요약에서 \"확정한 묶음이 없습니다\" | 체크한 묶음이 없음(일치 묶음은 맨 아래 \"읽었습니다\" 체크) | 도면과 대조한 묶음에 체크 |\n| \"C3b … 생성 시점 값 그대로\" 주의 |")
rep("- **C9의 한계**:", "- **검토 요약 확정의 한계**: 체크는 사용자의 선언이라 앱이 실제 대조 여부를 확인하지 못합니다. 사유는 앱이 아는 사실로 자동 작성되지만 도면과 무엇을 어떻게 대조했는지는 공통 의견에 직접 적어야 남습니다.\n- **C9의 한계**:")
DST.write_text(s, encoding="utf-8")
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
# ---- planning document + OPEN
PO = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.10.md"
PN = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.11.md"
shutil.copy2(PO, R / "DOC" / "01_기획" / "backup" / PO.name)
t = PO.read_text(encoding="utf-8")
row = "| v0.1.10 | 2026-10-20 |"
i = t.index(row)
j = t.index("\n", i)
t = t[:j + 1] + "| v0.1.11 | 2026-10-20 | 심영표 | 검토 요약 확정(한 화면 검토 + 한 번 확정) 6.9절 추가 | 사용자 \"확정 판정이 오래 걸리고 확인하기 어렵다 → 앱이 일괄 처리, 사용자는 일괄 검토\" 제안 → 권고안 진행 |\n" + t[j + 1:]
sec = """### 6.9 검토 요약 확정 (2026-10-20)
**문제**: 묶음 20~23개를 하나씩 확정하는 데 시간이 오래 걸리고, 사유가 비거나('12', 'asf') 무엇이 바뀌었는지 보기 어려웠다. 사용자 제안: 앱이 일괄 확정하고 사용자는 일괄 검토만 한다.
**결정(권고안)**: "앱이 확정"은 확정의 의미(사람이 도면과 대조했다는 기록)를 없애므로 채택하지 않고, **앱이 검토를 준비하고 사람이 한 화면에서 검토·체크해 한 번에 확정**한다(D1 유지). 근거 없음 묶음은 **항목별 체크**(a)를 요구, 기존 개별 확정 화면은 예외 처리용으로 유지.
| 요소 | 구현 |
| :--- | :--- |
| 화면 | `/pier/<이름>/review` (교각 화면 [검토 요약으로 확정]). 확정이 필요한 묶음을 네 구역으로: 불일치(확정 불가), 주의·근거 없음(항목별 체크), 사람 결정 기록(체크), 일치(근거 훑어보기 + 구역 체크 1회) |
| 앱이 준비하는 것 | `engine/review.py`: 바뀐 것(교각 생성 시점 값 기록 `origin_values.json` 대비 변경 값 수와 예), 앱이 확인하지 못한 것(근거 없음·주의 검사 항목), 값의 출처(`read_log.json`·`anchor_log.json` 이력), 사유 자동 작성(`auto_reason`) |
| 사람이 하는 것 | 도면과 대조한 묶음에 체크, 목록 끝까지 읽기(표식 `review_view.json` = 그때 목록의 해시), 확정자 이름, 공통 의견(선택) |
| 기록 | 묶음별 확정 기록에 사유(앱 확인·변경·출처·의견 포함), 확정 당시 앱 확인 상태, 이름. 앱은 체크가 없으면 아무것도 확정하지 않음 |
| 안전장치 | 이름·끝까지 읽기·체크 없으면 거절, 값이 바뀌면 표식 무효, 불일치는 요청에 id 가 들어와도 확정하지 않음 |
**시험** `tools/test_review.py`(PASS): 화면 조회만으로는 확정 0, 표식·이름 없으면 거절, 체크 없으면 0, 일치 구역은 구역 체크로만, B·D 는 체크한 것만, 불일치는 거절, 기록에 사유·앱 확인 상태·이름, 값이 바뀌면 표식 무효, 불일치를 고치면 모든 필수 묶음이 이 화면으로 확정되어 게이트가 열림. 회귀 fast 19/19.
**한계**: 체크는 사용자의 선언이라 실제 대조 여부는 앱이 확인할 수 없다. 사유의 "무엇을 어떻게 대조했는지"는 공통 의견에 적어야 남는다. 앞으로 앱 확인 'C10 도구 읽기 이력'을 근거(출처, 독립 검증 아님)로 연결하면 근거 없음 항목이 줄 수 있다(미구현).
---
"""
k = t.index("## 7. 리스크·결정 사항")
t = t[:k] + sec + t[k:]
PN.write_text(t, encoding="utf-8")
PO.unlink()
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_검토요약전.md")
o = OPEN.read_text(encoding="utf-8")
row36 = ("| 36 | 입력 묶음 확정에 시간이 오래 걸리고 확인이 어려움(사유 '12'·'asf') — **[해소 2026-10-20]** 「검토 요약 확정」 구현: 앱이 바뀐 것·확인하지 못한 것·값의 출처를 한 화면에 모으고 사람이 도면과 대조한 묶음에 체크해 한 번에 확정, 사유는 앱이 자동 기록(`engine/review.py`, `/pier/<이름>/review`, `tools/test_review.py`). "
"'앱이 확정' 방식은 확정의 의미를 없애므로 채택하지 않음. **남은 것**: 체크는 사용자의 선언이라 실제 대조 여부를 앱이 확인하지 못함, 'C10 도구 읽기 이력'(출처 근거) 연결 미구현, 기존 묶음별 개별 확정 화면은 예외용으로 유지 | 2026-10-20 | 2026-10-20 | 해소 | "
"[ref: DOC/01_기획/기획_입력묶음확인확정개편_2026-10-20_v0.1.11.md §6.9] |")
o = o.rstrip("\n") + "\n" + row36 + "\n"
OPEN.write_text(o, encoding="utf-8")
print("ok", DST.name, PN.name)
@@ -0,0 +1,46 @@
"""2026-10-20: manual v0.2.15 -> v0.2.16 (도면에서 읽기 now includes the table values; 검토 요약: bulk check buttons + flags), planning v0.1.12, OPEN #34/#36 notes."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
DOC = R / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.15.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.16.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.15 | 2026-10-20 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.16 | 2026-10-20 | 심영표 | 「도면에서 읽기」가 받침 표·솔플레이트 표·재료표 값까지 한 화면에서 반영(템플릿 원본 값을 한 번에 도면 값으로), 「검토 요약 확정」에 모두 체크·해제 버튼과 주의 표시: 4 화면 구성, 5.3, 5.4 | 사용자 \"템플릿 원본 그대로도 모두 도면에서 읽기로 바꾸고, 미확정도 일괄 확정\" |\n" + s[j + 1:]
rep("- 도면 틀이 다르면(공통 문자 비율 70 % 미만, 예: 새재1교·옥성교) 읽지 않고 이유를 알려 줍니다.",
"- **표 값도 같은 화면에서 반영합니다(2026-10-20).** 읽은 값 아래 \"표 값(받침·솔플레이트·재료표)\" 구역에 받침 표(EL·h1·h2·A·B), 솔플레이트 표, 철근재료표에서 읽은 값 중 현재 값과 다른 줄이 함께 나옵니다. 이 화면 한 번으로 템플릿 원본 값이 도면 값으로 바뀌므로 [표 후보값]을 따로 돌릴 필요가 없습니다(같은 값을 [표 후보값]에서 골라 반영할 수도 있음). 받침마다 값이 다른 솔플레이트 길이(앞열 700, 뒤열 800 등)는 expected에 `{번호: 값}`으로 자동 기록합니다.\n"
"- 도면 틀이 다르면(공통 문자 비율 70 % 미만, 예: 새재1교·옥성교) 읽지 않고 이유를 알려 줍니다.")
rep("5. 목록을 **끝까지 내리면** 확정자 이름과 공통 의견(선택)을 넣을 수 있고, [선택한 묶음 확정]을 누르면 체크한 묶음이 한 번에 확정됩니다.",
"5. **[주의·근거 없음·결정 기록 모두 도면과 대조함으로 체크]** 버튼으로 한 번에 모두 체크할 수 있고([모두 해제]로 되돌림), 템플릿 원본 그대로이거나 설계자 확인 전 임시 등록이 들어 있는 묶음에는 **주의 표시**가 붙습니다. 주의 표시가 붙은 묶음이 있으면 모두 체크할 때 \"○개가 포함됩니다. 모두 도면과 대조했습니까?\" 확인창이 나옵니다. 주의 표시는 확정 기록의 사유(\"주의 표시: …\")에도 남습니다.\n"
"6. 목록을 **끝까지 내리면** 확정자 이름과 공통 의견(선택)을 넣을 수 있고, [선택한 묶음 확정]을 누르면 체크한 묶음이 한 번에 확정됩니다.")
DST.write_text(s, encoding="utf-8")
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
PO = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.11.md"
PN = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.12.md"
shutil.copy2(PO, R / "DOC" / "01_기획" / "backup" / PO.name)
t = PO.read_text(encoding="utf-8")
row = "| v0.1.11 | 2026-10-20 |"
i = t.index(row)
j = t.index("\n", i)
t = t[:j + 1] + "| v0.1.12 | 2026-10-20 | 심영표 | 6.9절에 후속 변경 추가(도면에서 읽기에 표 값 통합, 검토 요약 모두 체크·주의 표시) | 사용자 \"템플릿 원본 그대로도 모두 도면에서 읽기로 바꾸고, 미확정도 일괄 확정\" |\n" + t[j + 1:]
add = """**후속 변경 (같은 날)**: ① 「도면에서 읽기」가 표 값(받침·솔플레이트·재료표 후보)을 같은 화면에 \"표 값\" 구역으로 포함(`geomread.table_changes`), 반영은 `reader.apply_proposals` 와 같은 규칙, 받침마다 다른 솔플레이트 길이는 `{번호: 값}` 으로 정규화 — 새 교각에서 템플릿 원본 값이 한 화면으로 모두 도면 값이 됨(시험: 초기 P4 백업 + 읽기 + 표 행 = 검증된 P4 의 표 관련 필드, 알려진 예외는 앵커 값·`tie_offset` 상수·낡은 직경 기록). ② 「검토 요약 확정」에 [모두 체크]·[모두 해제] 버튼, 주의 표시(템플릿 원본 그대로·설계자 확인 전 임시 등록 포함)를 칩과 확정 사유에 기록, 주의 표시가 있으면 모두 체크 시 확인창. 불일치 묶음은 여전히 확정 불가, 이름·끝까지 읽기·체크 없이는 확정되지 않음. 앞서 제안한 '낮은 위험/높은 위험 두 단계' 대신 사용자 요청에 따라 일괄 체크 + 주의 표시로 구현(위험도별 분류는 미구현).
"""
k = t.index("**한계**: 체크는 사용자의 선언이라")
t = t[:k] + add + t[k:]
PN.write_text(t, encoding="utf-8")
PO.unlink()
print("ok")
@@ -0,0 +1,157 @@
"""2026-10-20 Step 5: split the working log of the Ungok P3/P4 geometry-reading work into one log per task and day (F8) and write the user inputs verbatim
(INPUT, taken from the session transcript with their time). Reads prompt/심영표/2026-10-19/Task_도면도형판독시험...md (the running notes) and the inputs file."""
import datetime as dt
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4]
P19 = ROOT / "prompt" / "심영표" / "2026-10-19"
P20 = ROOT / "prompt" / "심영표" / "2026-10-20"
OLD = P19 / "Task_도면도형직접판독시험_2026-10-19_v0.1.00.md"
rows = json.loads((ROOT / "Code" / "교각모델링앱" / "검증" / "_user_inputs.json").read_text(encoding="utf-8"))
def kst(ts):
return dt.datetime.fromisoformat(ts) + dt.timedelta(hours=9)
turns = []
for ts, t in rows:
if t.startswith(("<ide_selection>", "<ide_opened_file>", "[Request interrupted")):
continue
turns.append((kst(ts), t))
def inputs(lo, hi):
out, n = [], 0
for k, t in turns:
if lo <= k < hi:
n += 1
note = "[주석] 이미지 첨부는 텍스트로 옮기지 않음(캡처 화면)" if "[첨부: 이미지]" in t else ""
body = t.replace("[첨부: 이미지]", "[첨부: 이미지(캡처)]")
out.append(f"### [{n}] {k:%Y-%m-%d %H:%M} KST\n```\n[원문]\n{body}\n```\n" + (note + "\n" if note else ""))
return "".join(out), n
text = OLD.read_text(encoding="utf-8").split("\n")
# section boundaries of the running notes
idx = {l: i for i, l in enumerate(text) if l.startswith("## ")}
heads = [l for l in text if l.startswith("## ")]
def find(prefix):
return next(i for i, l in enumerate(text) if l.startswith(prefix))
i_p4_0820 = find("## 운곡교 P4 게이트 결과 검토 (10-20 08:38")
i_impr = find("## 앱 개선 #34 ②③④⑥ 구현")
part_a = text[:i_p4_0820] # 10-19: reading work P3 + first P4 pass
part_b = text[i_p4_0820:i_impr] # 10-20 morning: P4 gate review and decisions
part_c = text[i_impr:] # 10-20: app improvements, review confirmation, P2 / P4 finishing
MODEL = "Claude Sonnet 5.5 (claude-sonnet-5-5) — Task 중 모델 전환 없음"
def header(title, day, task_id, prev, inputs_md, process, decisions, output, n_turns, status, note):
return f"""# {task_id}
| 항목 | 내용 |
|------|------|
| 담당자 | 심영표 |
| 일자 | {day} |
| Task 식별자 | {task_id} |
| 선행 Task | {prev} |
| 경로·모드 | COMPLEX / GATED |
| MODEL | {MODEL} |
| EFFORT | 턴 {n_turns} / 도구호출 미집계(실행환경이 제공하지 않음) |
| STATUS | {status} |
| 비고 | {note} |
## INPUT
{inputs_md}
## PROCESS
{process}
## DECISIONS
{decisions}
## OUTPUT
{output}
"""
# ---------------- log A (10-19)
inp_a, n_a = inputs(dt.datetime(2026, 10, 19, 14, 0), dt.datetime(2026, 10, 20, 0, 0))
body_a = "\n".join(part_a[part_a.index("## PROCESS / OUTPUT"):]) if False else "\n".join(l for l in part_a[9:])
dec_a = """| 결정 지점 | 채택안 | 기각안 / 사유 |
| :--- | :--- | :--- |
| 값을 어디서 읽나 | 글자·표는 앱이 읽는 그대로, 선·원·폴리라인은 템플릿(P1)에서 재현 확인한 규칙으로 읽음 | 사람이 판독서를 보고 값마다 입력 — 새 교각마다 수작업 100곳 이상 |
| 읽은 값으로 expected 까지 채울 때의 독립성 | 같은 읽기에서 나온 값은 서로 같은 것이 독립 근거가 아님을 명시하고 재료표 개수·P1 재현·앵커 B 일치로 보완 | 읽은 값을 그대로 PASS 근거로 삼음 — 내부 일관성일 뿐 도면 보증이 아님 |
| G4 FAIL 승인 지시("G4를 승인하고 보고서 초안 생성") | 앱 규칙상 FAIL 승인 불가 → 원인(hooks_up 좌표, C13 재료표 불일치) 정리 후 계획된 FAIL 로 등록 | 승인 기록 파일을 직접 고쳐 승인 — 앱 검증 절차 우회 |
| 읽을 수 없는 값(철근 위치·코핑 외곽) | 선·원 좌표 직접 읽기(P1 재현 검증) + 재료표 산술 검산 | "읽을 수 없음"으로 두고 P1 값 유지 — 새 도면에서 틀린 모델 |
| 허위 PASS(P4 첫 게이트 G4) | P1 값끼리 일치한 PASS 로 판정, 새 값으로 재실행 | 승인 — 도면과 무관한 일치 |"""
out_a = """| 산출물 | 경로 | 비고 |
| :--- | :--- | :--- |
| 코핑 외곽 추출 | Code/교각모델링앱/tools/extract_coping_outline.py | 신규 |
| 기초 철근 규칙 | Code/교각모델링앱/tools/infer_footing_rebar.py (→ engine/footing_rule.py) | 신규 |
| 도면 값 비교 | Code/교각모델링앱/tools/verify_pier_geometry.py | 신규(55항목, P1·P3 55/55) |
| P3 반영 | tools/apply_p3_read_values.py, apply_p3_stage2.py | 77곳 + 표 105건 + 48곳, 백업 data/운곡교P3/backup |
| P3 게이트 | data/운곡교P3/runs/current | G1~G5 PASS 승인(578/585, 계획 7), 보고서 초안 2026-10-19 |
| P4 반영 | tools/apply_pier_read.py | 144곳 + 표 118건, 게이트 G1~G5 PASS 후 승인 |
| 사용설명서·기획서 | DOC/04_실행 v0.2.12 → v0.2.13, DOC/01_기획 v0.1.07 → v0.1.08 | |
| 개선 목록 | context-migration/OPEN_미해결목록.md #34·#35 | |"""
(P19 / "Task_도면도형직접판독시험_2026-10-19_v0.1.00.md").write_text(
header("도면도형직접판독시험", "2026-10-19", "Task_도면도형직접판독시험_2026-10-19_v0.1.00", "prompt/심영표/2026-10-19/Task_입력묶음개편D단계_2026-10-19_v0.1.00.md", inp_a, body_a, dec_a, out_a, n_a,
"PROCESS COMPLETE", "운곡교 P3 게이트 통과·승인, P4 반영 완료. 하루를 넘긴 후속 작업(P4 게이트 검토 이후)은 2026-10-20 로그로 분리(F8). 도구호출 횟수는 집계되지 않음."),
encoding="utf-8")
# ---------------- log B (10-20 morning)
P20.mkdir(exist_ok=True)
inp_b, n_b = inputs(dt.datetime(2026, 10, 20, 8, 0), dt.datetime(2026, 10, 20, 9, 13))
body_b = "\n".join(part_b)
dec_b = """| 결정 지점 | 채택안 | 기각안 / 사유 |
| :--- | :--- | :--- |
| P4 B11(표 30본 대 작도 32본) | 작도 채택 + 계획된 FAIL 등록(설계자 확인 대기) | 표 30본으로 값을 고침 — 그려진 32개 점과 모델이 어긋남 |
| P4 C9 좌측 스터럽 윗면 | 재료표 C9 b 범위(2.812·1.376)를 역산한 40447 로 둠 | P1 규칙(소단 − 100) 유지 — 재료표와 150 mm 어긋남 |
| P4 C13 재료표 L 28.966 | P3 처럼 계획된 FAIL 임시 등록, 설계자 확인 | 규칙값 29.346 으로 모델 변경 — 재료표 자체와 불일치 |
| P4 일괄 실행 오류(display) | pier_id 변경 시 hidden_ids 접두어도 갱신(스크립트 결함 수정) | 오류를 무시하고 중간 결과만 사용 |"""
out_b = """| 산출물 | 경로 | 비고 |
| :--- | :--- | :--- |
| P4 게이트 결과 | data/운곡교P4/runs/current (이후 사용자가 템플릿에서 다시 만듦, 승인된 복사본은 data/_휴지통/운곡교P4_20261020_112015) | G1~G5 PASS·승인 09:08 |
| P4 결정 반영 | data/운곡교P4/expected.json planned_fail, params.json stirrups.top_left | 백업 backup/before_b11_c9_c13_* |
| 읽기 보정 | engine/geomread.py(이후 통합), tools/apply_pier_read.py | hidden_ids 갱신 |
| 이월 설명 | (대화) B11·B12·C9·C13 설명 | OPEN #35 |"""
(P20 / "Task_운곡교P4게이트검토와결정_2026-10-20_v0.1.00.md").write_text(
header("운곡교P4게이트검토와결정", "2026-10-20", "Task_운곡교P4게이트검토와결정_2026-10-20_v0.1.00", "prompt/심영표/2026-10-19/Task_도면도형직접판독시험_2026-10-19_v0.1.00.md", inp_b, body_b, dec_b, out_b, n_b,
"PROCESS COMPLETE", "P4 G1~G5 계획 외 FAIL 0 으로 통과·승인. 이후 사용자가 P4 를 삭제하고 템플릿에서 다시 만들어 같은 흐름을 재시험함(다음 로그)."),
encoding="utf-8")
# ---------------- log C (10-20 improvements and P2 / P4 finishing)
inp_c, n_c = inputs(dt.datetime(2026, 10, 20, 9, 13), dt.datetime(2026, 10, 21, 0, 0))
body_c = "\n".join(part_c)
dec_c = """| 결정 지점 | 채택안 | 기각안 / 사유 |
| :--- | :--- | :--- |
| 확정 방식 개선 요청("앱이 일괄 확인, 사용자가 일괄 확정") | 앱이 검토를 준비(바뀐 것·확인하지 못한 것·출처·자동 사유), 사람이 한 화면에서 체크·한 번 확정(검토 요약 확정). 이후 요청에 따라 [모두 체크] 버튼과 주의 표시 | 앱이 확정 대행 — 확정의 의미(사람의 대조 기록)가 사라지고 앱이 대조하지 못한 묶음까지 확정됨 |
| 위험도 두 단계(낮은 위험 일괄/높은 위험 개별) | 사용자 요청에 따라 일괄 체크 + 주의 표시로 대체(위험도 분류는 미구현, 기획서 6.9에 기록) | 위험도 분류를 먼저 구현 — 사용자가 단순한 일괄 방식을 요청함 |
| 거짓 앵커(재료표 칸 하나) | 단독 표 칸 후보만 C등급 | 표 칸 앵커를 아예 제외 — B 오답은 3/29 로 큰 차이 없이 A 후보 2건 손실 |
| 읽기 도구 변경 비교 허용오차 | 글자·재료표 값은 정확 비교, CAD 측정값만 1.5 mm | 모든 값에 1.5 mm — 코핑 상단 철근 a 1 mm 차이와 개수 36/37 을 같다고 봄(P2·P4 에서 발견) |
| P2·P4 새 교각 흐름 | 읽기(도면 + 표) → 결정 가져오기 → 검토 요약 확정 → 게이트 | 개별 도구 실행·묶음별 개별 확정 — P2 는 약 20분에 끝남 |
| 시험 데이터 의존 | 게이트가 모두 승인된 복사본(`verified_pier`)과 휴지통의 옛 pier 로 시험 | 실제 사용 중인 pier 를 시험 기준으로 사용 — 삭제·재작성으로 시험이 깨짐 |"""
out_c = """| 산출물 | 경로 | 버전/비고 |
| :--- | :--- | :--- |
| 일관성 검사 C9 | Code/교각모델링앱/engine/consistency.py, tools/test_consistency.py | 규칙 103~106건 + hooks_up 규칙 |
| 필드 단위 원본 잔존 C3b | engine/verify.py(B3), web/store.py(origin_values.json), tools/make_origin_values.py | |
| 거짓 앵커 | engine/anchors.py(VERSION 2, TABLE_RULE=single), tools/test_anchors.py | |
| 도면에서 읽기 | engine/geomread.py, engine/footing_rule.py, web/templates/geomread.html, tools/test_geomread.py | 표 값 통합, 정확 비교 |
| 일괄 실행 중단 표시 | web/app.py(batch.json reason/started), web/templates/gates.html, tools/test_batch_notice.py | |
| 검토 요약 확정 | engine/review.py, web/templates/review.html, tools/test_review.py | 모두 체크·주의 표시 |
| 사용설명서 | DOC/04_실행/실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.16.md | v0.2.12→v0.2.16 |
| 기획서 | DOC/01_기획/기획_입력묶음확인확정개편_2026-10-20_v0.1.12.md | v0.1.07→v0.1.12 |
| 운곡교 P2 | data/운곡교P2 | G1~G5 PASS·승인(578/585, 계획 7), 보고서 초안 미생성 |
| 운곡교 P4(재작성) | data/운곡교P4 | 11:59 G1~G3 승인, G4 계획 외 C1·C4(코핑 상단 철근 a)를 수정, 재확정·재실행 대기 |
| 개선 목록 | context-migration/OPEN_미해결목록.md #34·#36 | |"""
(P20 / "Task_앱개선_검토요약확정과P2P4마무리_2026-10-20_v0.1.00.md").write_text(
header("앱개선_검토요약확정과P2P4마무리", "2026-10-20", "Task_앱개선_검토요약확정과P2P4마무리_2026-10-20_v0.1.00", "prompt/심영표/2026-10-20/Task_운곡교P4게이트검토와결정_2026-10-20_v0.1.00.md", inp_c, body_c, dec_c, out_c, n_c,
"PROCESS PARTIAL (운곡교P4 재확정·G4 재실행 대기, 앱 개선 ⑦~⑫ 미착수, P2 보고서 초안 미생성)",
"Rhino 회귀 4종(test_web_batch 외)은 ⑤ 변경 뒤 통과, 울산 3종은 미실행. 이 로그는 하루 안에 20턴을 넘어 다음 변경부터 새 로그로 분리 권장(F8). 도구호출 횟수는 집계되지 않음."),
encoding="utf-8")
print("written", n_a, n_b, n_c)
@@ -0,0 +1,38 @@
"""2026-10-20 Step 5: context-migration update (OPEN list: #35 / #36 notes + new #37 #38; technical note: reading-tool rules and traps)."""
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
NOTE = R / "context-migration" / "기술노트_DWG판독_Rhino모델링환경.md"
t = OPEN.read_text(encoding="utf-8")
a = "P3·P4 승인 기록은 일괄 실행 자동 승인이며 보고서 3~5장은 담당자 작성 항목 | 2026-10-20 | 2026-10-20 | 미해소 |"
assert t.count(a) == 1
t = t.replace(a, "P3·P4 승인 기록은 일괄 실행 자동 승인이며 보고서 3~5장은 담당자 작성 항목. **[갱신 2026-10-20]** 재료표 C13 L 28.966(규칙 29.346과 380 mm 차이)은 **P3·P4 도면에만** 있고 운곡교 P2 도면의 C13 은 29.345 로 규칙과 일치(P2 는 G4 에 계획된 FAIL 없음) → 설계자 문의는 'P3·P4 도면의 재료표 C13'으로 한정. P2 는 옛 P2(2026-10-12)에서 사용자가 확정한 B9(551 대 493)·B12(32 대 38) 계획된 FAIL 만 있음 | 2026-10-20 | 2026-10-20 | 미해소 |")
rows = [
("| 37 | **운곡교 P4(2026-10-20 11:22 템플릿에서 재작성본) 마무리 대기** — 검증·승인된 첫 P4(G1~G5 PASS 09:08)는 사용자가 11:20 삭제해 `data/_휴지통/운곡교P4_20261020_112015` 에 있음(승인·결과 보존). 재작성본: 읽기(도면 145+1+4건·표 130건)·결정(좌측 스터럽 윗면 40447, tie_offset 22.5, 계획된 FAIL B11·C13, 앵커 rear_first_from_tip 1198)까지 반영, 11:59 게이트에서 G1~G3 승인·G4 계획 외 C1·C4(원래 길이 2 mm = 코핑 상단 철근 a 가 템플릿 값 11855) → 읽기 도구 비교 허용오차 수정(글자·재료표 값은 정확 비교) 후 `coping_rebar.tiers.a` 를 11856/11881/11905 로 반영. **남은 것**: `params:coping_rebar` 재확정(검토 요약) → 일괄 승인 재실행 → G4·G5 승인 → 보고서 초안 | 2026-10-20 | 2026-10-20 | 미해소 | [ref: Code/교각모델링앱/data/운곡교P4, prompt/심영표/2026-10-20/Task_앱개선_검토요약확정과P2P4마무리_2026-10-20_v0.1.00.md] |"),
("| 38 | 운곡교 P2(2026-10-20 10:53 템플릿에서 새로 만듦) — 읽기(126+4+6건)·표 94건·앵커 1건·planned_fail(B9·B12) 반영 후 G1~G5 PASS·승인(11:13, 578/585, 계획 7, 계획 외 0). 새 도구로 새 교각이 약 20분에 끝남. **남은 것**: 보고서 초안 미생성, 확정 기록의 공통 의견 칸이 비어 있음(앱이 자동 사유는 남김), 보고서 3~5장 담당자 작성 | 2026-10-20 | 2026-10-20 | 미해소 | [ref: Code/교각모델링앱/data/운곡교P2] |"),
]
t = t.rstrip("\n") + "\n" + "\n".join(rows) + "\n"
OPEN.write_text(t, encoding="utf-8")
n = NOTE.read_text(encoding="utf-8")
n = n.replace("최근확인일: 2026-10-07", "최근확인일: 2026-10-20", 1)
n = n.rstrip("\n") + """
## 도면 읽기 도구(`engine/geomread.py`)의 규칙과 함정 (2026-10-20)
- **틀**: 운곡교 P1 도면 틀(P1~P4)만. 첫 도면(D1) 좌표가 P1 도면과 같음: 코핑 평면 중심 (12162.4, 26412.3), 정면도 중심 x 12159.4, 단면도 기초 중심 x 30133.6, 기초 하면 y 8115.6 = 기초 EL, 코핑 EL = y + (최고 단 옆 'EL.' 글자 − 그 단 y). 틀 일치율은 템플릿 앵커의 랜드마크 글자 비율(70 % 미만이면 거절). 새재1교·옥성교는 0 %.
- **재현 검증이 먼저**: 읽기 규칙은 완성된 교각(P1)에서 같은 값이 나오는지 확인한 뒤에만 믿음 — `tools/test_geomread.py`(P1·P3·P2·P4, 초기 P4 백업 144~150건 = 검증값).
- **비교 허용오차는 값의 출처에 따라**: 글자·재료표에서 온 값(기초·말뚝·기둥·철근 개수·길이·반경·재료표 셀·코핑 상단 철근 a)은 **정확 비교**, CAD 선·원·폴리라인에서 잰 값만 1.5 mm(각도 0.05). **동일 방식 재시도 금지**: 모든 숫자에 1.5 mm 를 주면 개수 36 대 37, 재료표 17.247 대 17.377, 상단 철근 a 11855 대 11856 이 '같다'로 가려진다(P2 첫 반영에서 재료표 사본·후프 개수가 빠졌고 P4 G4 C1·C4 2 mm 실패의 원인).
- **C14 점은 템플릿 점 좌표에 맞춰 찾지 않는다**: x 오프셋이 교각마다 다름(P1·P3·P4: 13/913…, P2 상단 두 줄: 0/900…). 도면의 철근 원(R27, CS-RBAR-MAJR)을 읽고, 템플릿 C14 높이(+ 높이 이동) 5 mm 안의 원만 취함(기둥 후프 점은 x≈±902 이고 한 점은 C14 높이에서 23 mm 떨어짐, P2 의 C14 x=900 과는 2 mm). 오른쪽 반만 그려져 있어 좌우 대칭, 가운데(|x|≤20)는 한 번, 템플릿에서 왼쪽 짝이 없던 점(P1 x 2913 근처)은 오른쪽 전용.
- **재료표의 직경 칸 '"' 는 표 순서상 바로 윗 줄**이다(마크 번호 순서가 아님) — P1~P6 을 번호 순으로 이어받으면 틀림.
- **재료표 B9 개수는 격자(체스판)로 계산**: 38×29/2 = 551 = 모델이 놓는 개수, 재료표 493 과 다르면 계획된 FAIL 로 등록(P2·옛 P2). P3·P4 는 격자와 재료표가 같음.
- **작도 한쪽만 그려진 값(C14, 기초 중간 층 B11·B12, 좌측 스터럽 윗면)은 좌우 대칭 가정** — 재료표 개수와 어긋나면(P4 B11 30 대 32) 작도를 채택하고 계획된 FAIL 등록 후 설계자 확인.
- **`hooks_up` 처럼 점 좌표로 대상을 지정하는 목록은 점이 바뀌면 조용히 깨진다**(P3 G4 코핑 밖 점 56개) — 앱 확인 C9 가 `hooks_up ⊂ c14.points` 를 검사함.
- **교각 이름을 바꾸면 모델 객체 이름 접두어(`P1-FT`)가 바뀐다** — `params.display.hidden_ids` 도 같이 바꿔야 함(안 바꾸면 일괄 실행이 마지막 단계에서 오류로 중단).
- **고정(F) 항목(pier_id·display)을 도면에서 읽기로 바꾸면 앱 확인 C7 이 사유 기록을 요구** — 반영 화면이 `edit_log.json` 에 자동 기록하도록 수정함(이전 서버 프로세스는 재시작해야 반영).
- **실제 사용 데이터에 의존하는 시험은 깨진다**: 사용자가 `운곡교p2`·`운곡교P4` 를 삭제·재작성하면서 시험 6종이 깨짐 → 시험은 G5 까지 승인된 복사본(`testlib.verified_pier`)·휴지통 복사본(`testlib.pier_dir`)을 쓴다. Windows 의 글로빙은 대소문자를 구분하지 않아 `운곡교p2_*` 가 `운곡교P2_*` 를 집는다 — 이름 접두어를 대소문자 구분으로 다시 거른다.
- **Flask 는 디버그 꺼짐이면 템플릿을 캐시한다** — 화면·라우트를 고친 뒤에는 사용자가 앱을 껐다 켜야 새 메뉴·버튼이 보인다(2026-10-20 '도면에서 읽기가 없다'의 원인).
"""
NOTE.write_text(n, encoding="utf-8")
print("ok")
@@ -0,0 +1,23 @@
"""Insert the S02 view (s02_mockup_view.js) into the design mockup DOC/01_기획/웹앱디자인시안/시안/index.html (2026-10-15).
The original is copied to ../backup first by the caller; the patch is applied once (refuses if already applied)."""
from pathlib import Path
HERE = Path(__file__).parent
P = Path(__file__).resolve().parents[4] / "DOC" / "01_기획" / "웹앱디자인시안" / "시안" / "index.html"
s = P.read_text(encoding="utf-8")
assert "viewNew" not in s, "already applied"
js = (HERE / "s02_mockup_view.js").read_text(encoding="utf-8")
pairs = [
(" /* ---------- router ---------- */", js + "\n /* ---------- router ---------- */"),
(" else if (h.indexOf('#/candidates') === 0) viewCandidates();", " else if (h.indexOf('#/candidates') === 0) viewCandidates();\n else if (h.indexOf('#/new') === 0) viewNew();"),
(" $('#nav-cand').style.fontWeight = h.indexOf('#/candidates') === 0 ? '700' : '500';",
" $('#nav-cand').style.fontWeight = h.indexOf('#/candidates') === 0 ? '700' : '500';\n $('#nav-new').style.fontWeight = h.indexOf('#/new') === 0 ? '700' : '500';"),
(' <div class="sub"><a href="#/candidates" id="nav-cand">표 후보값</a></div>',
' <div class="sub"><a href="#/candidates" id="nav-cand">표 후보값</a></div>\n <div class="sub"><a href="#/new" id="nav-new">새 교각 (S02)</a></div>'),
("<title>PierProof 시안 — 입력 묶음 확인·확정</title>", "<title>PierProof 시안 — 입력 묶음·새 교각 판별</title>"),
]
for a, b in pairs:
assert s.count(a) == 1, a
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched", P)
@@ -0,0 +1,28 @@
// Probe for the S02 mockup (run in headless Edge via --dump-dom on a copy that has this script appended): drives the view and writes the results into <pre id="res">.
window.addEventListener('load', function () {
var out = [];
function rec(k, v) { out.push(k + '=' + v); }
function strip() { return document.querySelector('.strip[role=status]').textContent; }
try {
location.hash = '#/new';
setTimeout(function () {
try {
rec('upload_button', !!document.getElementById('up'));
document.getElementById('up').click();
rec('rank1', document.querySelector('tbody tr td:nth-child(2)').textContent);
rec('verdict_ok', strip().indexOf('이 템플릿으로 만들 수 있음') >= 0);
var tp = document.getElementById('tp'); tp.value = '옥성교P1형'; tp.dispatchEvent(new Event('change'));
rec('verdict_bad', strip().indexOf('같은 템플릿으로 만들 수 없음') >= 0 && strip().indexOf('코핑 하면 형상') >= 0);
rec('button', document.getElementById('mk').textContent);
document.getElementById('mk').click();
rec('name_error', (document.querySelector('.field.bad .err') || {}).textContent);
var nm = document.getElementById('nm'); nm.value = 'TEST'; nm.dispatchEvent(new Event('input'));
document.getElementById('mk').click();
rec('toast', document.getElementById('toast').textContent);
var rs = document.getElementById('rs'); rs.value = '아니오'; rs.dispatchEvent(new Event('change'));
rec('rebar_bad', strip().indexOf('철근 형상 종류') >= 0);
} catch (e) { rec('ERR', e.message); }
var pre = document.createElement('pre'); pre.id = 'res'; pre.textContent = out.join(' | '); document.body.appendChild(pre);
}, 200);
} catch (e) { rec('ERR0', e.message); }
});
@@ -0,0 +1,78 @@
/* ---------- S02 new pier fit (sample: Ungok P1 drawings uploaded) ---------- */
var QS = [['foundation', '기초 형식(말뚝 유무)'], ['soffit', '코핑 하면 형상'], ['bearings', '받침 개수']];
var OPTS = { foundation: ['기초판+말뚝', '기초판만', '말뚝만'], soffit: ['곡선', '직선 경사', '나팔형'], bearings: ['2', '4', '10'] };
var TPLS = [
{ n: '운곡교P1형', p: { foundation: '기초판+말뚝', soffit: '곡선', bearings: '10' } },
{ n: '옥성교P1형', p: { foundation: '기초판+말뚝', soffit: '직선 경사', bearings: '10' } },
{ n: '새재1교P1형', p: { foundation: '기초판만', soffit: '직선 경사', bearings: '10' } },
{ n: 'Ramp-A교P1형', p: { foundation: '말뚝만', soffit: '나팔형', bearings: '2' } },
{ n: '산외교울산P1형', p: { foundation: '기초판만', soffit: '직선 경사', bearings: '4' } }
];
var EST = {
foundation: ['기초판+말뚝', "'말뚝' 문자 7개: D1 CS-TEXT (복합말뚝)", '보통'],
soffit: ['곡선', "R 12000 mm: D1 CS-DIMT (8257, 16513) 'R=12.000'", '보통'],
bearings: ['10', 'ELEVATION TABLE 행 10개: D2 (26179, 9342)', '보통']
};
var N = { up: false, ans: null, tpl: '운곡교P1형', name: '', rs: '미확인', rm: '미확인', skew: '', brot: '', frot: '', err: '' };
function nMatch(t) { return QS.filter(function (q) { return t.p[q[0]] === EST[q[0]][0]; }).length; }
function nBad() {
var t = TPLS.filter(function (x) { return x.n === N.tpl; })[0], bad = [];
QS.forEach(function (q) { if (t.p[q[0]] !== N.ans[q[0]]) bad.push(q[1]); });
if (N.rs === '아니오') bad.push('철근 형상 종류');
if (N.rm === '아니오') bad.push('철근 생성 방식');
return bad;
}
function viewNew() {
setWhere('S02 · 새 교각 적합성 판별');
var html = '<div class="head"><span class="eyebrow">새 교각</span><h1>도면으로 템플릿 추천</h1><p class="lead">도면을 먼저 올리면 앱이 가장 비슷한 템플릿을 추천한다. 추천은 제안이고, 6문항은 사람이 확인한다.</p></div>';
if (!N.up) {
html += '<section class="card" style="max-width:38em"><div class="field"><label for="dw">교각 도면(DWG, 여러 장)</label><input id="dw" type="file" accept=".dwg" multiple><span class="help">시안에서는 운곡교 P1 도면 3장을 올린 것으로 본다</span></div><div class="row"><button class="btn primary" type="button" id="up">도면 읽고 추천 받기</button></div></section>';
main.innerHTML = html;
$('#up').onclick = function () { N.up = true; N.ans = {}; QS.forEach(function (q) { N.ans[q[0]] = EST[q[0]][0]; }); viewNew(); };
return;
}
var ranked = TPLS.slice().sort(function (a, b) { return nMatch(b) - nMatch(a); });
html += '<section class="strip"><p class="concl">도면 3장, 문자 2,366개를 읽었다 — 1위 ' + ranked[0].n + ' (3 / 3 일치)</p></section>';
html += '<h2>1 추천 순위</h2><div class="tw"><table class="t"><caption>추정한 3문항과 일치한 수</caption><thead><tr><th scope="col">순위</th><th scope="col">템플릿</th><th class="num" scope="col">일치</th>' + QS.map(function (q) { return '<th scope="col">' + q[1] + '</th>'; }).join('') + '</tr></thead><tbody>';
ranked.forEach(function (t, i) {
html += '<tr><td class="num">' + (i + 1) + '</td><td><b>' + t.n + '</b></td><td class="num">' + nMatch(t) + ' / 3</td>' + QS.map(function (q) {
var ok = t.p[q[0]] === EST[q[0]][0];
return '<td>' + esc(t.p[q[0]]) + ' ' + (ok ? badge('good', '●', '일치') : badge('bad', '■', '다름')) + '</td>';
}).join('') + '</tr>';
});
html += '</tbody></table></div>';
html += '<h2>2 추정 근거</h2><div class="tw"><table class="t"><thead><tr><th scope="col">문항</th><th scope="col">추정</th><th scope="col">근거(도면 위치)</th><th scope="col">신뢰</th></tr></thead><tbody>';
QS.forEach(function (q) { var e = EST[q[0]]; html += '<tr><td>' + q[1] + '</td><td><b>' + e[0] + '</b></td><td><span class="chip">' + esc(e[1]) + '</span></td><td>' + e[2] + '</td></tr>'; });
html += '<tr><td>기둥 단면·철근 형상·철근 생성 방식</td><td>' + badge('dash', '◇', '판단 불가') + '</td><td>도면 문자로 판별할 근거가 없다. 사람이 답한다</td><td>-</td></tr></tbody></table></div>';
html += '<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>코핑 하면 형상은 정면도의 큰 R 문자(10 m 이상)만 본다. 평면 모서리의 작은 R은 제외한다. 평면의 사각·직각과 각도는 문항이 아니라 아래 값으로 입력한다.</div></div>';
html += '<h2>3 6문항 확인 후 교각 만들기</h2><div style="max-width:38em;display:flex;flex-direction:column;gap:var(--prism-space-200)">';
QS.forEach(function (q) {
html += '<div class="field"><label for="a_' + q[0] + '">' + q[1] + '</label><select id="a_' + q[0] + '" data-a="' + q[0] + '">' + OPTS[q[0]].map(function (o) { return '<option' + (N.ans[q[0]] === o ? ' selected' : '') + '>' + o + '</option>'; }).join('') + '</select></div>';
});
[['rs', '철근 형상 종류가 템플릿의 재료표 형식 열과 같은가'], ['rm', '철근 생성 방식(규칙 값 / 철근별 중심선)이 같은가']].forEach(function (q) {
html += '<div class="field"><label for="' + q[0] + '">' + q[1] + '</label><select id="' + q[0] + '" data-r="' + q[0] + '">' + ['미확인', '예', '아니오'].map(function (o) { return '<option' + (N[q[0]] === o ? ' selected' : '') + '>' + o + '</option>'; }).join('') + '</select></div>';
});
html += '<fieldset class="card sunken"><legend class="lbl">각도 값 확인 <span class="muted">템플릿을 가르지 않는 값. 비워도 된다</span></legend><div class="grid3">' +
[['skew', '코핑 평면 skew (°)'], ['brot', '받침 회전 (°)'], ['frot', '기초 회전 (°)']].map(function (q) { return '<div class="field"><label for="g_' + q[0] + '">' + q[1] + '</label><input id="g_' + q[0] + '" type="text" inputmode="decimal" data-g="' + q[0] + '" value="' + esc(N[q[0]]) + '"></div>'; }).join('') + '</div></fieldset>';
var bad = nBad();
html += '<section class="strip" role="status" aria-live="polite"><h3 style="margin:0 0 .25rem">결과 판정</h3><p class="concl">' + (bad.length ? badge('bad', '■', '같은 템플릿으로 만들 수 없음') + ' 다른 문항: ' + esc(bad.join(', ')) : badge('good', '●', '이 템플릿으로 만들 수 있음')) + '</p>' +
(bad.length ? '<p class="muted" style="margin:.25rem 0 0">가장 가까운 템플릿에 생성기·검사기를 추가해야 한다. 그래도 진행하면 교각 이름 옆과 모든 화면 상단에 형식 불일치 경고가 계속 표시된다(닫을 수 없다).</p>' : '') + '</section>';
html += '<div class="field' + (N.err ? ' bad' : '') + '"><label for="nm">교각 이름<span class="req">필수</span></label><input id="nm" type="text" value="' + esc(N.name) + '" style="max-width:20rem">' + (N.err ? '<span class="err"><span aria-hidden="true">■</span>' + esc(N.err) + '</span>' : '') + '</div>';
html += '<div class="field"><label for="tp">템플릿</label><select id="tp" style="max-width:20rem">' + ranked.map(function (t, i) { return '<option value="' + t.n + '"' + (N.tpl === t.n ? ' selected' : '') + '>' + (i + 1) + '위 ' + t.n + '</option>'; }).join('') + '</select></div>';
html += '<div class="row"><button class="btn primary" type="button" id="mk">' + (bad.length ? '그래도 만들기' : '교각 만들기') + '</button></div></div>';
main.innerHTML = html;
var y = window.scrollY;
main.querySelectorAll('[data-a]').forEach(function (e) { e.onchange = function () { N.ans[e.getAttribute('data-a')] = e.value; viewNew(); window.scrollTo(0, y); $('#' + e.id).focus(); }; });
main.querySelectorAll('[data-r]').forEach(function (e) { e.onchange = function () { N[e.getAttribute('data-r')] = e.value; viewNew(); window.scrollTo(0, y); $('#' + e.id).focus(); }; });
main.querySelectorAll('[data-g]').forEach(function (e) { e.oninput = function () { N[e.getAttribute('data-g')] = e.value; }; });
$('#nm').oninput = function () { N.name = this.value; };
$('#tp').onchange = function () { N.tpl = this.value; viewNew(); window.scrollTo(0, y); $('#tp').focus(); };
$('#mk').onclick = function () {
N.name = $('#nm').value.trim();
if (!N.name) { N.err = '교각 이름이 비어 있다. 폴더를 만들 이름이 필요하다. 이름을 입력한다'; viewNew(); $('#nm').focus(); return; }
N.err = '';
toast("교각 '" + N.name + "'을(를) 만들었다. 올린 도면이 교각 폴더로 옮겨졌고 6문항 답과 각도 값이 저장되었다." + (bad.length ? ' 형식 불일치 경고가 표시된다.' : ''));
viewNew();
};
}