@structupath/pi-steel 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/DATA_PROVENANCE.json +23 -0
  2. package/DATA_PROVENANCE.md +35 -0
  3. package/PUBLIC_DATA_POLICY.md +52 -0
  4. package/README.md +114 -33
  5. package/docs/assets/pi-steel-demo.gif +0 -0
  6. package/docs/assets/pi-steel-gallery.webp +0 -0
  7. package/package.json +35 -4
  8. package/pyproject.toml +28 -0
  9. package/requirements-dev.txt +5 -0
  10. package/requirements-render.txt +1 -0
  11. package/requirements-tested.txt +9 -0
  12. package/requirements.txt +7 -0
  13. package/scripts/check-data-provenance.py +140 -0
  14. package/scripts/check-public-data.py +401 -0
  15. package/scripts/doctor.py +127 -0
  16. package/skills/_shared/bootstrap.py +32 -0
  17. package/skills/_shared/pi_steel/__init__.py +47 -0
  18. package/skills/_shared/pi_steel/cli.py +167 -0
  19. package/skills/_shared/pi_steel/contracts.py +87 -0
  20. package/skills/_shared/pi_steel/geometry_verify.py +243 -0
  21. package/skills/_shared/pi_steel/parsing.py +205 -0
  22. package/skills/_shared/pi_steel/run_manifest.py +317 -0
  23. package/skills/_shared/pi_steel/validation.py +597 -0
  24. package/skills/_shared/schemas/estimate-package.schema.json +424 -0
  25. package/skills/_shared/schemas/nest-result.schema.json +443 -0
  26. package/skills/_shared/schemas/run-manifest.schema.json +145 -0
  27. package/skills/steel-estimate/SKILL.md +119 -0
  28. package/skills/steel-estimate/references/estimate-package-example.json +91 -0
  29. package/skills/steel-estimate/references/output-contract.md +47 -0
  30. package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
  31. package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
  32. package/skills/steel-nest/SKILL.md +50 -23
  33. package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
  34. package/skills/steel-nest/references/example_job.json +21 -25
  35. package/skills/steel-nest/references/job_template.json +11 -9
  36. package/skills/steel-nest/scripts/nest.py +1276 -250
  37. package/skills/steel-rfq/SKILL.md +93 -175
  38. package/skills/steel-rfq/assets/company-profile.example.json +11 -5
  39. package/skills/steel-rfq/references/rfq-input.md +58 -0
  40. package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
  41. package/skills/steel-rfq/scripts/recalc.py +33 -10
  42. package/skills/steel-takeoff/SKILL.md +12 -8
  43. package/skills/steel-takeoff/assets/bom-template.csv +1 -1
  44. package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
  45. package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
  46. package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
  47. package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
@@ -0,0 +1,205 @@
1
+ """Adapters from the supported legacy BOM and nesting inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .contracts import (
11
+ ESTIMATE_PACKAGE_VERSION,
12
+ content_hash,
13
+ fallback_source_id,
14
+ item_id_for,
15
+ )
16
+
17
+
18
+ def parse_length_ft(raw: str) -> float:
19
+ value = raw.strip()
20
+ if "'" not in value:
21
+ return float(value)
22
+ feet, _, inches = value.replace('"', "").partition("'")
23
+ return float(feet.replace("-", "").strip()) + (
24
+ float(inches.replace("-", "").strip()) / 12 if inches.strip() else 0
25
+ )
26
+
27
+
28
+ def _package(
29
+ *,
30
+ project_id: str,
31
+ revision_id: str,
32
+ source_type: str,
33
+ source_hash: str,
34
+ items: list[dict[str, Any]],
35
+ stock: list[dict[str, Any]] | None = None,
36
+ unit_system: str = "imperial",
37
+ ) -> dict[str, Any]:
38
+ return {
39
+ "schema_version": ESTIMATE_PACKAGE_VERSION,
40
+ "project": {
41
+ "project_id": project_id,
42
+ "revision": {"revision_id": revision_id},
43
+ },
44
+ "unit_system": unit_system,
45
+ "items": items,
46
+ "stock": stock or [],
47
+ "commercial_basis": {"currency": "USD", "costs": []},
48
+ "review": {"status": "draft", "findings": [], "acknowledgements": []},
49
+ "lineage": {
50
+ "source_type": source_type,
51
+ "source_hash": source_hash,
52
+ "configuration_hash": content_hash({"adapter": source_type, "version": 1}),
53
+ },
54
+ }
55
+
56
+
57
+ def adapt_legacy_bom_csv(
58
+ path: str | Path, *, project_id: str, revision_id: str
59
+ ) -> dict[str, Any]:
60
+ csv_path = Path(path)
61
+ raw_bytes = csv_path.read_bytes()
62
+ items: list[dict[str, Any]] = []
63
+ with csv_path.open(newline="", encoding="utf-8-sig") as handle:
64
+ for row in csv.DictReader(handle):
65
+ mark = row.get("Mark", "").strip()
66
+ if mark.startswith("#") or not any((value or "").strip() for value in row.values()):
67
+ continue
68
+ explicit_source = row.get("Source_ID", "").strip()
69
+ stable_identity = {
70
+ key: (value or "").strip()
71
+ for key, value in row.items()
72
+ if key not in {"Total_Wt_lbs"}
73
+ }
74
+ source_id = explicit_source or (
75
+ f"legacy:{revision_id}:mark:{mark}"
76
+ if mark
77
+ else fallback_source_id(revision_id, stable_identity)
78
+ )
79
+ item: dict[str, Any] = {
80
+ "intent": row.get("Intent", "").strip() or "fabricated_part",
81
+ "source_id": source_id,
82
+ "item_id": item_id_for(project_id, revision_id, source_id),
83
+ "quantity": int(row.get("Qty", "0").strip()),
84
+ "mark": mark,
85
+ "designation": row.get("Size", "").strip(),
86
+ "grade": row.get("Grade", "").strip(),
87
+ "length_ft": parse_length_ft(row.get("Length_ft", "0")),
88
+ "unit_weight_plf": float(row.get("Unit_Wt_plf", "0").strip()),
89
+ "connections": row.get("Connections", "").strip(),
90
+ "notes": row.get("Notes", "").strip(),
91
+ }
92
+ total = row.get("Total_Wt_lbs", "").strip()
93
+ if total:
94
+ item["total_weight_lbs"] = float(total)
95
+ sheet = row.get("Source_Sheet", "").strip()
96
+ detail = row.get("Source_Detail", "").strip()
97
+ if sheet or detail:
98
+ item["source_evidence"] = [
99
+ {
100
+ "source": sheet or "legacy_bom",
101
+ "locator": detail or mark or source_id,
102
+ }
103
+ ]
104
+ if not explicit_source and not mark:
105
+ item["identity_warning"] = True
106
+ items.append(item)
107
+ return _package(
108
+ project_id=project_id,
109
+ revision_id=revision_id,
110
+ source_type="legacy_bom_csv",
111
+ source_hash=content_hash({"bytes_hex": raw_bytes.hex()}),
112
+ items=items,
113
+ )
114
+
115
+
116
+ def adapt_legacy_nest(
117
+ data: dict[str, Any] | str | Path, *, project_id: str, revision_id: str
118
+ ) -> dict[str, Any]:
119
+ if isinstance(data, (str, Path)):
120
+ value = json.loads(Path(data).read_text(encoding="utf-8"))
121
+ else:
122
+ value = json.loads(json.dumps(data))
123
+ material = value.get("material")
124
+ grade = value.get("grade")
125
+ unit_system = value.get("unit_system")
126
+ thickness = value.get("thickness_in", value.get("settings", {}).get("thickness_in"))
127
+ items = []
128
+ for part in value.get("parts", []):
129
+ explicit_source = part.get("source_id")
130
+ identity = {
131
+ key: part.get(key)
132
+ for key in ("name", "width", "height", "shape", "area")
133
+ }
134
+ source_id = explicit_source or fallback_source_id(revision_id, identity)
135
+ geometry = {
136
+ "shape": part.get("shape", "rect"),
137
+ "width": part.get("width"),
138
+ "height": part.get("height"),
139
+ "thickness": thickness,
140
+ "holes": [
141
+ (
142
+ {
143
+ "kind": "round",
144
+ "diameter": hole.get("dia"),
145
+ "x": hole.get("x"),
146
+ "y": hole.get("y"),
147
+ }
148
+ if "dia" in hole
149
+ else {
150
+ "kind": "rect",
151
+ "width": hole.get("w"),
152
+ "height": hole.get("h"),
153
+ "x": hole.get("x"),
154
+ "y": hole.get("y"),
155
+ }
156
+ )
157
+ for hole in part.get("holes", [])
158
+ ],
159
+ "rotatable": part.get("rotatable", True),
160
+ }
161
+ if "area" in part:
162
+ geometry["area"] = part["area"]
163
+ item = {
164
+ "intent": "fabricated_part",
165
+ "source_id": source_id,
166
+ "item_id": item_id_for(project_id, revision_id, source_id),
167
+ "quantity": part.get("qty", 0),
168
+ "mark": part.get("name", ""),
169
+ "geometry": geometry,
170
+ }
171
+ if material is not None:
172
+ item["material"] = material
173
+ if grade is not None:
174
+ item["grade"] = grade
175
+ if not explicit_source:
176
+ item["identity_warning"] = True
177
+ items.append(item)
178
+
179
+ stock = []
180
+ if material and grade and thickness is not None:
181
+ for index, legacy_stock in enumerate(value.get("stock", []), start=1):
182
+ stock.append(
183
+ {
184
+ "stock_kind": "purchasable",
185
+ "inventory_id": f"legacy-stock:{revision_id}:{index:04d}",
186
+ "material": material,
187
+ "grade": grade,
188
+ "width": legacy_stock.get("width"),
189
+ "height": legacy_stock.get("height"),
190
+ "thickness": legacy_stock.get("thickness", thickness),
191
+ "quantity": legacy_stock.get(
192
+ "qty", 1 if legacy_stock.get("unlimited") else 0
193
+ ),
194
+ "status": "available",
195
+ }
196
+ )
197
+ return _package(
198
+ project_id=project_id,
199
+ revision_id=revision_id,
200
+ source_type="legacy_nest_json",
201
+ source_hash=content_hash(value),
202
+ items=items,
203
+ stock=stock,
204
+ unit_system=unit_system or "imperial",
205
+ )
@@ -0,0 +1,317 @@
1
+ """Run manifests and isolated atomic artifact publication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import tempfile
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Any
16
+
17
+
18
+ MANIFEST_SCHEMA_VERSION = "1.0.0"
19
+ RUN_OUTCOMES = frozenset(
20
+ {
21
+ "ready",
22
+ "review_required",
23
+ "blocked",
24
+ "dependency_missing",
25
+ "usage_or_internal_error",
26
+ }
27
+ )
28
+ OUTCOME_EXIT_CODES = {
29
+ "ready": 0,
30
+ "review_required": 2,
31
+ "blocked": 3,
32
+ "dependency_missing": 4,
33
+ "usage_or_internal_error": 1,
34
+ }
35
+ PACKAGE_STATUSES = frozenset(
36
+ {
37
+ "draft",
38
+ "review_required",
39
+ "validated",
40
+ "nested_partial",
41
+ "nest_verified",
42
+ "rfq_draft_review_required",
43
+ "rfq_ready_for_review",
44
+ }
45
+ )
46
+ ARTIFACT_READINESS = frozenset(
47
+ {"geometry_verified", "reference_only", "draft", "diagnostic"}
48
+ )
49
+ _RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
50
+ _HASH = re.compile(r"^[0-9a-f]{64}$")
51
+
52
+
53
+ class ManifestError(RuntimeError):
54
+ """Raised when a run cannot be published safely."""
55
+
56
+
57
+ def canonical_json_bytes(value: Any) -> bytes:
58
+ """Return a stable UTF-8 JSON representation suitable for hashing."""
59
+ return (
60
+ json.dumps(
61
+ value,
62
+ ensure_ascii=False,
63
+ allow_nan=False,
64
+ separators=(",", ":"),
65
+ sort_keys=True,
66
+ )
67
+ + "\n"
68
+ ).encode("utf-8")
69
+
70
+
71
+ def sha256_bytes(value: bytes) -> str:
72
+ return hashlib.sha256(value).hexdigest()
73
+
74
+
75
+ def sha256_file(path: str | Path) -> str:
76
+ digest = hashlib.sha256()
77
+ with Path(path).open("rb") as stream:
78
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
79
+ digest.update(chunk)
80
+ return digest.hexdigest()
81
+
82
+
83
+ def outcome_exit_code(outcome: str) -> int:
84
+ try:
85
+ return OUTCOME_EXIT_CODES[outcome]
86
+ except KeyError as exc:
87
+ raise ManifestError(f"unknown run outcome: {outcome}") from exc
88
+
89
+
90
+ def semantic_manifest_projection(manifest: dict[str, Any]) -> dict[str, Any]:
91
+ """Remove volatile fields and the self-referential hash before semantic hashing."""
92
+ projection = copy.deepcopy(manifest)
93
+ for field in ("semantic_hash", "run_id", "created_at"):
94
+ projection.pop(field, None)
95
+ return projection
96
+
97
+
98
+ def _safe_relative_path(value: str | Path) -> PurePosixPath:
99
+ text = Path(value).as_posix()
100
+ path = PurePosixPath(text)
101
+ if (
102
+ text in {"", "."}
103
+ or path.is_absolute()
104
+ or ".." in path.parts
105
+ or path.name in {"run-manifest.json", "latest-run.json"}
106
+ ):
107
+ raise ManifestError(f"unsafe or reserved artifact path: {text}")
108
+ return path
109
+
110
+
111
+ def _utc_now() -> str:
112
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
113
+
114
+
115
+ class RunPublisher:
116
+ """Stage one isolated run and publish it with an integrity manifest."""
117
+
118
+ def __init__(
119
+ self,
120
+ destination: str | Path,
121
+ *,
122
+ stage: str,
123
+ run_outcome: str,
124
+ package_status: str,
125
+ input_hash: str,
126
+ configuration_hash: str,
127
+ schema_versions: dict[str, str],
128
+ tool_versions: dict[str, str],
129
+ explicit_dates: dict[str, str],
130
+ warnings: list[Any] | None = None,
131
+ approximations: list[Any] | None = None,
132
+ run_id: str | None = None,
133
+ created_at: str | None = None,
134
+ ) -> None:
135
+ if run_outcome not in RUN_OUTCOMES:
136
+ raise ManifestError(f"unknown run outcome: {run_outcome}")
137
+ if package_status not in PACKAGE_STATUSES:
138
+ raise ManifestError(f"unknown package status: {package_status}")
139
+ if not _HASH.fullmatch(input_hash) or not _HASH.fullmatch(configuration_hash):
140
+ raise ManifestError("input and configuration hashes must be SHA-256 hex")
141
+
142
+ self.destination = Path(destination).resolve()
143
+ self.run_id = run_id or (
144
+ datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
145
+ + "-"
146
+ + uuid.uuid4().hex[:12]
147
+ )
148
+ if not _RUN_ID.fullmatch(self.run_id):
149
+ raise ManifestError(f"invalid run id: {self.run_id}")
150
+
151
+ self._metadata = {
152
+ "schema_version": MANIFEST_SCHEMA_VERSION,
153
+ "run_id": self.run_id,
154
+ "stage": stage,
155
+ "run_outcome": run_outcome,
156
+ "package_status": package_status,
157
+ "created_at": created_at or _utc_now(),
158
+ "schema_versions": dict(schema_versions),
159
+ "tool_versions": dict(tool_versions),
160
+ "input_hash": input_hash,
161
+ "configuration_hash": configuration_hash,
162
+ "explicit_dates": dict(explicit_dates),
163
+ "warnings": list(warnings or []),
164
+ "approximations": list(approximations or []),
165
+ }
166
+ self._artifacts: dict[str, dict[str, Any]] = {}
167
+ self._published = False
168
+
169
+ staging_parent = self.destination / ".staging"
170
+ staging_parent.mkdir(parents=True, exist_ok=True)
171
+ self.staging_path = Path(
172
+ tempfile.mkdtemp(prefix=f"{self.run_id}-", dir=staging_parent)
173
+ )
174
+ self.final_path = self.destination / "runs" / self.run_id
175
+
176
+ def __enter__(self) -> "RunPublisher":
177
+ return self
178
+
179
+ def __exit__(self, exc_type, exc, traceback) -> None:
180
+ if not self._published and self.staging_path.exists():
181
+ shutil.rmtree(self.staging_path)
182
+
183
+ def path_for(self, relative_path: str | Path) -> Path:
184
+ relative = _safe_relative_path(relative_path)
185
+ target = self.staging_path.joinpath(*relative.parts)
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ return target
188
+
189
+ def register_artifact(
190
+ self,
191
+ relative_path: str | Path,
192
+ *,
193
+ readiness: str,
194
+ media_type: str | None = None,
195
+ ) -> Path:
196
+ if readiness not in ARTIFACT_READINESS:
197
+ raise ManifestError(f"unknown artifact readiness: {readiness}")
198
+ relative = _safe_relative_path(relative_path)
199
+ text = relative.as_posix()
200
+ if text in self._artifacts:
201
+ raise ManifestError(f"artifact already registered: {text}")
202
+ record: dict[str, Any] = {"readiness": readiness}
203
+ if media_type:
204
+ record["media_type"] = media_type
205
+ self._artifacts[text] = record
206
+ return self.path_for(relative)
207
+
208
+ def write_bytes(
209
+ self,
210
+ relative_path: str | Path,
211
+ value: bytes,
212
+ *,
213
+ readiness: str,
214
+ media_type: str | None = None,
215
+ ) -> Path:
216
+ target = self.register_artifact(
217
+ relative_path, readiness=readiness, media_type=media_type
218
+ )
219
+ target.write_bytes(value)
220
+ return target
221
+
222
+ def write_json(
223
+ self,
224
+ relative_path: str | Path,
225
+ value: Any,
226
+ *,
227
+ readiness: str,
228
+ ) -> Path:
229
+ return self.write_bytes(
230
+ relative_path,
231
+ canonical_json_bytes(value),
232
+ readiness=readiness,
233
+ media_type="application/json",
234
+ )
235
+
236
+ def write_qa_report(self, value: Any) -> Path:
237
+ """Register the standard machine-readable QA artifact for a stage run."""
238
+ return self.write_json("qa-report.json", value, readiness="diagnostic")
239
+
240
+ def publish(self) -> Path:
241
+ if self._published:
242
+ raise ManifestError("run already published")
243
+ if self.final_path.exists():
244
+ raise ManifestError(f"run already exists: {self.run_id}")
245
+ if (
246
+ self._metadata["run_outcome"] in {"ready", "review_required", "blocked"}
247
+ and "qa-report.json" not in self._artifacts
248
+ ):
249
+ raise ManifestError(
250
+ f"{self._metadata['run_outcome']} runs require qa-report.json"
251
+ )
252
+
253
+ actual_files = set()
254
+ for path in self.staging_path.rglob("*"):
255
+ if path.is_symlink():
256
+ raise ManifestError("artifact symlinks are not allowed")
257
+ if path.is_file():
258
+ actual_files.add(path.relative_to(self.staging_path).as_posix())
259
+ registered_files = set(self._artifacts)
260
+ if actual_files != registered_files:
261
+ missing = sorted(registered_files - actual_files)
262
+ extra = sorted(actual_files - registered_files)
263
+ raise ManifestError(
264
+ f"artifact allow-list mismatch; missing={missing}, unregistered={extra}"
265
+ )
266
+
267
+ artifact_records = []
268
+ for relative in sorted(self._artifacts):
269
+ path = self.staging_path / relative
270
+ record = {
271
+ "path": relative,
272
+ "sha256": sha256_file(path),
273
+ "size_bytes": path.stat().st_size,
274
+ **self._artifacts[relative],
275
+ }
276
+ artifact_records.append(record)
277
+
278
+ manifest = {**self._metadata, "artifacts": artifact_records}
279
+ manifest["semantic_hash"] = sha256_bytes(
280
+ canonical_json_bytes(semantic_manifest_projection(manifest))
281
+ )
282
+ manifest_path = self.staging_path / "run-manifest.json"
283
+ manifest_path.write_bytes(canonical_json_bytes(manifest))
284
+
285
+ self.final_path.parent.mkdir(parents=True, exist_ok=True)
286
+ os.replace(self.staging_path, self.final_path)
287
+ manifest_hash = sha256_file(self.final_path / "run-manifest.json")
288
+ pointer = {
289
+ "schema_version": MANIFEST_SCHEMA_VERSION,
290
+ "run_id": self.run_id,
291
+ "run_directory": f"runs/{self.run_id}",
292
+ "manifest_sha256": manifest_hash,
293
+ }
294
+ pointer_fd, pointer_name = tempfile.mkstemp(
295
+ prefix=".latest-run-", dir=self.destination
296
+ )
297
+ try:
298
+ with os.fdopen(pointer_fd, "wb") as stream:
299
+ stream.write(canonical_json_bytes(pointer))
300
+ os.replace(pointer_name, self.destination / "latest-run.json")
301
+ except Exception as pointer_error:
302
+ try:
303
+ shutil.rmtree(self.final_path)
304
+ except OSError as rollback_error:
305
+ raise ManifestError(
306
+ "latest-run pointer update failed and the unpublished run "
307
+ f"could not be rolled back: {self.final_path}"
308
+ ) from rollback_error
309
+ raise ManifestError(
310
+ "latest-run pointer update failed; unpublished run was rolled back"
311
+ ) from pointer_error
312
+ finally:
313
+ if os.path.exists(pointer_name):
314
+ os.unlink(pointer_name)
315
+
316
+ self._published = True
317
+ return self.final_path