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

+909
View File
@@ -0,0 +1,909 @@
"""T-pier modelling app (semi-automatic MVP): local Flask UI over the engine.
Flow per pier: upload drawings -> search texts / review table candidates -> edit and confirm sections ->
run gates G1..G5 one by one (each needs its sections confirmed and the previous gate approved) ->
approve / reject each gate -> download the .3dm.
Local only: binds 127.0.0.1 and has no authentication (do not expose it on a network).
usage: python web/app.py then open http://127.0.0.1:5000
"""
import json
import os
import shutil
import sys
import threading
import time
import traceback
import uuid
from pathlib import Path
from flask import Flask, abort, flash, redirect, render_template, request, send_file, url_for
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "web"))
from engine import anchors, geomread, recommend, reader, render, report_files, review as reviewlib, variability, verify # noqa: E402
from engine.gates import run_batch_session, run_gate # noqa: E402
from engine.pipeline import GATES, gate_ids # noqa: E402
import store # noqa: E402
app = Flask(__name__)
app.secret_key = "local-only" # session flash messages only; the app is bound to 127.0.0.1
RHINO_LOCK = threading.Lock() # one Rhino job at a time
ACTIVE = {} # job id -> thread of this server process
BATCH = {} # pier name -> batch thread of this server process
AUTO_NOTE = "일괄 실행 자동 승인"
GATE_NAMES = {g: n for g, n, _ in GATES}
def pier_or_404(name):
try:
return store.Pier(name)
except (FileNotFoundError, ValueError):
abort(404)
@app.route("/")
def index():
return render_template("index.html", piers=[store.Pier(n) for n in store.piers()], types=store.PIER_TYPES + ["기타"],
templates=store.templates(), tpl=store.TPL)
@app.post("/pier/new")
def pier_new():
try:
p = store.create_pier(request.form["name"], request.form["type"], request.form["template"])
except (ValueError, FileNotFoundError) as e:
flash(str(e), "fail")
return redirect(url_for("index"))
flash(f"교각 '{p.name}'을(를) 만들었습니다. 템플릿 값이 들어 있으니 모든 묶음을 확인·수정한 뒤 확정하세요.", "ok")
return redirect(url_for("pier", name=p.name))
STAGE = "_추천" # data/_추천/<id>: drawings uploaded before the pier exists (S02)
def stage_dir(sid):
if not sid.isalnum():
abort(404)
return store.DATA / STAGE / sid
@app.post("/recommend")
def recommend_upload():
"""S02: upload drawings first, estimate the fit answers from the texts, rank the protected templates."""
files = [f for f in request.files.getlist("dwg") if f.filename]
if not files or any(not f.filename.lower().endswith(".dwg") for f in files):
flash("DWG 파일(.dwg)만 올릴 수 있습니다.", "fail")
return redirect(url_for("index"))
sid = uuid.uuid4().hex[:12]
d = stage_dir(sid)
(d / "dwg").mkdir(parents=True)
paths = []
for f in sorted(files, key=lambda f: f.filename):
dst = d / "dwg" / store.safe_name(Path(f.filename).name)
f.save(dst)
paths.append(dst)
try:
audit = reader.convert(paths, d / "dxf")
except Exception as e:
flash(f"DWG 변환 실패: {e}", "fail")
return redirect(url_for("index"))
labels = [f"D{i}" for i in range(1, len(audit) + 1)]
for a, lab in zip(audit, labels):
a["label"] = lab
store.wjson(d / "audit.json", audit)
texts = reader.build_index([d / "dxf" / a["dxf"] for a in audit], labels, d / "texts.json")
return recommend_show(sid, texts)
def recommend_show(sid, texts):
rows = next((t["rows"] for t in reader.table_candidates(texts) if t["key"] == "elevation"), [])
est = recommend.estimate(texts, rows)
tpls = store.templates()
profiles = {t["name"]: recommend.profile(store.rjson(store.template_dir(t["name"]) / "params.json")) for t in tpls}
return render_template("recommend.html", profiles=profiles, sid=sid, est=est, questions=recommend.QUESTIONS, rank=recommend.rank(est, profiles),
tpl=store.TPL, ntexts=len(texts), nfiles=len(store.rjson(stage_dir(sid) / "audit.json", [])))
@app.post("/recommend/<sid>/create")
def recommend_create(sid):
"""Create the pier from the chosen template and move the staged drawings into it; the answers are kept in pier.json."""
d = stage_dir(sid)
if not (d / "texts.json").exists():
abort(404)
answers = {q: request.form.get(f"ans_{q}", "").strip() for q, _ in recommend.QUESTIONS}
answers.update({k: request.form.get(f"ans_{k}", "").strip() for k in ("rebar_shape", "rebar_method")})
try:
p = store.create_pier(request.form["name"], request.form["type"], request.form["template"])
except (ValueError, FileNotFoundError) as e:
flash(str(e), "fail")
return redirect(url_for("index"))
p.drawings.mkdir(parents=True, exist_ok=True)
for item in d.iterdir():
shutil.move(str(item), str(p.drawings / item.name))
d.rmdir()
answers.update({k: request.form.get(f"ans_{k}", "").strip() for k in ("skew_deg", "bearing_rot_deg", "footing_rot_deg")})
tname = request.form["template"]
bad = (recommend.mismatches(answers, recommend.profile(store.rjson(store.template_dir(tname[len(store.TPL):]) / "params.json")))
if tname.startswith(store.TPL) else [])
store.wjson(p.dir / "pier.json", {**p.info, "fit_answers": answers, "fit_mismatch": bad})
flash(f"교각 '{p.name}'을(를) 만들었습니다. 도면 {len(p.texts())}개 문자 색인을 옮겼습니다. 모든 묶음을 확인·수정한 뒤 확정하세요.", "ok")
return redirect(url_for("pier", name=p.name))
@app.post("/pier/<name>/delete")
def pier_delete(name):
"""Delete = move to data/_휴지통 (recoverable). The typed name must match; refused while a job runs."""
p = pier_or_404(name)
if request.form.get("confirm_name", "").strip() != p.name:
flash(f"삭제 안 됨: 확인란에 교각 이름 '{p.name}'을(를) 정확히 입력하세요.", "fail")
elif anything_running(p):
flash(f"삭제 안 됨: '{p.name}'은(는) 게이트를 실행 중입니다. 끝난 뒤 다시 시도하세요.", "fail")
else:
try:
dst = store.delete_pier(p.name)
flash(f"'{p.name}'을(를) 삭제했습니다(휴지통으로 이동: {dst.relative_to(store.DATA)}). "
f"되돌리려면 이 폴더를 data 폴더로 옮기고 이름을 원래대로 바꾸세요.", "ok")
except OSError as e:
flash(f"삭제 실패: {e} (파일이 열려 있으면 닫고 다시 시도하세요)", "fail")
return redirect(url_for("index"))
@app.route("/pier/<name>")
def pier(name):
p = pier_or_404(name)
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"])))
rank = {"b": 0, "n": 1, "w": 2, "g": 3, "x": 4, "?": 1} # worst app check first, required bundles before the others
secs.sort(key=lambda s: (0 if s["required"] else 1, rank.get(s["chk"]["st"], 1), s["file"], s["key"]))
origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 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
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))
gates = [dict(id=g, name=GATE_NAMES[g], **p.gate_status(g)) for g in p.scope()]
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, cnt=cnt, human=human, nreq=len(req),
nmatch=nmatch, ST=verify.ST_NAME, chain_bad=chain_bad,
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()],
batch=batch_state(p),
audit=store.rjson(p.drawings / "audit.json", []), ntexts=len(p.texts()))
# ---------------- drawings
@app.post("/pier/<name>/upload")
def upload(name):
p = pier_or_404(name)
files = [f for f in request.files.getlist("dwg") if f.filename]
if not files or any(not f.filename.lower().endswith(".dwg") for f in files):
flash("DWG 파일(.dwg)만 올릴 수 있습니다.", "fail")
return redirect(url_for("pier", name=name))
src = p.drawings / "dwg"
src.mkdir(parents=True, exist_ok=True)
paths = []
for f in sorted(files, key=lambda f: f.filename):
dst = src / store.safe_name(Path(f.filename).name)
f.save(dst)
paths.append(dst)
try:
audit = reader.convert(paths, p.drawings / "dxf")
except Exception as e:
flash(f"DWG 변환 실패: {e}", "fail")
return redirect(url_for("pier", name=name))
labels = [f"D{i}" for i in range(1, len(audit) + 1)]
for a, lab in zip(audit, labels):
a["label"] = lab
store.wjson(p.drawings / "audit.json", audit)
texts = reader.build_index([p.drawings / "dxf" / a["dxf"] for a in audit], labels, p.drawings / "texts.json")
flash(f"도면 {len(audit)}장 변환, 문자 {len(texts)}개 색인", "ok")
return redirect(url_for("pier", name=name))
@app.route("/pier/<name>/search")
def search(name):
p = pier_or_404(name)
q, rx = request.args.get("q", ""), bool(request.args.get("regex"))
hits, err = [], None
if q:
try:
hits = reader.search(p.texts(), q, regex=rx)
except Exception as e:
err = f"검색식 오류: {e}"
audit = store.rjson(p.drawings / "audit.json", [])
hd, hdwg, ent = request.args.get("h", "").strip(), request.args.get("dwg", ""), None
if hd: # handle lookup of a drawing object (OPEN #19 A6)
a = next((a for a in audit if a["label"] == hdwg), None)
ent = reader.entity_info(p.drawings / "dxf" / a["dxf"], hd) if a else None
if ent is None:
err = f"{hdwg} 도면에 핸들 '{hd}' 객체가 없습니다."
return render_template("search.html", p=p, q=q, regex=rx, hits=hits, err=err, audit=audit,
h=hd, hdwg=hdwg, ent=ent, dumps=lambda v: json.dumps(v, ensure_ascii=False))
@app.route("/pier/<name>/candidates")
def candidates(name):
p = pier_or_404(name)
texts = p.texts()
tables = reader.table_candidates(texts) if texts else []
rows = reader.proposals(reader.field_candidates(tables), p.load("params"), p.load("expected")) if tables else []
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"}
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:
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"], "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})
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")
@app.post("/pier/<name>/candidates/apply")
def candidates_apply(name):
p = pier_or_404(name)
texts = p.texts()
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), p.load("params"), p.load("expected"))
chosen = set(request.form.getlist("row"))
sel = [r for i, r in enumerate(rows) if str(i) in chosen and not r["same"]]
P, E = p.load("params"), p.load("expected")
reader.apply_proposals(sel, P, E)
p.save("params", P)
p.save("expected", E)
flash(f"후보값 {len(sel)}건을 반영했습니다. 바뀐 묶음은 다시 확정해야 합니다.", "ok")
return redirect(url_for("candidates", name=name))
# ---------------- 도면에서 읽기 (improvement #34-1, 2026-10-20): engine/geomread.py
def _geomread(p):
"""-> (state, P', E', changes, notes, message). state: ok / no_texts / no_dxf / frame / error."""
texts = p.texts()
if not texts:
return "no_texts", None, None, [], [], "도면 문자 색인이 없습니다. 도면을 먼저 올리세요."
ref_dir = store.template_dir("운곡교P1형")
ref = store.rjson(ref_dir / "params.json")
if not ref:
return "error", None, None, [], [], "기준 템플릿(운곡교P1형)을 찾지 못했습니다."
cov = geomread.frame_ok(texts, store.rjson(ref_dir / "anchors.json"), anchors)
if cov is not None and cov < geomread.FRAME_MIN_COVERAGE:
return "frame", None, None, [], [], f"도면 틀 일치율이 {cov * 100:.0f} %입니다(70 % 미만). 운곡교 P1 도면 틀(P1~P4)이 아니라서 읽지 않습니다."
try:
dxf = geomread.dxf_of(p.dir, store.rjson(p.drawings / "audit.json", []))
P, E, changes, notes = geomread.read(p.load("params"), p.load("expected"), ref, texts, dxf, p.name)
except geomread.FrameError as e:
return "frame", None, None, [], [], str(e)
except Exception as e: # a reader must never break the page
return "error", None, None, [], [], f"읽는 중 오류: {type(e).__name__}: {e}"
own = [a.get("source_sha256") for a in store.rjson(p.drawings / "audit.json", []) if a.get("source_sha256")]
changes = changes + geomread.table_changes(P, E, texts) + geomread.extra_changes(P, E, texts, store.rjson(p.dir / "anchors.json"), own) # tables too: one screen brings every template value in line with the drawing; + anchor dimensions and planned FAIL proposals
return "ok", P, E, changes, notes, f"도면 틀 일치율 {(cov or 0) * 100:.0f} %" if cov is not None else ""
@app.route("/pier/<name>/geomread")
def geomread_page(name):
p = pier_or_404(name)
state, _P, _E, changes, notes, msg = _geomread(p)
groups = {}
for i, c in enumerate(changes):
groups.setdefault(c["group"], []).append((i, c))
brief = lambda v: v if isinstance(v, str) else json.dumps(v, ensure_ascii=False) # noqa: E731
return render_template("geomread.html", p=p, state=state, msg=msg, notes=notes, groups=groups, n=len(changes), brief=brief,
log=store.rjson(p.dir / "read_log.json", [])[-3:], running=anything_running(p))
@app.post("/pier/<name>/geomread/apply")
def geomread_apply(name):
"""Write the selected readings into params / expected. The page is recomputed here (indices refer to the same fresh list); a backup is taken first;
the changed bundles lose their confirmation by themselves (the hash changes) and the app check runs again."""
p = pier_or_404(name)
if anything_running(p):
flash("게이트 실행 중에는 값을 바꿀 수 없습니다.", "fail")
return redirect(url_for("geomread_page", name=name))
state, _P, _E, changes, notes, msg = _geomread(p)
if state != "ok":
flash(msg, "fail")
return redirect(url_for("geomread_page", name=name))
chosen = sorted({int(i) for i in request.form.getlist("row") if i.isdigit() and int(i) < len(changes)})
if not chosen:
flash("반영할 줄을 고르세요.", "fail")
return redirect(url_for("geomread_page", name=name))
P0, E0 = p.load("params"), p.load("expected")
bk = p.dir / "backup" / f"before_geomread_{time.strftime('%Y%m%d_%H%M%S')}"
bk.mkdir(parents=True)
for f in ("params.json", "expected.json", "confirm.json"):
if (p.dir / f).exists():
shutil.copy2(p.dir / f, bk / f)
P1, E1 = geomread.apply_selected(P0, E0, changes, chosen)
if any(changes[i]["path"] == "planned_fail" for i in chosen):
geomread.stamp_pending(E1, request.form.get("by", "").strip(), store.now()[:10])
p.save("params", P1)
p.save("expected", E1)
# a fixed (F) bundle / fixed field changed by a reading needs a logged reason (check C7): the reading itself is the reason
by_bundle = {}
for i in chosen:
c = changes[i]
key = c["path"].split(".")[0]
m = p.section_meta(c["file"], key)
rel = c["path"].split(".", 1)[1] if "." in c["path"] else ""
if m.get("grade") == "F" or (rel and rel in (m.get("fixed_fields") or [])):
by_bundle.setdefault((c["file"], key), []).append(c["path"])
for (fl, key), paths in by_bundle.items():
p.log_fixed_edit(fl, key, paths, "도면에서 읽기 반영(" + (request.form.get("by", "").strip() or "-") + "): " + "; ".join(paths[:3]))
log = store.rjson(p.dir / "read_log.json", [])
log.append({"at": store.now(), "by": request.form.get("by", "").strip(), "count": len(chosen), "backup": bk.name,
"fields": [f"{changes[i]['file']}:{changes[i]['path']}" for i in chosen]})
store.wjson(p.dir / "read_log.json", log)
p.run_autocheck(force=True)
flash(f"도면에서 읽은 값 {len(chosen)}건을 반영했습니다(백업 {bk.name}). 바뀐 묶음은 다시 확정해야 합니다.", "ok")
return redirect(url_for("geomread_page", name=name))
# ---------------- sections
@app.route("/pier/<name>/section/<which>/<key>", methods=["GET", "POST"])
def section(name, which, key):
p = pier_or_404(name)
if which not in ("params", "expected"):
abort(404)
d = p.load(which)
if key not in d:
abort(404)
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)
@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.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"])
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))
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)
# ---------------- 검토 요약 일괄 확정 (2026-10-20): one screen to review, one act to confirm
def review_items(p):
"""Required bundles that are not (validly) confirmed, each with the app check, what changed since the pier was created, where the values came
from (app tools) and its review group (A 일치 / B 주의·근거 없음 / C 불일치 / D 결정 기록·참고)."""
p.run_autocheck()
org = store.rjson(p.dir / "origin_values.json", {})
rl, al = store.rjson(p.dir / "read_log.json", []), store.rjson(p.dir / "anchor_log.json", [])
docs = {"params": p.load("params"), "expected": p.load("expected")}
out = []
for f, k in sorted(required(p)):
s = p.section_state(f, k)
if s["confirmed"]:
continue
c = p.section_check(f, k)
ov = (org.get(f) or {}).get(k)
item = dict(file=f, key=k, id=f"{f}:{k}", st=c["st"], summary=c["summary"], checks=c["checks"], sha=c["sha"] if "sha" in c else "",
stale=s["stale"], prev=(s["by"], s["at"]) if s["stale"] else None, change=reviewlib.bundle_change(docs[f][k], ov),
prov=reviewlib.provenance(f, k, rl, al), unverified=reviewlib.not_checked(c["checks"], c["st"]), group=reviewlib.group_of(c["st"]))
if (f, k) == ("expected", "planned_fail"):
item["detail"] = [f"{x.get('gate', '')} {x.get('item') or x.get('item_prefix', '')} — {x.get('reason', '')} ({x.get('approved', '')})" for x in docs[f][k]]
item["flags"] = reviewlib.flags(item)
out.append(item)
return out
def _review_sha(items):
return store.sha([[i["id"], i["st"], i["change"]["text"], i.get("sha", "")] for i in items])
@app.post("/pier/<name>/review/read")
def review_read(name):
"""Marker: the review list was shown to its end (the page posts this when its last line is visible). Holds the hash of the list that was shown."""
p = pier_or_404(name)
items = review_items(p)
store.wjson(p.dir / "review_view.json", {"sha": _review_sha(items), "at": store.now(), "n": len(items)})
return {"ok": True, "n": len(items)}
@app.route("/pier/<name>/review", methods=["GET", "POST"])
def review(name):
"""One screen: the app prepares what changed / what it verified / what it could not verify; the person ticks what they compared with the drawing
(every 주의·근거 없음·결정 기록 bundle needs its own tick, 일치 bundles one tick for the group), names themselves and confirms once. 불일치 bundles
cannot be confirmed. Each confirmation record gets an informative reason (engine/review.auto_reason) + the person's note."""
p = pier_or_404(name)
items = review_items(p)
if request.method == "POST":
by, note = request.form.get("by", "").strip(), request.form.get("note", "")
ticked = set(request.form.getlist("ack"))
mark = store.rjson(p.dir / "review_view.json", {})
if not by:
flash("확정자 이름을 입력하세요.", "fail")
elif mark.get("sha") != _review_sha(items):
flash("검토 목록을 끝까지 읽은 뒤에 확정할 수 있습니다(목록이 바뀌었으면 다시 여세요).", "fail")
else:
done, skipped = [], []
for it in items:
if it["group"] == "C":
continue
if it["group"] == "A":
if not request.form.get("ack_matched"):
continue
elif it["id"] not in ticked:
skipped.append(it["id"])
continue
p.confirm(it["file"], it["key"], by, reviewlib.auto_reason(it, note), it["st"])
done.append(it["id"])
if not done:
flash("확정한 묶음이 없습니다. 도면과 대조한 묶음에 체크하세요(일치 묶음은 아래 '읽었습니다'에 체크).", "fail")
else:
flash(f"{len(done)}개 묶음을 {by}이(가) 확정했습니다. 사유는 앱이 아는 사실(앱 확인·변경·출처)과 입력한 의견으로 기록했습니다."
+ (f" 체크하지 않은 {len(skipped)}개는 미확정으로 남았습니다." if skipped else ""), "ok")
return redirect(url_for("review", name=name) if [i for i in items if i["id"] not in done] else url_for("pier", name=name))
return redirect(url_for("review", name=name))
cnt = {g: sum(1 for i in items if i["group"] == g) for g in "ABCD"}
return render_template("review.html", p=p, items=items, cnt=cnt, ST=verify.ST_NAME, by_group={g: [i for i in items if i["group"] == g] for g in "ABCD"})
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):
"""Required sections (all gates) that exist and are not confirmed, without duplicates."""
out = []
for g in p.scope():
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
# ---------------- gates
def gate_blockers(p, gate):
out = [f"{r['file']}.{r['key']} 미확정" for r in p.gate_requirements(gate) if r["exists"] and not r["confirmed"]]
out += [f"{r['file']}.{r['key']} 없음" for r in p.gate_requirements(gate) if not r["exists"]]
if p.info.get("type") not in store.PIER_TYPES:
out.append("T형이 아닌 교각")
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]} 미승인")
return out
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())
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="중단됨", reason="killed")
return b
def _batch(p, by, gates_todo):
bp = p.run_dir / "batch.json"
log = []
started = store.now()
def put(state, **kw):
"""batch.json; started = when this run began, reason (중단 only): blocked / error / fail -- the gates page tells the three apart (#34-5)"""
store.wjson(bp, dict({"state": state, "by": by, "pid": os.getpid(), "started": started, "at": store.now(), "log": log}, **kw))
put("실행 중")
need = list(gates_todo)
# 1. gates with a current PASS result: approve without running again (in order, until one needs a run)
while need:
g = need[0]
st = p.gate_status(g)
rep = st["report"] or {}
if not (rep.get("verdict") == "PASS" and not st["stale"]):
break
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("실행 중")
need.pop(0)
if not need:
return put("완료")
# 2. the rest: one Rhino session (gate checks + negative tests only, no per-gate save / reload, G5 saved once)
missing = [f"{g}: {b}" for g in need for b in gate_blockers(p, g) if not b.endswith("미승인")]
if missing:
log.append("실행 불가: " + ", ".join(missing))
return put("중단", reason="blocked")
jid, inputs = uuid.uuid4().hex[:12], p.inputs_sha()
ACTIVE[jid] = threading.current_thread()
jlog = []
def jobs(state, **kw):
for g in need:
store.wjson(p.run_dir / f"{g}_job.json",
dict({"id": jid, "pid": os.getpid(), "state": state, "at": store.now(), "log": jlog}, **kw))
def note(m):
jlog.append(m)
log.append(m)
jobs("실행 중")
put("실행 중")
jobs("대기")
log.append(f"{need[0]}~{need[-1]} 단일 세션 실행 (게이트 검사·음성 시험만, 중간 저장·재로드 없음)")
put("실행 중")
with RHINO_LOCK:
try:
reps = run_batch_session(need, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
except Exception:
tb = traceback.format_exc()
jobs("오류", error=tb)
log.append("오류 — 게이트 화면에서 내용을 확인하세요")
return put("중단", reason="error", error=(tb.strip().splitlines() or ["?"])[-1])
for g in need:
reps[g]["inputs_sha"], reps[g]["job_id"], reps[g]["at"] = inputs, jid, store.now()
store.wjson(p.run_dir / f"{g}_gate.json", reps[g])
jobs("완료")
# 3. approve in order; stop at the first gate that is not PASS
for g in need:
rep = reps[g]
if rep["verdict"] != "PASS":
log.append(f"{g} {rep['verdict']} — 승인하지 않고 멈춤 (결과를 확인하세요)")
return put("중단", reason="fail", gate=g)
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 p.scope() 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))
b = gate_blockers(p, gate)
if b:
flash(f"{gate} 실행 불가: " + ", ".join(b), "fail")
return redirect(url_for("gates", name=name))
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()
flash(f"{gate} 실행을 시작했습니다. 1~2분 걸립니다.", "ok")
return redirect(url_for("gates", name=name))
@app.post("/pier/<name>/gate/<gate>/decide")
def gate_decide(name, gate):
p = pier_or_404(name)
st = p.gate_status(gate)
by, decision, note = request.form.get("by", "").strip(), request.form["decision"], request.form.get("note", "")
if not st["report"] or st["stale"]:
flash("판정할 최신 결과가 없습니다. 게이트를 다시 실행하세요.", "fail")
elif not by:
flash("판정자 이름을 입력하세요.", "fail")
elif decision == "승인" and st["report"]["verdict"] != "PASS":
flash("FAIL 게이트는 승인할 수 없습니다. 입력을 고치거나 계획 FAIL로 등록(expected.planned_fail)한 뒤 다시 실행하세요.", "fail")
elif decision == "반려" and not note.strip():
flash("반려 사유를 입력하세요.", "fail")
else:
p.set_approval(gate, decision, by, note)
flash(f"{gate} {decision} ({by})", "ok")
return redirect(url_for("gates", name=name))
@app.route("/pier/<name>/gates")
def gates(name):
p = pier_or_404(name)
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 p.scope()]
for r in rows:
r["state"] = job_state(r["job"])
batch = batch_state(p)
# #34-5 (2026-10-20): a batch that did not finish must not look like a finished one -- say what stopped it, which results are older than the run,
# and show the partial results that were computed before it stopped (never used for approval or the report)
aborted = bool(batch) and batch["state"] in ("중단", "중단됨")
since = (batch or {}).get("started") or (batch or {}).get("at") or ""
for r in rows:
r["older"] = bool(aborted and r["report"] and (r["report_at"] or "") < since)
r["show_partial"] = bool(r["partial"] and (aborted or r["state"] in ("오류", "중단됨")))
running = any(r["state"] in ("대기", "실행 중") for r in rows) or (batch or {}).get("state") == "실행 중"
return render_template("gates.html", p=p, rows=rows, running=running, batch=batch, aborted=aborted,
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()])
@app.post("/pier/<name>/report")
def report_make(name):
p = pier_or_404(name)
if not any(p.gate_status(g)["report"] for g in p.scope()):
flash("게이트 결과가 없습니다. 게이트를 먼저 실행하세요.", "fail")
return redirect(url_for("gates", name=name))
try:
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()])
except Exception as e:
flash(f"보고서 생성 실패: {e}", "fail")
return redirect(url_for("gates", name=name))
c = out["check"]
flash(f"보고서 초안 생성: {out['md'].name} · 자체 점검 {'통과' if c['ok'] else '미통과'} "
f"(장 누락 {len(c['chapters_missing'])}, 재료표 {c['schedule_rows']}/{c['schedule_marks']}행"
f"{' — 재료표를 읽지 못함' if c.get('schedule_unread') else ''}, 지적 {c['finding_rows']}건)",
"ok" if c["ok"] else "fail")
return redirect(url_for("gates", name=name))
@app.route("/pier/<name>/report/<kind>")
def report_get(name, kind):
p = pier_or_404(name)
files = sorted(p.run_dir.glob(f"보고서_*.{kind}")) if kind in ("md", "pdf") else []
if not files:
abort(404)
return send_file(files[-1], as_attachment=True, download_name=files[-1].name)
@app.route("/pier/<name>/download/<gate>")
def download(name, gate):
p = pier_or_404(name)
f = p.run_dir / f"{gate}.3dm"
rep = p.gate_status(gate)["report"] if gate in gate_ids() else None
if not rep or not rep.get("model") or not f.exists(): # batch run: only G5 is saved
abort(404)
return send_file(f, as_attachment=True, download_name=f"{p.name}_{gate}.3dm")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)
@@ -0,0 +1,69 @@
/* Prism for the whole web app (stage D, 2026-10-19): app shell + the plain HTML elements of the older screens, written with the tokens of
prism.css (generated from the design mockup). The stage-B/C screens use the scoped .prism components on top of this. Decisions UX1-UX6:
Prism replaces R8, status colours are Prism's with marks and words (never colour alone), Pretendard is bundled, no dark theme test. */
html { background: var(--prism-bg-canvas); }
body { margin: 0; background: var(--prism-bg-canvas); color: var(--prism-text-body); font-family: var(--prism-font); font-size: var(--prism-type-body-sm);
line-height: var(--prism-lh-body); font-variant-numeric: tabular-nums; }
* { box-sizing: border-box; }
a { color: var(--prism-text-heading); }
:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }
/* shell */
header.top { height: var(--prism-comp-header-height); background: var(--prism-bg-card); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);
display: flex; align-items: center; gap: var(--prism-space-300); padding: 0 var(--prism-space-300); position: sticky; top: 0; z-index: var(--prism-z-sticky); }
header.top .brand { display: flex; align-items: center; gap: var(--prism-space-100); font-weight: 700; font-size: var(--prism-type-body-lg); color: var(--prism-text-heading);
text-decoration: none; letter-spacing: -0.01em; }
header.top .brand i { width: 0.7rem; height: 0.7rem; background: var(--prism-text-accent); transform: rotate(45deg); display: block; }
header.top nav { display: flex; align-items: center; gap: var(--prism-space-050); flex-wrap: wrap; }
header.top nav a { display: inline-flex; align-items: center; min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); border-radius: var(--prism-radius-sm);
text-decoration: none; font-weight: 500; color: var(--prism-text-body); }
header.top nav a.pier { font-weight: 700; color: var(--prism-text-heading); }
header.top nav a:hover { background: var(--prism-bg-canvas); }
header.top .sep { color: var(--prism-text-subtle); }
main.page { max-width: 80rem; margin: 0 auto; padding: var(--prism-space-300) var(--prism-space-300) var(--prism-space-600); }
main.page > h1, main.page h1 { font-size: var(--prism-type-title); line-height: var(--prism-lh-title); color: var(--prism-text-heading); font-weight: 700; margin: var(--prism-space-100) 0 var(--prism-space-200); }
main.page h2 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 500; color: var(--prism-text-heading); margin: var(--prism-space-400) 0 var(--prism-space-150); }
main.page h3 { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); margin: var(--prism-space-300) 0 var(--prism-space-100); }
main.page p { max-width: 62rem; margin: var(--prism-space-100) 0; }
.muted { color: var(--prism-text-subtle); }
/* status words of the older screens (always words, never colour alone) */
main.page .ok, main.page .warn, main.page .fail { font-weight: 700; }
main.page .ok { color: var(--prism-comp-state-good-fg); }
main.page .warn { color: var(--prism-comp-state-warn-fg); }
main.page .fail { color: var(--prism-comp-state-bad-fg); }
.flash { border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); padding: var(--prism-space-150) var(--prism-space-200);
margin: var(--prism-space-150) 0; background: var(--prism-bg-card); font-weight: 500; }
.flash.ok { border-color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); color: var(--prism-comp-state-good-fg); }
.flash.fail { border-color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold); }
.flash.warn { border-color: var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); color: var(--prism-comp-state-warn-fg); }
/* tables of the older screens */
main.page table { border-collapse: collapse; margin: var(--prism-space-100) 0; background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line);
border-radius: var(--prism-radius-sm); font-size: var(--prism-type-body-sm); }
main.page th, main.page td { padding: var(--prism-space-100) var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);
text-align: left; vertical-align: top; }
main.page th { background: var(--prism-comp-table-head-bg); font-weight: 700; white-space: nowrap; border-bottom: var(--prism-stroke-regular) solid var(--prism-comp-line); }
main.page .wrap { overflow-x: auto; max-width: 100%; }
main.page details { margin: var(--prism-space-100) 0; }
main.page summary { cursor: pointer; min-height: var(--prism-comp-hit-min); display: flex; align-items: center; font-weight: 500; }
main.page code { font-family: var(--prism-font); font-size: var(--prism-type-caption); background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line);
border-radius: var(--prism-radius-sm); padding: 0 var(--prism-space-050); }
main.page pre { background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); padding: var(--prism-space-150);
white-space: pre-wrap; font-size: var(--prism-type-caption); }
/* buttons and fields of the older screens (the .prism blocks keep their own .btn / .field) */
main.page button:not(.btn) { min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200); border-radius: var(--prism-radius-sm); font: inherit; font-weight: 500;
cursor: pointer; border: var(--prism-stroke-regular) solid var(--prism-text-heading); background: var(--prism-bg-card); color: var(--prism-text-heading); }
main.page button:not(.btn):disabled { border-style: dashed; color: var(--prism-text-subtle); border-color: var(--prism-comp-line); cursor: not-allowed; }
main.page input[type=text], main.page input[type=file], main.page select { min-height: var(--prism-comp-hit-min); padding: var(--prism-space-100) var(--prism-space-150); font: inherit;
color: var(--prism-text-body); background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
main.page input[type=checkbox] { width: 1.25rem; height: 1.25rem; accent-color: var(--prism-text-heading); }
main.page textarea { width: 100%; height: 30rem; font-family: ui-monospace, Consolas, monospace; font-size: var(--prism-type-caption); line-height: 1.6; padding: var(--prism-space-150);
background: var(--prism-bg-card); color: var(--prism-text-body); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
main.page form { margin: var(--prism-space-100) 0; }
main.page ul { padding-left: var(--prism-space-300); }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
@media (max-width: 48rem) { header.top { height: auto; flex-wrap: wrap; padding: var(--prism-space-100) var(--prism-space-200); } main.page { padding: var(--prism-space-200); } }
.skip:focus { left: var(--prism-space-200) !important; top: var(--prism-space-100); z-index: var(--prism-z-modal); background: var(--prism-bg-card); padding: var(--prism-space-100) var(--prism-space-200);
border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-sm); }
@@ -0,0 +1,136 @@
/* generated by tools/make_prism_css.py from DOC/01_기획/웹앱디자인시안/시안/pier.css — do not edit by hand */
@font-face { font-family: "Pretendard"; src: url("fonts/PretendardVariable.woff2") format("woff2-variations"); font-weight: 45 920; font-display: swap; }
/* PierProof — Prism app profile tokens (design.md 3.2 / 3.3) and components. Literals live only in :root. */
:root {
--prism-bg-canvas: #F4F3F0; --prism-bg-card: #FFFFFF;
--prism-text-body: #474235; --prism-text-heading: #1F524B; --prism-text-subtle: #474235; --prism-text-accent: #D05C26;
--prism-border-default: #474235; --prism-border-strong: #1F524B;
--prism-status-do: #005A4E; --prism-status-do-bg: #DCEAE6;
--prism-status-dont: #8F2A11; --prism-status-dont-bg: #F6DFDA;
--prism-status-caution: #6B4A00; --prism-status-caution-bg: #F5E7CC;
--prism-status-tip: #0A4E86; --prism-status-tip-bg: #DDE8F3;
--prism-space-025: 0.125rem; --prism-space-050: 0.25rem; --prism-space-100: 0.5rem; --prism-space-150: 0.75rem;
--prism-space-200: 1rem; --prism-space-300: 1.5rem; --prism-space-400: 2rem; --prism-space-600: 3rem; --prism-space-800: 4rem;
--prism-type-caption: 0.8125rem; --prism-lh-caption: 1.55; --prism-type-body-sm: 0.875rem; --prism-lh-body: 1.7;
--prism-type-body: 1rem; --prism-type-body-lg: 1.125rem; --prism-type-title-sm: 1.25rem; --prism-lh-title: 1.5;
--prism-type-title: 1.625rem; --prism-type-title-lg: 2rem;
--prism-radius-sm: 4px; --prism-radius-md: 8px; --prism-radius-lg: 12px; --prism-radius-pill: 9999px;
--prism-stroke-hairline: 1px; --prism-stroke-regular: 2px; --prism-stroke-bold: 3px;
--prism-z-raised: 100; --prism-z-sticky: 200; --prism-z-scrim: 400; --prism-z-modal: 500;
--prism-scrim: rgba(0, 0, 0, 0.32);
--prism-motion-fast: 100ms; --prism-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--prism-focus-ring: 3px; --prism-focus-offset: 2px; --prism-measure-max: 38em;
--prism-font: Pretendard, -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
--prism-comp-line: var(--prism-border-default); --prism-comp-line-strong: var(--prism-border-strong);
--prism-comp-state-good-fg: var(--prism-status-do); --prism-comp-state-good-bg: var(--prism-status-do-bg);
--prism-comp-state-bad-fg: var(--prism-status-dont); --prism-comp-state-bad-bg: var(--prism-status-dont-bg);
--prism-comp-state-warn-fg: var(--prism-status-caution); --prism-comp-state-warn-bg: var(--prism-status-caution-bg);
--prism-comp-state-info-fg: var(--prism-status-tip); --prism-comp-state-info-bg: var(--prism-status-tip-bg);
--prism-comp-rail-width: 15rem; --prism-comp-header-height: 3.5rem; --prism-comp-hit-min: 2.75rem;
--prism-comp-table-head-bg: var(--prism-bg-canvas);
--prism-comp-on-emphasis: var(--prism-bg-card);
}
:root[data-theme="dark"] {
--prism-bg-canvas: #111111; --prism-bg-card: #111111; --prism-text-body: #F4F3F0; --prism-text-heading: #F4F3F0;
--prism-text-subtle: #D3D3D3; --prism-border-default: #D3D3D3; --prism-border-strong: #F4F3F0;
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E; --prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00; --prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
}
.prism { font-family: var(--prism-font); font-size: var(--prism-type-body-sm); line-height: var(--prism-lh-body); color: var(--prism-text-body); font-variant-numeric: tabular-nums; margin: var(--prism-space-200) 0; }
.prism * { box-sizing: border-box; }
.prism a { color: var(--prism-text-heading); }
.prism h1, .prism h2, .prism h3, .prism p, .prism dl, .prism dd, .prism ol, .prism ul { margin: 0; padding: 0; }
.prism h2 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 500; color: var(--prism-text-heading); }
.prism h3 { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); }
.prism textarea { height: auto; min-height: 5rem; width: 100%; font-family: var(--prism-font); }
.prism :focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }
.prism .eyebrow {font-size: var(--prism-type-caption); font-weight: 500; letter-spacing: 0.06em;}
.prism .lead {max-width: var(--prism-measure-max); font-size: var(--prism-type-body);}
.prism .cap {font-size: var(--prism-type-caption);}
.prism .muted {color: var(--prism-text-subtle);}
.prism .row {display: flex; gap: var(--prism-space-200); align-items: center; flex-wrap: wrap;}
.prism .row.end {justify-content: flex-end;}
.prism .stack {display: flex; flex-direction: column; gap: var(--prism-space-200);}
.prism .grid3 {display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--prism-space-300);}
.prism .grid2 {display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--prism-space-300);}
.prism .split {display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); gap: var(--prism-space-300); align-items: start;}
.prism .grow {flex: 1;}
.prism .scope {background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); padding: var(--prism-space-150) var(--prism-space-200);
display: flex; gap: var(--prism-space-400); font-size: var(--prism-type-body-sm); border-radius: var(--prism-radius-sm);}
.prism .scope b {font-weight: 700;}
.prism .card {background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-md); padding: var(--prism-space-200);
display: flex; flex-direction: column; gap: var(--prism-space-150);}
.prism .card.sunken {background: var(--prism-bg-canvas);}
.prism .strip {background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-md);
padding: var(--prism-space-200) var(--prism-space-300); display: flex; flex-direction: column; gap: var(--prism-space-150);}
.prism .strip .concl {font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); max-width: none;}
.prism .counts {display: flex; gap: var(--prism-space-200); flex-wrap: wrap; align-items: stretch;}
.prism .cnt {display: flex; align-items: baseline; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200);
background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); font: inherit; color: inherit; cursor: pointer;}
.prism .cnt b {font-size: var(--prism-type-title); font-weight: 700; line-height: 1.2;}
.prism .cnt.sel {background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis); border-color: var(--prism-text-heading);}
.prism .banner {border: var(--prism-stroke-regular) solid var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); color: var(--prism-comp-state-warn-fg);
border-radius: var(--prism-radius-sm); padding: var(--prism-space-150) var(--prism-space-200); display: flex; gap: var(--prism-space-150); align-items: flex-start;}
.prism .banner b {font-weight: 700;}
.prism .banner.tip {border-color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); color: var(--prism-comp-state-info-fg);}
.prism .banner.dont {border-color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); color: var(--prism-comp-state-bad-fg);}
.prism .banner.good {border-color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); color: var(--prism-comp-state-good-fg);}
.prism .banner .m {flex: none;}
.prism .b {display: inline-flex; align-items: center; gap: var(--prism-space-050); padding: var(--prism-space-025) var(--prism-space-100); white-space: nowrap;
font-size: var(--prism-type-caption); font-weight: 500; line-height: 1.5; border: var(--prism-stroke-regular) solid var(--prism-comp-line);
border-radius: var(--prism-radius-sm); color: var(--prism-text-body); background: transparent;}
.prism .b.good {color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); border-color: var(--prism-comp-state-good-fg);}
.prism .b.warn {color: var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); border-color: var(--prism-comp-state-warn-fg);}
.prism .b.info {color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); border-color: var(--prism-comp-state-info-fg);}
.prism .b.bad {color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); border-color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold);}
.prism .b.dash {border-style: dashed;}
.prism .b.fixed {color: var(--prism-comp-on-emphasis); background: var(--prism-text-heading); border-color: var(--prism-text-heading);}
.prism .b .m {font-size: 0.7em;}
.prism .btn {min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200); border-radius: var(--prism-radius-sm); font: inherit; font-weight: 500;
cursor: pointer; border: var(--prism-stroke-regular) solid var(--prism-text-heading); background: var(--prism-bg-card); color: var(--prism-text-heading);
display: inline-flex; align-items: center; justify-content: center; gap: var(--prism-space-100); text-decoration: none; white-space: nowrap;}
.prism .btn.primary {background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis);}
.prism .btn.quiet {border-color: transparent; background: transparent; text-decoration: underline;}
.prism .btn.danger {border-color: var(--prism-comp-state-bad-fg); color: var(--prism-comp-state-bad-fg);}
.prism .btn.sm {min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); font-size: var(--prism-type-caption);}
.prism .field {display: flex; flex-direction: column; gap: var(--prism-space-050);}
.prism .field label, .prism .lbl {font-weight: 700; font-size: var(--prism-type-body-sm);}
.prism .req {font-weight: 500; font-size: var(--prism-type-caption); margin-left: var(--prism-space-100);}
.prism .field input, .prism .field select, .prism .field textarea {min-height: var(--prism-comp-hit-min); padding: var(--prism-space-100) var(--prism-space-150); font: inherit;
color: var(--prism-text-body); background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);}
.prism .field textarea {font-size: var(--prism-type-body-sm); line-height: 1.6; resize: none;}
.prism .field .help {font-size: var(--prism-type-caption);}
.prism .field.bad input, .prism .field.bad textarea {border-color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold);}
.prism .err {color: var(--prism-comp-state-bad-fg); font-size: var(--prism-type-caption); font-weight: 500; display: flex; gap: var(--prism-space-050);}
.prism .choice {display: flex; gap: var(--prism-space-300); align-items: center;}
.prism .choice label {display: inline-flex; align-items: center; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); font-weight: 500;}
.prism .choice input {width: 1.25rem; height: 1.25rem; accent-color: var(--prism-text-heading);}
.prism .tw {overflow-x: auto; background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);}
.prism table.t {width: 100%; border-collapse: collapse; font-size: var(--prism-type-body-sm);}
.prism table.t caption {text-align: left; font-weight: 700; padding: var(--prism-space-100) var(--prism-space-150); color: var(--prism-text-heading);}
.prism table.t th, .prism table.t td {padding: var(--prism-space-100) var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line); text-align: left; vertical-align: middle;}
.prism table.t thead th {position: sticky; top: 0; background: var(--prism-comp-table-head-bg); font-weight: 700; white-space: nowrap; border-bottom: var(--prism-stroke-regular) solid var(--prism-comp-line);}
.prism table.t td.num, .prism table.t th.num {text-align: right;}
.prism table.t tr.sel td {background: var(--prism-comp-table-head-bg);}
.prism table.t tr.sep td {background: var(--prism-comp-table-head-bg); font-weight: 700; color: var(--prism-text-heading);}
.prism table.t td a {font-weight: 700;}
.prism .chip {display: inline-block; border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);
padding: 0 var(--prism-space-100); font-size: var(--prism-type-caption); white-space: nowrap; background: var(--prism-bg-card);}
.prism dl.kv {display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--prism-space-100) var(--prism-space-300); font-size: var(--prism-type-body-sm);}
.prism dl.kv dt {font-weight: 700;}
.prism .pbar {height: 0.75rem; border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-pill); overflow: hidden; background: var(--prism-bg-card);}
.prism .pbar > i {display: block; height: 100%; background: var(--prism-text-heading);}
.prism .list {list-style: none; display: flex; flex-direction: column;}
.prism .list li {display: flex; align-items: center; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);}
.prism .list li a {flex: 1; font-weight: 500; text-decoration: none;}
.prism .list li.cur {background: var(--prism-bg-canvas);}
.prism .list li.cur a {font-weight: 700;}
.prism .scroll {max-height: 18rem; overflow-y: auto; border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);}
.prism .drawing {background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); display: block; width: 100%; height: auto;}
.prism .toc a {display: flex; align-items: center; min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); text-decoration: none; font-weight: 500; border-radius: var(--prism-radius-sm);}
.prism .toc a.cur {background: var(--prism-bg-canvas); font-weight: 700;}
.prism .toc ol {list-style: none;}
.prism .num {font-variant-numeric: tabular-nums;}
.prism pre.code {margin: 0; background: var(--prism-bg-canvas); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);
padding: var(--prism-space-150); font-family: var(--prism-font); font-size: var(--prism-type-caption); line-height: 1.6; white-space: pre-wrap;}
+494
View File
@@ -0,0 +1,494 @@
"""Pier data store for the web app: params / expected / confirmations / drawings / gate runs per pier folder.
data/<pier>/pier.json name, type (T형 only), created
data/<pier>/params.json generator params (pier-T/1)
data/<pier>/expected.json independent drawing values for the checkers
data/<pier>/confirm.json section confirmations {"params:footing": {"sha", "by", "at"}}
data/<pier>/drawings/ uploaded DWG, converted DXF, texts.json, audit.json
data/<pier>/runs/current/ gate models and results; approvals.json
data/_템플릿/<template>/ protected templates (params, expected, template.json); not listed as piers,
only written by tools/make_template.py (2026-10-13)
"""
import hashlib
import json
import os
import re
import shutil
import sys
import threading
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
if str(APP) not in sys.path:
sys.path.insert(0, str(APP))
from engine import anchors, reader, variability, verify # noqa: E402
DATA = Path(os.environ.get("PIERAPP_DATA", APP / "data"))
PIER_TYPES = ["T형"] # first target only; other types are rejected
COMMON = [("params", "pier_id"), ("params", "rebar_marks"), ("params", "display"),
("expected", "pier_id"), ("expected", "tol_mm"), ("expected", "rebar_palette"),
("expected", "planned_fail"),
# run settings: they change what is built and how it is checked, so they need confirmation too
("params", "scope"), ("params", "builders"), ("expected", "checkers"), ("expected", "negative")]
OPTIONAL = {("params", "piles"), # a pier on a spread footing has no piles (Ulsan P1)
("params", "pile"), ("params", "shaft_rebar"), # single drilled shaft form only (Ramp-A P1, 2026-10-14)
# no footing / lean concrete / footing rebar in the drilled shaft form: absent -> not required (Ramp-A G1 / G3
# were blocked as "없음" in the app, found 2026-10-14)
("params", "lean_concrete"), ("params", "footing"), ("params", "footing_rebar"),
("params", "scope"), ("params", "builders"), # absent -> defaults (Ungok)
("expected", "checkers"), ("expected", "negative")}
def is_info(key):
"""Reference-only section (schema tag, source note, decision record): not used by any gate."""
return key.startswith("_") or key.endswith("_src") or key.startswith("hitl_decisions")
GATE_SECTIONS = {
"G1": [("params", k) for k in ("lean_concrete", "footing", "column", "coping", "piles", "pile", "bearing_seats",
"materials")] + [("expected", "G1")],
"G2": [("params", "column_rebar"), ("expected", "G2")],
"G3": [("params", "footing_rebar"), ("params", "shaft_rebar"), ("expected", "G3")],
"G4": [("params", "coping_rebar"), ("expected", "G4")],
"G5": [("params", "sole_plates"), ("expected", "G5")],
}
def now():
return time.strftime("%Y-%m-%d %H:%M:%S")
def rjson(p, default=None):
"""Read JSON; retries briefly because on Windows a read during wjson's os.replace raises PermissionError."""
p = Path(p)
for i in range(20):
if not p.exists():
return default
try:
return json.loads(p.read_text(encoding="utf-8"))
except PermissionError:
time.sleep(0.02 * (i + 1))
return json.loads(p.read_text(encoding="utf-8"))
def wjson(p, obj):
"""Atomic write (temp file + replace): the gate thread writes job files while page requests read them;
a plain write_text let a reader see an empty file (JSONDecodeError, E2E test 2026-10-12)."""
p = Path(p)
p.parent.mkdir(parents=True, exist_ok=True)
tmp = p.with_name(f".{p.name}.{threading.get_ident()}.tmp")
tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=1), encoding="utf-8")
for i in range(20):
try:
os.replace(tmp, p)
return
except PermissionError: # Windows: target briefly open by a reader
time.sleep(0.05 * (i + 1))
os.replace(tmp, p)
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):
return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()[:16]
CHAIN_RE = re.compile(r"^(?:(\d+)\s*@\s*)?(-?\d+(?:\.\d+)?)$")
def chain(text):
"""Dimension chain -> positions: 'start, inc, n@pitch, ...' (first value = start, then increments; 'n@p' = n
increments of p, as '22@120' on the drawings). '-4575, 200, 2@125' -> [-4575, -4375, -4250, -4125]."""
toks = [t.strip() for t in str(text).split(",") if t.strip()]
if len(toks) < 1:
raise ValueError("치수 사슬이 비어 있습니다")
out = []
for i, t in enumerate(toks):
m = CHAIN_RE.match(t)
if not m or (i == 0 and m.group(1)):
raise ValueError(f"치수 사슬 형식 오류: '{t}' (첫 값은 시작 위치, 이후는 증분 또는 n@간격)")
n, v = int(m.group(1) or 1), float(m.group(2))
if n < 1:
raise ValueError(f"치수 사슬 형식 오류: '{t}' (반복 횟수는 1 이상)")
for _ in range(n):
out.append(v if i == 0 else out[-1] + v)
fl = any("." in t for t in toks)
return [round(x, 6) if fl else int(round(x)) for x in out]
def expand_chains(v):
"""Replace every {"chain": "..."} in an edited section by its position list (OPEN #19 A5). The saved params
hold plain lists only, so builders, checkers and fingerprints are unchanged."""
if isinstance(v, dict):
if set(v) == {"chain"}:
return chain(v["chain"])
return {k: expand_chains(x) for k, x in v.items()}
if isinstance(v, list):
return [expand_chains(x) for x in v]
return v
def safe_name(name):
"""Folder / file name: keep Korean and alphanumerics, drop path separators and control characters."""
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "", name).strip().strip(".")
if not name:
raise ValueError("이름이 비어 있습니다")
return name
class Pier:
def __init__(self, name):
self.name = safe_name(name)
self.dir = DATA / self.name
if not (self.dir / "params.json").exists():
raise FileNotFoundError(name)
# ---- files
@property
def info(self):
return rjson(self.dir / "pier.json", {"name": self.name, "type": "T형", "created": "-"})
def load(self, which):
return rjson(self.dir / f"{which}.json")
def save(self, which, obj):
wjson(self.dir / f"{which}.json", obj)
def confirmations(self):
return rjson(self.dir / "confirm.json", {})
# ---- sections
def sections(self):
out = []
for which in ("params", "expected"):
d = self.load(which)
for k, v in d.items():
out.append({"file": which, "key": k, "sha": sha(v), "kind": type(v).__name__})
return out
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
# ---- 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")}
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 {})
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): 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()), anchors.VERSION,
sha(rjson(self.dir / "anchors.json", {}).get("fields", {}))])
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"]}
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)
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),
rjson(self.dir / "origin_values.json", None))
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):
d = self.load(which)
if key not in d:
return {"exists": False, "confirmed": False}
c = self.confirmations().get(f"{which}:{key}")
ok = bool(c) and c["sha"] == sha(d[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, 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
wjson(self.dir / "confirm.json", conf)
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):
value = expand_chains(value)
d = self.load(which)
if key in d and type(d[key]) is not type(value):
raise ValueError(f"형식이 바뀌었습니다: {type(d[key]).__name__} → {type(value).__name__}")
d[key] = value
self.save(which, d)
def gate_requirements(self, gate):
need = COMMON + GATE_SECTIONS[gate]
D = {"params": self.load("params"), "expected": self.load("expected")}
need = [(f, k) for f, k in need if not ((f, k) in OPTIONAL and k not in D[f])]
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]
def inputs_sha(self):
return sha([self.load("params"), self.load("expected")])
# ---- drawings
@property
def drawings(self):
return self.dir / "drawings"
def texts(self):
return rjson(self.drawings / "texts.json", [])
# ---- gate runs
@property
def run_dir(self):
return self.dir / "runs" / "current"
def approvals(self):
return rjson(self.run_dir / "approvals.json", {})
def set_approval(self, gate, decision, by, note):
a = self.approvals()
rep = rjson(self.run_dir / f"{gate}_gate.json", {})
a[gate] = {"decision": decision, "by": by, "note": note, "at": now(), "inputs_sha": rep.get("inputs_sha")}
wjson(self.run_dir / "approvals.json", a)
def gate_status(self, gate):
rep = rjson(self.run_dir / f"{gate}_gate.json")
appr = self.approvals().get(gate)
cur = self.inputs_sha()
stale = bool(rep) and rep.get("inputs_sha") != cur
approved = bool(appr) and appr["decision"] == "승인" and appr.get("inputs_sha") == cur and not stale
return {"report": rep, "approval": appr, "stale": stale, "approved": approved,
"approval_stale": bool(appr) and appr["decision"] == "승인" and not approved,
"report_at": (rep or {}).get("at") or self._mtime(self.run_dir / f"{gate}_gate.json"),
"partial": self.partial_result(gate),
"job": rjson(self.run_dir / f"{gate}_job.json")}
@staticmethod
def _mtime(path):
try:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(Path(path).stat().st_mtime))
except OSError:
return None
def partial_result(self, gate):
"""Check results of a run that did not finish (#34-5): `<gate>.json` (the checker's own output) is written while the run goes on, `<gate>_gate.json`
(the recorded result) only when it ends. A checker output newer than the recorded result is a partial result of an aborted run:
{at, passed, total, fails: [item ...]} or None. It is shown as such and never used for approval or the report."""
g, c = self.run_dir / f"{gate}.json", self.run_dir / f"{gate}_gate.json"
if not g.exists() or (c.exists() and g.stat().st_mtime <= c.stat().st_mtime):
return None
d = rjson(g)
if not isinstance(d, dict) or "results" not in d:
return None
s = d.get("summary") or {}
fails = [r["item"] for r in d["results"] if r.get("result") == "FAIL"]
return {"at": self._mtime(g), "passed": s.get("passed"), "total": s.get("total"), "fails": fails}
def piers():
return sorted(p.name for p in DATA.iterdir() if (p / "params.json").exists()) if DATA.exists() else []
TRASH = "_휴지통"
def delete_pier(name):
"""Move the pier folder to data/_휴지통/<name>_<time> (recoverable by moving it back). Returns the new path."""
p = Pier(name)
dst = DATA / TRASH / f"{p.name}_{time.strftime('%Y%m%d_%H%M%S')}"
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(p.dir), str(dst))
return dst
TEMPLATES = "_템플릿"
TPL = "tpl:" # form value prefix of a protected template
def templates():
"""Protected templates [{name, desc, source, created, scope}] from data/_템플릿/<name>/template.json."""
root = DATA / TEMPLATES
out = []
if root.exists():
for d in sorted(root.iterdir()):
if (d / "params.json").exists() and (d / "expected.json").exists():
info = rjson(d / "template.json", {})
sc = rjson(d / "params.json", {}).get("scope")
out.append(dict(name=d.name, desc=info.get("desc", ""), source=info.get("source", "-"),
created=info.get("created", "-"), scope=sc or ["G1", "G2", "G3", "G4", "G5"]))
return out
def template_dir(name):
d = DATA / TEMPLATES / safe_name(name)
if not (d / "params.json").exists():
raise FileNotFoundError(f"템플릿이 없습니다: {name}")
return d
def save_template(source, tname, desc):
"""Snapshot params / expected as a protected template; `source` is a pier name or a folder holding
params.json / expected.json (e.g. a pier in data/_휴지통). An older snapshot of the same name is kept
under data/_템플릿/_이전/."""
sdir = Path(source) if Path(source).is_dir() and (Path(source) / "params.json").exists() else Pier(source).dir
tname = safe_name(tname)
d = DATA / TEMPLATES / tname
if d.exists():
old = DATA / TEMPLATES / "_이전" / f"{tname}_{time.strftime('%Y%m%d_%H%M%S')}"
old.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(d), str(old))
d.mkdir(parents=True)
for f in ("params.json", "expected.json"):
shutil.copy2(sdir / f, d / f)
label = sdir.name if sdir.parent == DATA else f"{sdir.parent.name}/{sdir.name}"
wjson(d / "template.json", {"name": tname, "desc": desc, "source": label, "created": now(),
"inputs_sha": sha([rjson(sdir / "params.json"), rjson(sdir / "expected.json")])})
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
def create_pier(name, ptype, template):
"""New pier from a protected template ("tpl:<name>") or a copy of an existing pier (plain name)."""
if ptype not in PIER_TYPES:
raise ValueError(f"대상 외 교각 형식입니다: {ptype} (1차 대상은 T형만)")
name = safe_name(name)
if name == TRASH:
raise ValueError(f"'{TRASH}'는 휴지통 폴더 이름이라 쓸 수 없습니다")
if name == TEMPLATES:
raise ValueError(f"'{TEMPLATES}'는 템플릿 폴더 이름(앱이 쓰는 폴더 이름)이라 쓸 수 없습니다")
d = DATA / name
if d.exists():
raise ValueError(f"이미 있는 교각입니다: {name}")
if template.startswith(TPL):
src_dir = template_dir(template[len(TPL):])
label = f"템플릿: {src_dir.name}"
else:
src_dir = Pier(template).dir
label = src_dir.name
d.mkdir(parents=True)
for f in ("params.json", "expected.json"):
shutil.copy2(src_dir / f, d / f)
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)
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)
# 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()},
"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")}}
wjson(d / "pier.json", {"name": name, "type": ptype, "created": now(), "template": label, "origin": origin})
# creation snapshot of every value (C3b 필드 단위 원본 잔존, 2026-10-20): the app can then tell which fields of a changed bundle were never touched
wjson(d / "origin_values.json", {"label": label, "at": now(), "reconstructed": False, "params": P0, "expected": E0})
return Pier(name)
@@ -0,0 +1,19 @@
{# Prism badges (stage B, 2026-10-16). Colour is never the only signal: every badge has a mark and a word. #}
{% macro st_badge(st) -%}
{%- set M = {'g': ['good', '●', '일치'], 'w': ['warn', '▲', '주의'], 'b': ['bad', '■', '불일치'], 'n': ['dash', '◇', '근거 없음'], 'x': ['', '—', '해당 없음'], '?': ['dash', '◇', '앱 확인 전']} -%}
{%- set m = M.get(st, M['n']) -%}
<span class="b {{ m[0] }}"><span class="m" aria-hidden="true">{{ m[1] }}</span>{{ m[2] }}</span>
{%- endmacro %}
{% 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) -%}
{%- if s.confirmed -%}<span class="b fixed"><span class="m" aria-hidden="true">●</span>확정</span>
{%- elif s.info -%}<span class="b dash" title="게이트가 쓰지 않는 참고 정보입니다">확정 불필요(참고)</span>
{%- elif s.stale -%}<span class="b warn"><span class="m" aria-hidden="true">◆</span>변경됨 · 재확정 필요</span>
{%- else -%}<span class="b dash">미확정</span>{%- endif -%}
{%- endmacro %}
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}T형 교각 모델링{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='prism.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='prism-app.css') }}">
</head>
<body>
<a class="skip" href="#main" style="position:absolute;left:-999rem">본문 바로가기</a>
<header class="top">
<a class="brand" href="{{ url_for('index') }}"><i aria-hidden="true"></i>T형 교각 모델링 <span class="muted" style="font-weight:400">반자동 MVP</span></a>
{% if p %}<span class="sep" aria-hidden="true">›</span><nav aria-label="교각 화면">
<a class="pier" href="{{ url_for('pier', name=p.name) }}">{{ p.name }}</a>
<a href="{{ url_for('search', name=p.name) }}">도면 문자 검색</a>
<a href="{{ url_for('candidates', name=p.name) }}">표 후보값</a>
<a href="{{ url_for('geomread_page', name=p.name) }}">도면에서 읽기</a>
<a href="{{ url_for('gates', name=p.name) }}">게이트</a></nav>{% endif %}
</header>
<main class="page" id="main">
{% with msgs = get_flashed_messages(with_categories=true) %}{% for c, m in msgs %}
<div class="flash {{ c }}" role="status">{{ m }}</div>{% endfor %}{% endwith %}
{% if p and p.info.fit_mismatch %}<div class="flash warn" role="alert">▲ 형식 불일치 경고: 이 교각은 선택한 템플릿과 다음 항목이 다릅니다 — {{ p.info.fit_mismatch | join(', ') }}. 같은 템플릿으로 만들 수 없는 형식일 수 있으니 모든 묶음을 특히 주의해서 확인하세요. (이 경고는 닫을 수 없습니다)</div>{% endif %}
{% block body %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block title %}표 후보값 · {{ p.name }}{% endblock %}
{% block body %}
<h1>표 후보값</h1>
<p class="muted">자동 인식 범위: 교량받침 ELEVATION TABLE, 솔플레이트 상세 TABLE, 철근재료표(SD400·SD500). 운곡교 P1 도면 1세트로만 검증한 규칙입니다.
후보값은 반드시 도면과 대조한 뒤 반영하고, 반영 후 해당 묶음을 다시 확정하세요. 그 밖의 값은 문자 검색으로 찾아 직접 입력합니다.</p>
<p class="muted">같은 표 값이 <code>expected</code>(검사 기준) 행과 <code>params</code>(모델 생성값) 행으로 따로 나옵니다.
둘을 함께 반영하면 표를 잘못 읽었을 때 모델과 검사가 같이 틀려 게이트가 통과하므로, params 행은 도면과 따로 대조한 뒤 선택하세요.</p>
{% 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 %}
{% if r.table_confirmed %}<br><b>표 값과도 일치</b>{% elif r.table is defined %}<br><b>표 값 {{ r.table }}와 다름 — 표를 따르세요</b>{% 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="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>
<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>
<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>
{% 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>
{% if rows %}
<form method="post" action="{{ url_for('candidates_apply', name=p.name) }}">
<div class="wrap"><table>
<tr><th>반영</th><th>대상</th><th>현재값</th><th>후보값</th><th>상태</th><th>근거</th></tr>
{% for r in rows %}
<tr><td>{% if not r.same %}<input type="checkbox" name="row" value="{{ loop.index0 }}">{% endif %}</td>
<td><code>{{ r.file }}.{{ r.path|join('.') }}</code></td>
<td><code>{{ dumps(r.current) }}</code></td><td><code>{{ dumps(r.candidate) }}</code></td>
<td>{% if r.same %}<span class="ok">일치</span>{% else %}<span class="warn">다름</span>{% endif %}</td>
<td>{{ r.src }}</td></tr>
{% endfor %}
</table></div>
<p><button>선택한 후보값 반영</button></p>
</form>
{% else %}<p class="muted">인식된 표가 없습니다.</p>{% endif %}
<h2>인식한 표 ({{ tables|length }})</h2>
{% for t in tables %}
<details><summary>{{ t.key }} · {{ t.anchor.dwg }} '{{ t.anchor.text }}' ({{ t.anchor.x }}, {{ t.anchor.y }}) · {{ t.rows|length }}행</summary>
<div class="wrap"><table>{% for r in t.rows %}<tr>{% for c in r.cells %}<td>{{ c.text }}</td>{% endfor %}</tr>{% endfor %}</table></div>
</details>
{% endfor %}
<h2>EL 표기 ({{ els|length }})</h2>
<p class="muted">EL 문자는 위치에 따라 뜻이 달라 자동 반영하지 않습니다.</p>
<table><tr><th>도면</th><th>레이어</th><th>X</th><th>Y</th><th>문자</th><th>mm</th></tr>
{% for e in els %}<tr><td>{{ e.dwg }}</td><td>{{ e.layer }}</td><td>{{ e.x }}</td><td>{{ e.y }}</td><td>{{ e.text }}</td><td>{{ e.value_mm }}</td></tr>{% endfor %}
</table>
{% endblock %}
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% from "_prism.html" import st_badge %}
{% block title %}일치 묶음 일괄 확정{% endblock %}
{% block body %}
<div class="prism">
<div class="stack">
<div class="stack" style="gap:var(--prism-space-100)">
<span class="eyebrow">입력 묶음 · 일괄 확정</span>
<h1 style="font-size:var(--prism-type-title);color:var(--prism-text-heading);margin:0">앱 확인 '일치' 묶음 일괄 확정</h1>
</div>
{% if targets %}
<section class="strip">
<p class="concl">일치 {{ targets|length }}개를 확정합니다. 주의·불일치·근거 없음·미확인 {{ rest|length }}개는 대상이 아닙니다.</p>
<p class="cap">근거표를 <b>끝까지 내려</b> 읽어야 확정할 수 있습니다. 확정 기록에는 입력한 이름만 남고, 앱은 확정하지 않습니다.</p>
</section>
<h3>근거표 <span class="cap muted">끝까지 내려 확인합니다</span></h3>
<div class="scroll" id="sc" tabindex="0" role="region" aria-label="일치 묶음 근거표" style="max-height:20rem">
<table class="t">
<caption style="position:absolute;left:-999rem">일치 묶음 근거</caption>
<thead><tr><th scope="col">묶음</th><th scope="col">근거</th><th scope="col">출처·위치</th></tr></thead>
<tbody>
{% for t in targets %}
{% set c = (t.chk.checks | selectattr('st', 'equalto', 'g') | selectattr('strength', 'in', ['S', 'F', 'C']) | list) %}
<tr><td><b>{{ t.file }}.{{ t.key }}</b></td><td>{% if c %}{{ c[0].id }} {{ c[0].text }}{% else %}{{ t.chk.summary }}{% endif %}</td>
<td>{% if c and c[0].pos %}<span class="chip">{{ c[0].pos }}</span>{% endif %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<details class="card sunken">
<summary style="min-height:2.75rem;display:flex;align-items:center;cursor:pointer;font-weight:700">제외된 묶음 {{ rest|length }}개 — 누르면 사유를 봅니다</summary>
<table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">앱 확인</th><th scope="col">사유</th></tr></thead><tbody>
{% for r in rest %}
<tr><td><a href="{{ url_for('evidence', name=p.name, which=r.file, key=r.key) }}">{{ r.file }}.{{ r.key }}</a></td><td>{{ st_badge(r.chk.st) }}</td><td>{{ r.chk.summary }}</td></tr>
{% endfor %}
</tbody></table>
</details>
<form method="post" id="mf" class="stack">
<div class="field"><label for="mby">확정자<span class="req">필수</span></label>
<input id="mby" type="text" name="by" required style="max-width:20rem" disabled>
<span class="help" id="hint">근거표를 끝까지 내리면 입력할 수 있습니다.</span></div>
<div class="row end"><a class="btn" href="{{ url_for('pier', name=p.name) }}">취소</a>
<button class="btn primary" id="mok" type="submit" disabled>일치 {{ targets|length }}개 확정</button></div>
</form>
<script>
(function () {
var sc = document.getElementById('sc'), by = document.getElementById('mby'), ok = document.getElementById('mok'), hint = document.getElementById('hint'), sent = false;
function done() {
if (sent) return; sent = true;
fetch({{ url_for('confirm_matched_read', name=p.name)|tojson }}, {method: 'POST'}).then(function () {
by.disabled = false; ok.disabled = false; hint.textContent = '근거표를 끝까지 읽었습니다. 이름을 입력하고 확정하세요.'; by.focus();
});
}
function chk() { if (sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 2) done(); }
sc.addEventListener('scroll', chk); chk();
})();
</script>
{% else %}
<section class="strip"><p class="concl">확정할 일치 묶음이 없습니다.</p>
<p class="cap">모든 일치 묶음이 확정되었거나, 앱 확인이 아직 '일치'인 묶음이 없습니다. 주의·불일치·근거 없음 묶음은 근거 보기에서 사유와 함께 하나씩 확정합니다.</p></section>
{% if rest %}<div class="tw"><table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">앱 확인</th><th scope="col">사유</th></tr></thead><tbody>
{% for r in rest %}<tr><td><a href="{{ url_for('evidence', name=p.name, which=r.file, key=r.key) }}">{{ r.file }}.{{ r.key }}</a></td><td>{{ st_badge(r.chk.st) }}</td><td>{{ r.chk.summary }}</td></tr>{% endfor %}
</tbody></table></div>{% endif %}
<div class="row"><a class="btn" href="{{ url_for('pier', name=p.name) }}">묶음 표로 돌아가기</a></div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% from "_prism.html" import st_badge, human_badge %}
{% block title %}근거 보기 {{ which }}.{{ key }}{% endblock %}
{% block body %}
<div class="prism">
<div class="stack">
<div class="stack" style="gap:var(--prism-space-100)">
<span class="eyebrow">입력 묶음 · 근거 보기</span>
<h1 style="font-size:var(--prism-type-title);color:var(--prism-text-heading);margin:0">{{ which }}.{{ key }}</h1>
</div>
<section class="strip" aria-label="결론">
<p class="concl">{{ st_badge(chk.st) }} {{ chk.summary }}</p>
{% if chk.stale %}<p class="cap">▲ 값이 바뀌어 이 결과는 낡았습니다. [전체 확인]으로 다시 확인하세요.</p>{% endif %}
<dl class="kv">
<dt>변수 등급</dt><dd>{% if st.info %}참고{% else %}{{ meta.grade }} {{ grades[meta.grade] }}{% endif %} <span class="muted">— {{ meta.note }}</span></dd>
<dt>값 출처(일반)</dt><dd>{{ meta.source|join(' · ') }}{% if meta.origin %} · <span class="b warn"><span class="m" aria-hidden="true">▲</span>템플릿 원본 그대로</span>{% endif %}</dd>
<dt>앱 확인</dt><dd>{{ st_badge(chk.st) }}</dd>
<dt>사람 확정</dt><dd>{{ human_badge(st) }} {% if st.confirmed %}<span class="muted">{{ st.by }} · {{ st.at }}{% if st.app_st %} · 확정 당시 앱 확인 {{ ST.get(st.app_st, st.app_st) }}{% endif %}</span>{% endif %}
{% if st.why %}<br><span class="muted">사유: {{ st.why }}</span>{% endif %}</dd>
</dl>
</section>
<div class="tw"><table class="t">
<caption>앱 확인 8종 (해당하는 것만 수행)</caption>
<thead><tr><th scope="col">검사</th><th scope="col">결과</th><th scope="col">무엇을 무엇과 비교했는가</th><th scope="col">출처·위치</th></tr></thead>
<tbody>
{% for c in chk.checks %}
<tr><td><b>{{ c.id }}</b> {{ c.name }}</td><td>{{ st_badge(c.st) }}</td><td>{{ c.text }}</td><td>{% if c.pos %}<span class="chip">{{ c.pos }}</span>{% endif %}</td></tr>
{% else %}
<tr><td colspan="4">앱 확인 전입니다. [전체 확인]을 누르세요.</td></tr>
{% endfor %}
</tbody>
</table></div>
<div class="banner tip"><span class="m" aria-hidden="true">◆</span>
<div>'일치'는 "독립 근거(도면 표·재료표 수량·템플릿)와 값이 같다"는 뜻이지 "도면이 맞다"는 뜻이 아닙니다. 앱은 확정하지 않으며, 근거를 읽은 사람이 확정합니다.
철근 위치·좌표와 표 밖 치수(EL·치수 사슬)는 아직 앱이 대조하지 못합니다(C단계 예정).</div></div>
{% if not st.info %}
<section class="card" aria-label="확정">
<h3>확정</h3>
<form method="post" action="{{ url_for('section_confirm', name=p.name, which=which, key=key) }}" class="stack">
<input type="hidden" name="next" value="{{ url_for('evidence', name=p.name, which=which, key=key) }}">
<div class="field"><label for="by">확정자<span class="req">필수</span></label>
<input id="by" type="text" name="by" required style="max-width:20rem"></div>
<div class="field"><label for="why">사유<span class="req">{% if chk.st in ('w', 'b', 'n') %}앱 확인이 '일치'가 아니므로 필수{% else %}선택{% endif %}</span></label>
<textarea id="why" name="why" rows="3" {% if chk.st in ('w', 'b', 'n') %}required{% endif %}></textarea>
<span class="help">무엇을 어떻게 확인했는지 적습니다(예: 도면 D1 정면도 EL 문자와 직접 대조).</span></div>
<div class="row"><button class="btn primary" type="submit">저장된 값으로 확정</button>
<a class="btn" href="{{ url_for('section', name=p.name, which=which, key=key) }}">값 편집</a>
<a class="btn quiet" href="{{ url_for('pier', name=p.name) }}">묶음 표로 돌아가기</a></div>
</form>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}게이트 · {{ p.name }}{% endblock %}
{% block body %}
{% if running %}<meta http-equiv="refresh" content="5">{% endif %}
<h1>게이트 G1 → G5</h1>
<p class="muted">게이트마다 G1부터 그 단계까지 새로 만들고, 저장한 파일을 다시 열어 검사합니다(재로드).
PASS 조건: 계획 FAIL 외 FAIL 0 · 수평/수직 음성 시험 검출 · 재로드 객체 수 일치 · 앞 게이트 객체 불변.
다음 게이트는 앞 게이트가 승인되어야 실행할 수 있습니다.{% if running %} <b>실행 중 — 5초마다 새로 고칩니다.</b>{% endif %}</p>
{% if out_of_scope %}<p class="muted">이 교각의 범위 밖 게이트: {{ out_of_scope|join(', ') }} (실행·승인 대상 아님)</p>{% endif %}
<h2>일괄 승인 (필요하면 실행)</h2>
{% if batch %}<p>최근 일괄 실행: <b class="{{ 'ok' if batch.state == '완료' else ('warn' if batch.state in ('실행 중', '중단됨') else 'fail') }}">{{ batch.state }}</b>
· 판정자 {{ batch.by }} · {% if batch.started %}{{ batch.started }} 시작 · {% endif %}{{ batch.at }}{% if batch.state != '실행 중' %} 끝{% endif %}</p>
{% if aborted %}<div class="flash fail" role="alert" id="batch-aborted">
<b>이 일괄 실행은 끝까지 가지 못했습니다.</b>
{% if batch.reason == 'error' %}모델을 만들거나 검사하는 도중 <b>오류</b>가 나서 멈췄습니다{% if batch.error %}: <code>{{ batch.error }}</code>{% endif %}.
{% elif batch.reason == 'fail' %}<b>{{ batch.gate }}</b>가 FAIL이라 승인하지 않고 멈췄습니다.
{% elif batch.reason == 'blocked' %}실행 조건이 맞지 않아 시작하지 못했습니다.
{% elif batch.reason == 'killed' %}서버가 멈추거나 다시 켜져서 끝나지 못했습니다.
{% else %}이유가 기록되지 않았습니다.{% endif %}
<ul>
<li>아래 게이트별 표에서 <b>"이전 실행 결과"</b>로 표시된 것은 이번 실행에서 갱신되지 않은 옛 결과입니다. 그 결과나 승인 기록을 이번 입력의 결과로 읽지 마세요.</li>
<li>중단되기 전에 계산된 <b>임시 결과</b>가 있으면 해당 게이트 표 위에 따로 표시합니다. 승인과 보고서에는 쓰이지 않습니다.</li>
<li>원인을 해결한 뒤 일괄 승인을 다시 실행하세요.</li>
</ul></div>{% endif %}
<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 결과가 있는 게이트는 바로 승인하고, 결과가 없으면 실행한 뒤 PASS일 때만 승인합니다. 진행할까요?');">
판정자 <input type="text" name="by" required>
<button {% if running %}disabled{% endif %}>{{ todo[0] if todo|length == 1 else todo[0] ~ '~' ~ todo[-1] }} 일괄 승인</button>
</form>
<p class="muted">최신 PASS 결과가 있는 게이트는 다시 실행하지 않고 바로 승인합니다. 결과가 없거나 입력이 바뀐 게이트는 실행한 뒤 PASS일 때만 승인합니다. FAIL·오류·실행 조건 미충족이면 그 게이트에서 멈추고 승인하지 않습니다.
승인 기록의 의견란에 '일괄 실행 자동 승인'이 남습니다. 결과는 아래 게이트별 표에서 확인하세요.</p>
{% else %}<p class="ok">모든 게이트가 승인되어 있습니다.</p>{% endif %}
<h2>검토보고서 (초안)</h2>
<form method="post" action="{{ url_for('report_make', name=p.name) }}">
작성자 <input type="text" name="by"> <button {% if running %}disabled{% endif %}>보고서 초안 생성 (MD + PDF)</button>
<a href="{{ url_for('report_get', name=p.name, kind='md') }}">MD 받기</a> · <a href="{{ url_for('report_get', name=p.name, kind='pdf') }}">PDF 받기</a>
</form>
<p class="muted">게이트 결과·도면 표·승인 기록으로 자동 작성합니다. 승인되지 않은 게이트가 있으면 '미완료'로 표시됩니다. 판정 확정과 3~5장 서술은 담당자가 합니다.</p>
{% for g in rows %}
<h2>{{ g.id }} {{ g.name }} —
{% if g.report %}{% if g.stale %}<span class="warn">입력 변경 · 재실행 필요</span>{% elif g.report.verdict == 'PASS' %}<span class="ok">PASS</span>{% else %}<span class="fail">FAIL</span>{% endif %}{% else %}<span class="muted">미실행</span>{% endif %}
{% if g.approved %}· <span class="ok">승인</span>{% elif g.approval_stale %}· <span class="warn">승인 기록은 이전 입력 것(무효)</span>{% elif g.approval %}· {{ g.approval.decision }}{% endif %}</h2>
{% if g.report %}<p class="muted">결과 시각 {{ g.report_at or '-' }}{% if g.report.mode == 'batch' %} · 일괄 실행{% endif %}
{% if g.older %}<b class="warn">이전 실행 결과 — 이번 일괄 실행에서 갱신되지 않았습니다</b>{% endif %}</p>{% endif %}
{% if g.show_partial %}{% set pr = g.partial %}<div class="flash warn" id="partial-{{ g.id }}"><b>중단된 실행의 임시 결과</b> ({{ pr.at }}) — 검사 {{ pr.passed }} / {{ pr.total }} PASS,
FAIL {{ pr.fails|length }}건{% if pr.fails %}: {{ pr.fails[:6]|join(' · ') }}{% if pr.fails|length > 6 %} 외 {{ pr.fails|length - 6 }}건{% endif %}{% endif %}.
계획된 예외는 구분하지 않은 검사기 원본 결과이며 승인·보고서에는 쓰이지 않습니다.</div>{% 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 %}
{% if g.blockers %}<p>실행 조건 미충족: {% for b in g.blockers %}<span class="fail">{{ b }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</p>
{% else %}<form method="post" action="{{ url_for('gate_run', name=p.name, gate=g.id) }}"><button {% if running %}disabled{% endif %}>{{ g.id }} 실행</button></form>{% endif %}
{% if g.report %}{% set r = g.report %}
<table>
<tr><th>검사</th><td>{{ r.summary.passed }} / {{ r.summary.total }} PASS ({{ r.checker }})</td></tr>
<tr><th>미계획 FAIL</th><td>{% if r.unplanned_fails %}<span class="fail">FAIL {{ r.unplanned_fails|length }}건</span><ul>{% for f in r.unplanned_fails %}<li>{{ f }}</li>{% endfor %}</ul>{% else %}<span class="ok">0건</span>{% endif %}</td></tr>
<tr><th>계획 FAIL (승인된 예외)</th><td>{% if r.planned_fails %}<span class="warn">계획 FAIL {{ r.planned_fails|length }}건</span><ul>{% for f in r.planned_fails %}<li>{{ f.item }} — {{ f.reason }}</li>{% endfor %}</ul>{% else %}0건{% endif %}</td></tr>
<tr><th>음성 시험</th><td>{% for k, v in r.negative.items() %}{{ '수평' if k == 'negH' else '수직' }} {{ v.move }} →
{% if v.detected %}<span class="ok">검출</span> ({{ v.new_fails|join(', ') }}){% else %}<span class="fail">미검출</span>{% endif %}<br>{% endfor %}</td></tr>
<tr><th>재로드</th><td>{% if r.reload.ok is none %}<span class="muted">생략</span> {{ r.reload.skipped }}{% elif r.reload.ok %}<span class="ok">일치</span> 생성 {{ r.reload.built }} / 다시 연 파일 {{ r.reload.reopened }}{% else %}<span class="fail">불일치</span> 생성 {{ r.reload.built }} / 다시 연 파일 {{ r.reload.reopened }}{% endif %}</td></tr>
<tr><th>앞 게이트 객체 불변</th><td>{% if r.unchanged.ok is none %}<span class="muted">생략</span> {{ r.unchanged.skipped }}{% elif r.unchanged.ok %}<span class="ok">불변</span> {{ r.unchanged.compared }}개 비교{% if r.unchanged.against %} (기준 {{ r.unchanged.against }}){% endif %}{% else %}<span class="fail">변경</span> {{ r.unchanged.compared }}개 비교{% endif %}</td></tr>
<tr><th>모델</th><td>{% if r.model %}<a href="{{ url_for('download', name=p.name, gate=g.id) }}">{{ p.name }}_{{ g.id }}.3dm 내려받기</a>{% else %}<span class="muted">일괄 실행 — 중간 모델 저장 안 함 (G5 모델에 포함)</span>{% endif %} · {{ r.secs }}초{% if r.mode == 'batch' %} (일괄 세션 {{ r.session_secs }}초){% endif %}</td></tr>
</table>
{% if not g.stale %}
<form method="post" action="{{ url_for('gate_decide', name=p.name, gate=g.id) }}">
판정자 <input type="text" name="by" required> 의견 <input type="text" name="note" size="40" placeholder="반려 시 필수">
<button name="decision" value="승인" {% if r.verdict != 'PASS' %}disabled{% endif %}>승인</button>
<button name="decision" value="반려">반려</button>
</form>{% endif %}
{% if g.approval %}<p class="muted">최근 판정: {{ g.approval.decision }} · {{ g.approval.by }} · {{ g.approval.at }}{% if g.approval.note %} · {{ g.approval.note }}{% endif %}</p>{% endif %}
{% endif %}
{% endfor %}
{% endblock %}
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}도면에서 읽기 · {{ p.name }}{% endblock %}
{% block body %}
<h1>도면에서 읽기</h1>
<p class="muted">한 화면에서 <b>템플릿 원본 값을 모두 도면 값으로</b> 바꿉니다. 운곡교 P1 도면 틀(P1~P4)의 첫 번째 도면(D1)과 철근재료표에서 코핑·기초·말뚝·기둥·받침·철근 값을 읽어 <code>params</code>(모델 생성값)와 <code>expected</code>(검사 기준)의 현재 값과 비교합니다.
읽기 규칙은 모두 완성된 교각(P1)에서 같은 값이 재현되는지 먼저 확인한 것입니다(<code>tools/test_geomread.py</code>). 이 도구는 아무것도 자동으로 바꾸지 않고, 고른 줄만 반영합니다. 반영한 묶음은 다시 확정해야 합니다.</p>
{% if running %}<p class="warn">게이트 실행 중에는 값을 바꿀 수 없습니다.</p>{% endif %}
{% if state != 'ok' %}
<p class="fail">{{ msg }}</p>
{% else %}
<p class="muted">{{ msg }}{% if msg %} · {% endif %}현재 값과 다른 줄 {{ n }}건</p>
<div class="flash warn">params와 expected를 같은 도면 읽기에서 채우면 둘이 서로 같은 것은 <b>독립 근거가 아닙니다</b>(앱 확인 C9). 받침 표·솔플레이트 표·재료표 값(아래 "표 값" 구역)도 이 화면에 함께 나오며, 같은 값을 <a href="{{ url_for('candidates', name=p.name) }}">표 후보값</a> 화면에서 따로 골라 반영할 수도 있습니다.
추론 줄(예: 좌측 스터럽 윗면)은 기본으로 선택되어 있지 않습니다.</div>
{% if notes %}<h2>확인이 필요한 점</h2><ul>{% for t in notes %}<li>{{ t }}</li>{% endfor %}</ul>{% endif %}
{% if not n %}<p class="ok">읽은 값이 모두 현재 값과 같습니다(1.5 mm 반올림 안).</p>
{% else %}
<form method="post" action="{{ url_for('geomread_apply', name=p.name) }}">
{% for g, items in groups.items() %}
<h2>{{ g }} ({{ items|length }}건)</h2>
<div class="wrap"><table>
<thead><tr><th>반영</th><th>대상</th><th>현재</th><th>읽은 값</th><th>근거</th></tr></thead>
<tbody>{% for i, c in items %}
<tr><td><input type="checkbox" name="row" value="{{ i }}" {% if not c.inferred %}checked{% endif %} aria-label="{{ c.file }}:{{ c.path }} 반영"></td>
<td><code>{{ c.file }}:{{ c.path }}</code></td>
<td>{{ brief(c.old)|truncate(60) }}</td><td><b>{{ brief(c.new)|truncate(60) }}</b></td>
<td>{{ c.why }}{% if c.inferred %} <span class="warn">추론</span>{% endif %}</td></tr>{% endfor %}
</tbody></table></div>
{% endfor %}
<p>반영자 <input type="text" name="by" required> <button {% if running %}disabled{% endif %}>선택한 줄 반영</button></p>
<p class="muted">반영 전에 <code>params.json</code>·<code>expected.json</code>·<code>confirm.json</code>을 <code>backup/before_geomread_…</code>에 보관하고, 반영 기록은 <code>read_log.json</code>에 남깁니다.</p>
</form>
{% endif %}
{% endif %}
{% if log %}<h2>최근 반영 기록</h2><ul>{% for e in log %}<li>{{ e.at }} · {{ e.by or '-' }} · {{ e.count }}건 · {{ e.backup }}</li>{% endfor %}</ul>{% endif %}
{% endblock %}
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block body %}
<h1>교각 목록</h1>
<table>
<tr><th>교각</th><th>형식</th><th>만든 날</th><th>템플릿</th><th>삭제</th></tr>
{% for p in piers %}
<tr><td><a href="{{ url_for('pier', name=p.name) }}">{{ p.name }}</a></td><td>{{ p.info.type }}</td>
<td>{{ p.info.created }}</td><td>{{ p.info.template or '-' }}</td>
<td><form method="post" action="{{ url_for('pier_delete', name=p.name) }}" style="margin:0"
onsubmit="return confirm('교각 \'{{ p.name }}\'을(를) 삭제합니다. 도면·입력·결과가 모두 휴지통(data/_휴지통)으로 옮겨집니다. 계속할까요?');">
<input type="text" name="confirm_name" placeholder="교각 이름 입력" size="12" required>
<button>삭제</button></form></td></tr>
{% endfor %}
</table>
<p class="muted">삭제하려면 해당 줄의 칸에 교각 이름을 똑같이 입력하고 [삭제]를 누르세요. 삭제한 교각은 data/_휴지통 폴더로 옮겨지며, 폴더를 다시 옮기면 복구됩니다. 게이트 실행 중인 교각은 삭제할 수 없습니다.</p>
<h2>도면으로 템플릿 추천</h2>
<form method="post" action="{{ url_for('recommend_upload') }}" enctype="multipart/form-data">
교각 도면(DWG, 여러 장) <input type="file" name="dwg" accept=".dwg" multiple required>
<button>도면 읽고 추천 받기</button>
</form>
<p class="muted">도면을 먼저 올리면 가장 비슷한 템플릿을 순위로 보여 줍니다. 추천은 제안이며, 아래 6문항을 사람이 확인한 뒤 교각을 만듭니다. 도면을 올리지 않고 만들려면 아래 '새 교각'을 쓰세요.</p>
<h2>새 교각</h2>
<form method="post" action="{{ url_for('pier_new') }}">
이름 <input type="text" name="name" required>
형식 <select name="type">{% for t in types %}<option>{{ t }}</option>{% endfor %}</select>
템플릿 <select name="template">
{% if templates %}<optgroup label="보호 템플릿">{% for t in templates %}<option value="{{ tpl }}{{ t.name }}">{{ t.name }}</option>{% endfor %}</optgroup>{% endif %}
<optgroup label="기존 교각 복사">{% for p in piers %}<option value="{{ p.name }}">{{ p.name }}</option>{% endfor %}</optgroup>
</select>
<button>만들기</button>
</form>
<p class="muted">1차 대상은 T형 교각뿐입니다. 다른 형식은 만들 수 없습니다.
템플릿의 매개변수·기대값이 복사되므로, 새 도면 값으로 모든 묶음을 확인·수정한 뒤 확정해야 게이트를 실행할 수 있습니다.</p>
{% if templates %}
<h2>보호 템플릿</h2>
<table>
<tr><th>템플릿</th><th>형식 설명</th><th>모델링 게이트</th><th>원본 교각</th><th>저장일</th></tr>
{% for t in templates %}
<tr><td>{{ t.name }}</td><td>{{ t.desc }}</td><td>{{ t.scope | join(', ') }}</td><td>{{ t.source }}</td><td>{{ t.created }}</td></tr>
{% endfor %}
</table>
<p class="muted">보호 템플릿은 data/_템플릿 폴더에 고정 사본으로 저장되어, 원본 교각을 고치거나 삭제해도 바뀌지 않습니다.
앱 화면에서는 고칠 수 없습니다. 형식이 같은 교각이면 보호 템플릿에서 시작하는 것을 권장합니다.</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}{{ p.name }}{% endblock %}
{% block body %}
<h1>{{ p.name }} <span class="muted">({{ p.info.type }})</span></h1>
<h2>1. 도면</h2>
{% if audit %}
<table><tr><th>구분</th><th>DWG</th><th>DXF</th><th>DWG SHA-256</th></tr>
{% for a in audit %}<tr><td>{{ a.label }}</td><td>{{ a.source }}</td><td>{{ a.dxf }}</td><td><code>{{ a.source_sha256[:16] }}…</code></td></tr>{% endfor %}
</table>
<p>색인 문자 {{ ntexts }}개 · <a href="{{ url_for('search', name=p.name) }}">문자 검색</a> · <a href="{{ url_for('candidates', name=p.name) }}">표 후보값</a> · <a href="{{ url_for('geomread_page', name=p.name) }}">도면에서 읽기</a></p>
{% else %}<p class="muted">올린 도면이 없습니다.</p>{% endif %}
<form method="post" action="{{ url_for('upload', name=p.name) }}" enctype="multipart/form-data">
<input type="file" name="dwg" accept=".dwg" multiple> <button>올리기·변환</button>
<span class="muted">파일 이름 순서대로 D1, D2, …로 부릅니다. 다시 올리면 색인을 새로 만듭니다.</span>
</form>
<h2>2. 입력 묶음 확인·확정</h2>
<!-- 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>
<a class="btn" href="{{ url_for('geomread_page', name=p.name) }}">도면에서 읽기</a>
<a class="btn primary" href="{{ url_for('review', name=p.name) }}">검토 요약으로 확정</a>
{% if nmatch %}<a class="btn" href="{{ url_for('confirm_matched', name=p.name) }}">일치 묶음만 확정 ({{ nmatch }}개)</a>{% endif %}
</div>
<p class="cap">앱 확인은 도면 표·재료표 수량·고정 항목을 앱이 대조한 결과이며 <b>앱은 확정하지 않습니다</b>. '일치'는 "독립 근거와 값이 같다"는 뜻이지 "도면이 맞다"는 뜻이 아닙니다.
<b>변수 등급</b> V1 값 · V2 목록 · V3 생성(화면 편집 차단) · F 고정(바꾸면 사유 필요).</p>
</section>
{% 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"><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 %}<p class="muted">게이트에 필요한 묶음 {{ need|length }}개가 아직 확정되지 않았습니다. 앱 확인이 '일치'인 묶음은 위의 [일치 묶음 일괄 확정]으로, 나머지는 '근거 보기'에서 사유와 함께 하나씩 확정합니다.</p>
{% else %}<p class="ok">게이트에 필요한 묶음이 모두 확정되어 있습니다.</p>{% endif %}
<h2>3. 게이트</h2>
{% if out_of_scope %}<p class="muted">범위 밖: {{ out_of_scope|join(', ') }} — 이 교각에서는 모델링하지 않습니다.</p>{% endif %}
<table><tr><th>게이트</th><th>내용</th><th>판정</th><th>승인</th></tr>
{% for g in gates %}
<tr><td>{{ g.id }}</td><td>{{ g.name }}</td>
<td>{% if g.report %}{% if g.stale %}<span class="warn">입력 변경 · 재실행 필요</span>{% elif g.report.verdict == 'PASS' %}<span class="ok">PASS</span>{% else %}<span class="fail">FAIL</span>{% endif %}{% else %}<span class="muted">미실행</span>{% endif %}</td>
<td>{% if g.approved %}<span class="ok">승인</span>{% elif g.approval %}{{ g.approval.decision }}{% else %}<span class="muted">-</span>{% endif %}</td></tr>
{% endfor %}
</table>
{% if running %}<p class="warn">실행 중입니다{% if batch and batch.log %} — {{ batch.log[-1] }}{% endif %}.
<a href="{{ url_for('gates', name=p.name) }}">게이트 화면에서 진행 보기</a></p>
{% elif todo %}
<form method="post" action="{{ url_for('gates_run_all', name=p.name) }}"
onsubmit="return confirm('{{ todo[0] }}~{{ todo[-1] }}를 차례로 승인합니다. 최신 PASS 결과가 있는 게이트는 바로 승인하고, 결과가 없으면 실행한 뒤 PASS일 때만 승인합니다. 진행할까요?');">
판정자 <input type="text" name="by" required>
<button {% if need %}disabled title="먼저 묶음을 확정하세요"{% endif %}>{{ todo[0] if todo|length == 1 else todo[0] ~ '~' ~ todo[-1] }} 일괄 승인</button>
<span class="muted">{% if need %}먼저 위의 묶음을 확정하세요. {% endif %}최신 PASS 결과가 있으면 바로 승인하고, 없으면 실행한 뒤 PASS일 때만 승인합니다. FAIL·오류가 나면 멈춥니다. 진행은 게이트 화면에서 보입니다.</span>
</form>
{% else %}<p class="ok">모든 게이트가 승인되어 있습니다.</p>{% endif %}
{% if batch and not running %}<p class="muted">최근 일괄 실행: {{ batch.state }} · {{ batch.by }} · {{ batch.at }}{% if batch.log %} — {{ batch.log[-1] }}{% endif %}</p>{% endif %}
<p><a href="{{ url_for('gates', name=p.name) }}">게이트 화면으로</a> (게이트별 결과·개별 실행·보고서)</p>
{% endblock %}
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block body %}
<h1>도면으로 템플릿 추천</h1>
<p class="muted">도면 {{ nfiles }}장, 문자 {{ ntexts }}개를 읽었습니다. 아래 추정은 <b>제안</b>이며 확정이 아닙니다. 맞지 않으면 아래 6문항에서 바꾸세요.</p>
<h2>1. 추천 순위</h2>
{% set asked = rank[0].asked if rank else 0 %}
{% if rank and rank[0].matched < asked %}
<p class="warn">▲ 판단 불가: 추정한 {{ asked }}문항이 모두 맞는 템플릿이 없습니다. 가장 가까운 템플릿과 다른 문항을 확인하고, 필요하면 생성기·검사기를 추가해야 합니다.</p>
{% endif %}
<table>
<tr><th>순위</th><th>템플릿</th><th>일치</th>{% for q, label in questions %}<th>{{ label }}</th>{% endfor %}</tr>
{% for r in rank %}
<tr><td>{{ loop.index }}</td><td>{{ r.name }}</td><td>{{ r.matched }} / {{ r.asked }}</td>
{% for q, label in questions %}<td>{{ r.profile[q] }}{% if q in r.hits %} {% if r.hits[q] %}<span class="ok">● 일치</span>{% else %}<span class="fail">■ 다름</span>{% endif %}{% endif %}</td>{% endfor %}</tr>
{% endfor %}
</table>
<h2>2. 추정 근거</h2>
<table>
<tr><th>문항</th><th>추정</th><th>근거(도면 위치)</th><th>신뢰</th></tr>
{% for q, label in questions %}{% set e = est[q] %}
<tr><td>{{ label }}</td><td>{% if e[0] is none %}<span class="warn">▲ 판단 불가</span>{% else %}{{ e[0] }}{% endif %}</td><td>{{ e[1] }}</td><td>{{ e[2] or '-' }}</td></tr>
{% endfor %}
</table>
<p class="muted">코핑 하면 형상은 정면도의 큰 R 문자(10 m 이상 = 곡선)만 보며, 작은 R(평면 모서리 등)은 제외합니다. 기초 형식은 '말뚝' 문자 유무로 정하므로, 도면에 말뚝 문구가 없는데 말뚝이 있는 경우는 틀립니다. 평면의 사각·직각과 각도는 문항이 아니라 값이라 여기서 묻지 않습니다.</p>
<h2>3. 6문항 확인 후 교각 만들기</h2>
<form method="post" action="{{ url_for('recommend_create', sid=sid) }}">
<table>
{% for q, label in questions %}
<tr><th>{{ label }}</th><td><input type="text" name="ans_{{ q }}" value="{{ '' if est[q][0] is none else est[q][0] }}" required></td></tr>
{% endfor %}
<tr><th>철근 형상 종류가 템플릿의 재료표 형식 열과 같은가</th><td><select name="ans_rebar_shape"><option>미확인</option><option>예</option><option>아니오</option></select></td></tr>
<tr><th>철근 생성 방식(규칙 값 / 철근별 중심선)이 같은가</th><td><select name="ans_rebar_method"><option>미확인</option><option>예</option><option>아니오</option></select></td></tr>
</table>
<h3>각도 값 확인 <span class="muted">(템플릿을 가르지 않는 값. 도면에서 읽어 입력하며 비워도 됩니다)</span></h3>
<table>
<tr><th>코핑 평면 skew (°)</th><td><input type="text" name="ans_skew_deg" size="8"></td>
<th>받침 회전 (°)</th><td><input type="text" name="ans_bearing_rot_deg" size="8"></td>
<th>기초 회전 (°)</th><td><input type="text" name="ans_footing_rot_deg" size="8"></td></tr>
</table>
<h3>결과 판정</h3>
<p id="verdict" class="muted" role="status">템플릿과 답을 고르면 판정이 나옵니다.</p>
<p>이름 <input type="text" name="name" required>
형식 <select name="type"><option>T형</option></select>
템플릿 <select name="template">{% for r in rank %}<option value="{{ tpl }}{{ r.name }}">{{ loop.index }}위 {{ r.name }}</option>{% endfor %}</select>
<button id="mk">교각 만들기</button></p>
</form>
<p class="muted">[교각 만들기]를 누를 때 교각 폴더가 만들어지고 올린 도면이 그 안으로 옮겨집니다. 6문항 답은 교각 정보(pier.json)에 저장됩니다.
'다름'이 있는데 진행하면 형식이 다른 도면을 같은 템플릿에 넣는 것이므로, 만든 뒤 모든 묶음을 특히 주의해서 확인하세요.</p>
<script>
(function () {
var PROF = {{ profiles | tojson }}, QS = {{ questions | map(attribute=0) | list | tojson }}, LAB = {{ questions | map(attribute=1) | list | tojson }};
var f = document.querySelector('form'), v = document.getElementById('verdict'), mk = document.getElementById('mk');
function judge() {
var t = f.template.value.replace('{{ tpl }}', ''), pr = PROF[t], bad = [];
QS.forEach(function (q, i) { if (String(pr[q]) !== f['ans_' + q].value.trim()) bad.push(LAB[i]); });
if (f.ans_rebar_shape.value === '아니오') bad.push('철근 형상 종류');
if (f.ans_rebar_method.value === '아니오') bad.push('철근 생성 방식');
var unk = (f.ans_rebar_shape.value === '미확인' ? 1 : 0) + (f.ans_rebar_method.value === '미확인' ? 1 : 0);
if (bad.length) { v.className = 'fail'; v.textContent = '■ 같은 템플릿으로 만들 수 없음 — 다른 문항: ' + bad.join(', ') + '. 가장 가까운 템플릿에 생성기·검사기를 추가해야 합니다. 진행하면 형식 불일치 경고가 교각에 계속 표시됩니다.'; mk.textContent = '그래도 만들기'; }
else { v.className = 'ok'; v.textContent = '● 이 템플릿으로 만들 수 있음' + (unk ? ' (철근 문항 ' + unk + '개는 미확인)' : ''); mk.textContent = '교각 만들기'; }
}
f.addEventListener('input', judge); f.addEventListener('change', judge); judge();
})();
</script>
{% endblock %}
@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% from "_prism.html" import st_badge %}
{% block title %}검토 요약 확정 · {{ p.name }}{% endblock %}
{% block body %}
<div class="prism">
<div class="stack">
<div class="stack" style="gap:var(--prism-space-100)">
<span class="eyebrow">입력 묶음 · 검토 요약 확정</span>
<h1 style="font-size:var(--prism-type-title);color:var(--prism-text-heading);margin:0">{{ p.name }} — 한 화면에서 검토하고 한 번에 확정</h1>
</div>
{% if not items %}
<section class="strip"><p class="concl">확정이 필요한 묶음이 없습니다.</p>
<p class="cap">필요한 묶음이 모두 확정되어 있습니다. 값을 고치면 해당 묶음만 다시 여기에 나타납니다.</p></section>
<div class="row"><a class="btn" href="{{ url_for('pier', name=p.name) }}">교각 화면으로</a></div>
{% else %}
<section class="strip">
<p class="concl">확정이 필요한 묶음 {{ items|length }}개 — 일치 {{ cnt.A }} · 주의·근거 없음 {{ cnt.B }} · 결정 기록 {{ cnt.D }} · 불일치 {{ cnt.C }}</p>
<p class="cap">앱이 <b>무엇이 바뀌었는지</b>, <b>앱이 무엇을 확인했는지</b>, <b>앱이 확인하지 못한 것</b>을 정리했습니다. 확정은 사람의 기록이므로 <b>도면과 대조한 묶음에 직접 체크</b>해야 하고,
체크하지 않은 묶음은 미확정으로 남습니다. 사유는 앱이 아는 사실(앱 확인·변경·출처)과 아래 의견으로 자동 기록됩니다.</p>
</section>
<form method="post" id="rf" class="stack">
{% if by_group.C %}
<h2>불일치 {{ cnt.C }}개 — 확정할 수 없습니다</h2>
<div class="banner dont" role="alert"><span class="m" aria-hidden="true">■</span><div>값을 고치거나 도면과 다른 이유를 해결한 뒤 다시 확인하세요(묶음을 열어 고치면 이 화면에 다시 나타납니다).</div></div>
<div class="tw"><table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">앱 확인</th><th scope="col">불일치 내용</th></tr></thead><tbody>
{% for i in by_group.C %}<tr><td><a href="{{ url_for('evidence', name=p.name, which=i.file, key=i.key) }}">{{ i.id }}</a></td><td>{{ st_badge(i.st) }}</td><td>{{ i.summary }}</td></tr>{% endfor %}
</tbody></table></div>
{% endif %}
{% if by_group.B %}
<h2>주의·근거 없음 {{ cnt.B }}개 — 도면과 대조한 것에 체크</h2>
<div class="row"><button class="btn" type="button" id="all-on" data-flagged="{{ by_group.B|selectattr('flags')|list|length + by_group.D|selectattr('flags')|list|length }}">주의·근거 없음·결정 기록 모두 도면과 대조함으로 체크</button>
<button class="btn quiet" type="button" id="all-off">모두 해제</button></div>
<p class="cap">앱이 독립적으로 대조하지 못했거나 일부만 확인한 묶음입니다. 아래 "앱이 확인하지 못한 것"을 도면과 직접 대조한 뒤 체크하세요. 체크 하나가 그 묶음의 확정 기록입니다.</p>
<div class="tw"><table class="t"><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 i in by_group.B %}
<tr><td><input type="checkbox" name="ack" value="{{ i.id }}" aria-label="{{ i.id }} 도면과 대조함" style="width:1.4rem;height:1.4rem"></td>
<td><b><a href="{{ url_for('evidence', name=p.name, which=i.file, key=i.key) }}">{{ i.id }}</a></b><br>{{ st_badge(i.st) }}{% if i.stale %} <span class="cap muted">변경됨(이전 확정 {{ i.prev[0] }})</span>{% endif %}</td>
<td>{{ i.change.text }}{% for fl in i.flags %}<br><span class="b warn"><span class="m" aria-hidden="true">▲</span>{{ fl }}</span>{% endfor %}</td>
<td>{% if i.unverified %}<ul style="margin:0;padding-left:1.1rem">{% for u in i.unverified[:3] %}<li>{{ u }}</li>{% endfor %}</ul>{% else %}{{ i.summary }}{% endif %}</td>
<td>{% if i.prov %}{% for pv in i.prov %}<span class="chip">{{ pv }}</span> {% endfor %}{% else %}<span class="cap muted">기록 없음</span>{% endif %}</td></tr>
{% endfor %}
</tbody></table></div>
{% endif %}
{% if by_group.D %}
<h2>사람 결정 기록 {{ cnt.D }}개 — 내용을 읽고 체크</h2>
<div class="tw"><table class="t"><thead><tr><th scope="col">읽음</th><th scope="col">묶음</th><th scope="col">내용</th></tr></thead><tbody>
{% for i in by_group.D %}
<tr><td><input type="checkbox" name="ack" value="{{ i.id }}" aria-label="{{ i.id }} 읽었음" style="width:1.4rem;height:1.4rem"></td>
<td><b>{{ i.id }}</b><br>{{ st_badge(i.st) }}</td>
<td>{% if i.detail %}<ul style="margin:0;padding-left:1.1rem">{% for d in i.detail %}<li>{{ d }}</li>{% endfor %}</ul>
<p class="cap muted">계획된 FAIL(승인된 예외)은 도면과 재료표의 알려진 차이를 받아들이는 사람의 결정입니다. 설계자 확인 전 임시 등록은 사유에 그렇게 적혀 있어야 합니다.</p>
{% else %}{{ i.change.text }}{% endif %}{% for fl in i.flags %}<br><span class="b warn"><span class="m" aria-hidden="true">▲</span>{{ fl }}</span>{% endfor %}</td></tr>
{% endfor %}
</tbody></table></div>
{% endif %}
{% if by_group.A %}
<h2>앱 확인 일치 {{ cnt.A }}개 — 근거를 훑어보세요</h2>
<div class="tw"><table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">바뀐 것</th><th scope="col">앱이 확인한 것</th></tr></thead><tbody>
{% for i in by_group.A %}
{% set c = (i.checks | selectattr('st', 'equalto', 'g') | selectattr('strength', 'in', ['S', 'F', 'C']) | list) %}
<tr><td><b><a href="{{ url_for('evidence', name=p.name, which=i.file, key=i.key) }}">{{ i.id }}</a></b></td><td>{{ i.change.text }}</td>
<td>{% if c %}{{ c[0].id }} {{ c[0].text }}{% if c[0].pos %} <span class="chip">{{ c[0].pos }}</span>{% endif %}{% else %}{{ i.summary }}{% endif %}</td></tr>
{% endfor %}
</tbody></table></div>
<label class="row" style="gap:.5rem"><input type="checkbox" name="ack_matched" value="1" style="width:1.4rem;height:1.4rem"> 일치 {{ cnt.A }}개의 근거를 읽었습니다(앱이 독립 근거와 같다고 확인한 묶음입니다)</label>
{% endif %}
<h2>확정</h2>
<div class="field"><label for="rby">확정자<span class="req">필수</span></label>
<input id="rby" type="text" name="by" required style="max-width:20rem" disabled>
<span class="help" id="rhint">목록을 끝까지 내리면 입력할 수 있습니다.</span></div>
<div class="field"><label for="rnote">공통 의견 (선택)</label>
<input id="rnote" type="text" name="note" style="max-width:40rem" placeholder="예: 운곡교 P5 도면(2026-10 개정)과 대조" disabled>
<span class="help">모든 확정 기록의 사유 끝에 붙습니다. 앱이 쓰는 사유(앱 확인·변경·출처)는 따로 자동으로 남습니다.</span></div>
<div class="row end"><a class="btn" href="{{ url_for('pier', name=p.name) }}">취소</a>
<button class="btn primary" id="rok" type="submit" disabled>선택한 묶음 확정</button></div>
<div id="rend" aria-hidden="true" style="height:1px"></div>
</form>
<script>
(function () {
var on = document.getElementById('all-on'), off = document.getElementById('all-off');
function boxes() { return document.querySelectorAll('input[name=ack]'); }
if (on) on.addEventListener('click', function () {
var n = parseInt(on.getAttribute('data-flagged') || '0', 10);
if (n && !confirm('주의 표시가 붙은 묶음 ' + n + '개(템플릿 원본 그대로이거나 설계자 확인 전 임시 등록)가 포함됩니다. 모두 도면과 대조했습니까?')) return;
boxes().forEach(function (b) { b.checked = true; });
});
if (off) off.addEventListener('click', function () { boxes().forEach(function (b) { b.checked = false; }); });
})();
(function () {
var end = document.getElementById('rend'), by = document.getElementById('rby'), note = document.getElementById('rnote'), ok = document.getElementById('rok'), hint = document.getElementById('rhint'), sent = false;
function done() {
if (sent) return; sent = true;
fetch({{ url_for('review_read', name=p.name)|tojson }}, {method: 'POST'}).then(function () {
by.disabled = false; note.disabled = false; ok.disabled = false; hint.textContent = '목록을 끝까지 읽었습니다. 이름을 입력하고 확정하세요.'; by.focus();
});
}
if ('IntersectionObserver' in window) { new IntersectionObserver(function (e) { if (e[0].isIntersecting) done(); }).observe(end); } else { done(); }
})();
</script>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}문자 검색 · {{ p.name }}{% endblock %}
{% block body %}
<h1>도면 문자 검색</h1>
{% if not audit %}<p class="fail">도면이 없습니다. 교각 화면에서 DWG를 먼저 올리세요.</p>{% endif %}
<form>
<input type="text" name="q" value="{{ q }}" size="40" placeholder="예: EL. 26.208, 9.350, @250">
<label><input type="checkbox" name="regex" value="1" {% if regex %}checked{% endif %}> 정규식</label>
<button>검색</button>
</form>
{% if err %}<p class="fail">{{ err }}</p>{% endif %}
{% if q %}<p>{{ hits|length }}건{% if hits|length >= 200 %} (최대 200건만 표시){% endif %}</p>
<div class="wrap"><table>
<tr><th>도면</th><th>레이어</th><th>X</th><th>Y</th><th>글자 높이</th><th>문자</th></tr>
{% for t in hits %}<tr><td>{{ t.dwg }}</td><td>{{ t.layer }}</td><td>{{ t.x }}</td><td>{{ t.y }}</td><td>{{ t.h }}</td><td>{{ t.text }}</td></tr>{% endfor %}
</table></div>{% endif %}
<h2>핸들 조회</h2>
<p class="muted">CAD에서 확인한 객체 핸들(예: 21A76)로 선·폴리선·호·원·문자·블록의 좌표·길이·반지름을 봅니다. 값은 자동 반영하지 않습니다.</p>
<form>
<select name="dwg">{% for a in audit %}<option value="{{ a.label }}" {% if a.label == hdwg %}selected{% endif %}>{{ a.label }} · {{ a.source }}</option>{% endfor %}</select>
<input type="text" name="h" value="{{ h }}" size="10" placeholder="핸들">
<button>조회</button>
</form>
{% if ent %}<p>{{ hdwg }} · <code>{{ ent.handle }}</code> · {{ ent.type }} · 레이어 {{ ent.layer }}</p>
<div class="wrap"><table>{% for k, v in ent['values'].items() %}<tr><th>{{ k }}</th><td><code>{{ dumps(v) }}</code></td></tr>{% endfor %}</table></div>{% endif %}
<p class="muted">좌표는 도면(DXF) 좌표이며 모델 좌표가 아닙니다. 이 도면 세트에서는 치수가 치수 객체가 아니라 문자로 그려져 있어 문자 검색으로 찾습니다.</p>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}{{ which }}.{{ key }}{% endblock %}
{% block body %}
<h1>{{ which }}.{{ key }}
{% if st.confirmed %}<span class="ok">확정</span> <span class="muted">{{ st.by }} · {{ st.at }}</span>
{% elif st.info %}<span class="muted">확정 불필요(참고)</span>
{% elif st.stale %}<span class="warn">변경됨 · 재확정 필요</span>{% else %}<span class="muted">미확정</span>{% endif %}</h1>
<!-- 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' %}검사 기대값입니다. 생성용 params와 따로, 도면 치수 문자·표에서 직접 옮긴 값이어야 합니다(독립성).{% else %}모델 생성용 매개변수입니다. 단위 mm, Z = EL × 1000, 교각 중심 원점(X 교축직각, Y 교축).{% endif %}
값은 JSON으로 편집합니다. 최상위 형식(객체·목록·숫자 등)은 바꿀 수 없습니다.</p>
<p class="muted">위치 목록은 치수 사슬로 입력할 수 있습니다: <code>{"chain": "-4575, 200, 2@125, 30@250"}</code>
— 첫 값은 시작 위치, 나머지는 증분이며 <code>n@p</code>는 간격 p를 n번 반복합니다. 저장하면 숫자 목록으로 펼쳐집니다.</p>
<form method="post">
<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>
</form>
<form method="post" action="{{ url_for('section_confirm', name=p.name, which=which, key=key) }}">
확정자 <input type="text" name="by" required>
<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>
</form>
{% endblock %}