Files
Pier_v1/Code/교각모델링앱/web/store.py
T
ahnyongjooandClaude Sonnet 5.5 964a841de1 chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환
- Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용
- pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관)
- worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md)
- D:\Rhino-MCP 경로 참조를 새 위치로 치환

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 11:20:18 +09:00

495 lines
24 KiB
Python

"""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)