- 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>
495 lines
24 KiB
Python
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)
|