优化流程

This commit is contained in:
2026-09-02 13:51:35 +08:00
parent 97a03c290b
commit f3eb5dff54
71 changed files with 5290 additions and 515 deletions
+4
View File
@@ -33,7 +33,11 @@ build/
# Runtime data and generated local artifacts
backend/data/
backend/live-evals/
data/cadfs-sample/
json_to_cdsl/input/
onshape_to_cdsl/input/
onshape_to_cdsl/data/
# Logs
*.log
+68
View File
@@ -153,6 +153,7 @@ class ProfileCadRuntime:
fragment,
selector_tokens=allowed_selectors,
reference_tokens=list(reference_tokens),
root_xy_datum=not bool((base_cdsl or {}).get("features")),
)
if errors:
raise RuntimeAdapterError(
@@ -262,6 +263,73 @@ class ProfileCadRuntime:
except Exception as error:
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
def rebuild_best_effort(
self,
cdsl: dict[str, Any],
output_dir: str,
task_id: str,
revision_id: str,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Build independent feature prefixes without discarding prior geometry.
A full CDSL document can contain several features even though the
action protocol normally appends one at a time. Engine validation is
all-or-nothing, so replay features in source order and retain each
executable feature. A failed feature is reported as a diagnostic;
it does not erase already executable geometry or prevent later,
independent features from being tried.
"""
features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else []
accepted: list[dict[str, Any]] = []
accepted_ids: set[str] = set()
failures: list[dict[str, Any]] = []
latest: dict[str, Any] | None = None
for index, feature in enumerate(features):
if not isinstance(feature, dict):
failures.append({"feature_index": index, "feature_id": "", "message": "Feature must be an object."})
continue
feature_id = str(feature.get("id") or f"feature_{index + 1:03d}")
dependencies = [str(value) for value in feature.get("depends_on") or () if isinstance(value, str)]
unavailable = [value for value in dependencies if value not in accepted_ids]
if unavailable:
failures.append({
"feature_index": index,
"feature_id": feature_id,
"message": "Feature was skipped because an earlier dependency did not execute.",
"dependencies": unavailable,
})
continue
candidate = self._feature_subset(cdsl, [*accepted, feature])
try:
latest = self.rebuild(candidate, output_dir, task_id, revision_id)
except Exception as error:
failures.append({"feature_index": index, "feature_id": feature_id, "message": str(error)[:1000]})
continue
accepted.append(deepcopy(feature))
accepted_ids.add(feature_id)
if not accepted:
if failures:
raise RuntimeAdapterError(str(failures[0].get("message") or "RUNTIME_EXECUTION_FAILURE: no feature could be rebuilt"))
raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features")
# A failed later attempt may have left partial files in the stage.
# Rebuild the retained feature set once so all published artifacts are
# guaranteed to describe the same successful checkpoint.
latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id)
return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures
@staticmethod
def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]:
document = deepcopy(cdsl)
document["features"] = deepcopy(features)
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
sketch_ids = {str(feature.get("sketch_id") or "") for feature in features}
document["geometry"] = {
**geometry,
"sketches": [deepcopy(sketch) for sketch in sketches if isinstance(sketch, dict) and str(sketch.get("id") or "") in sketch_ids],
}
return document
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None:
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
@@ -19,6 +19,7 @@ class SqliteTaskRepository:
self.database_path = database_path
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self.protocol_reset = False
self._initialize()
@contextmanager
@@ -34,11 +35,27 @@ class SqliteTaskRepository:
def _initialize(self) -> None:
with self._lock, self._connection() as connection:
# Protocol 3.1 intentionally has no migration path from the
# structured-only / review-loop task model. Deployment starts with
# an empty task database, as those tasks do not have immutable
# Markdown source artifacts to compile from.
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
if existing is not None and "'3.1'" not in str(existing[0] or ""):
self.protocol_reset = True
connection.executescript("""
DROP TABLE IF EXISTS outbox;
DROP TABLE IF EXISTS tool_audits;
DROP TABLE IF EXISTS usage_records;
DROP TABLE IF EXISTS invocations;
DROP TABLE IF EXISTS ledger;
DROP TABLE IF EXISTS model_capabilities;
DROP TABLE IF EXISTS tasks;
""")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.0'),
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.1'),
request TEXT NOT NULL,
phase TEXT NOT NULL,
state_version INTEGER NOT NULL,
@@ -50,6 +67,9 @@ class SqliteTaskRepository:
last_error TEXT,
retry_from_phase TEXT NOT NULL DEFAULT '',
requirements_spec_path TEXT NOT NULL DEFAULT '',
requirements_document_path TEXT NOT NULL DEFAULT '',
completion_target_path TEXT NOT NULL DEFAULT '',
modeling_plan_path TEXT NOT NULL DEFAULT '',
clarification_path TEXT NOT NULL DEFAULT '',
requirements_contract_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -104,8 +124,8 @@ class SqliteTaskRepository:
def create_task(self, task_id: str, request: str) -> TaskState:
with self._lock, self._connection() as connection:
connection.execute(
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.0', ?, ?, 0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS.value),
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.1', ?, ?, 0)",
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
)
state = self.get_state(task_id)
if state is None:
@@ -130,7 +150,7 @@ class SqliteTaskRepository:
for item in events
if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str)
]
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_contract_frozen"), {})
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
verification_warnings = [
str(item) for item in frozen.get("verification_warnings") or () if str(item)
] if isinstance(frozen, dict) else []
@@ -141,12 +161,13 @@ class SqliteTaskRepository:
"no_progress_limit",
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
"failed_author_format", "runtime_contract_invalid",
"completed_best_effort",
}
), {}) if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
return {
"schema_version": "3.0",
"schema_version": "3.1",
"task_id": state.task_id,
"phase": state.phase.value,
"lifecycle": self._lifecycle(state.phase),
@@ -160,10 +181,13 @@ class SqliteTaskRepository:
"last_error": state.last_error.value if state.last_error else "",
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
"requirements_spec_path": state.requirements_spec_path,
"requirements_document_path": state.requirements_document_path,
"completion_target_path": state.completion_target_path,
"modeling_plan_path": state.modeling_plan_path,
"clarification_path": state.clarification_path,
"requirements_contract_path": state.requirements_contract_path,
"verification_status": (
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and verification_warnings
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
else "verified" if state.phase == TaskPhase.COMPLETED
else "pending"
),
@@ -219,7 +243,7 @@ class SqliteTaskRepository:
cursor = connection.execute(
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND state_version = ?""",
(
state.phase.value, state.version, state.active_revision,
@@ -227,6 +251,7 @@ class SqliteTaskRepository:
state.candidate_id, state.candidate_stage_id,
int(state.repair_required), state.last_error.value if state.last_error else None,
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
state.clarification_path, state.requirements_contract_path,
state.task_id, previous_version,
),
@@ -377,6 +402,9 @@ class SqliteTaskRepository:
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
requirements_spec_path=str(row["requirements_spec_path"] or ""),
requirements_document_path=str(row["requirements_document_path"] or ""),
completion_target_path=str(row["completion_target_path"] or ""),
modeling_plan_path=str(row["modeling_plan_path"] or ""),
clarification_path=str(row["clarification_path"] or ""),
requirements_contract_path=str(row["requirements_contract_path"] or ""),
)
@@ -109,8 +109,8 @@ class ActionCommandHandler:
for event in self.repository.ledger_events(task_id)[-16:]:
if event.get("event") in {
"candidate_rejected", "candidate_build_failed", "candidate_recovery_failed",
"candidate_recovered_rejected", "final_review_repair",
}:
"candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair",
} or (event.get("event") == "accepted" and event.get("repair_required")):
sequence = event.get("sequence")
if isinstance(sequence, int):
refs.append(f"evidence_ledger_{sequence}")
@@ -134,8 +134,10 @@ class ActionCommandHandler:
"candidate_build_failed",
"candidate_recovery_failed",
"candidate_recovered_rejected",
"candidate_operation_skipped",
"runtime_precondition_rejected",
"final_review_repair",
"accepted",
}
diagnostics: list[dict[str, Any]] = []
for event in reversed(self.repository.ledger_events(task_id)):
@@ -153,6 +155,20 @@ class ActionCommandHandler:
message = event.get("message")
if isinstance(message, str) and message:
item["message"] = message[:500]
for field in ("issues", "failed_checklist_items"):
values = event.get(field)
if isinstance(values, list):
item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)]
failures = event.get("operation_failures")
if isinstance(failures, list):
item["operation_failures"] = [
{key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}}
for failure in failures[:8]
if isinstance(failure, dict)
]
fragment_hash = event.get("fragment_hash")
if isinstance(fragment_hash, str) and fragment_hash:
item["fragment_hash"] = fragment_hash
stage_id = event.get("stage_id")
candidate = None
if isinstance(stage_id, str) and stage_id:
@@ -263,7 +279,11 @@ class ActionCommandHandler:
state = state or self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
return False
return any(
has_earlier_checkpoint = any(
revision != state.active_revision
for revision in self.checkpoint_tokens(task_id, state).values()
)
return has_earlier_checkpoint and any(
event.get("event") == "geometry_conclusion"
and event.get("decision") == "rollback"
and event.get("working_head") == state.working_head
@@ -390,6 +410,7 @@ class ActionCommandHandler:
fragment,
selector_tokens=allowed_selector_tokens,
reference_tokens=list(references),
root_xy_datum=not bool(state.active_revision),
)
if errors:
return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors)))
@@ -452,8 +473,48 @@ class ActionCommandHandler:
if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]):
return Rejected(self._stale())
try:
rebuilt = self.runtime.rebuild(cdsl, stage.output_dir, task_id, candidate_id)
claim_results = self._evaluate_claims(task_id, rebuilt)
rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id)
attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)}
executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)}
if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids):
next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED)
result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures}
if not self._commit_invocation(next_state, [{
"event": "candidate_operation_skipped",
"candidate_id": candidate_id,
"stage_id": stage.stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": fragment_hash,
"operation_failures": operation_failures,
"message": "The submitted feature did not execute; earlier executable features were retained.",
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(
ErrorCode.CANDIDATE_BUILD_FAILED,
"The submitted feature could not execute; the previous executable checkpoint was retained.",
details={"operation_failures": operation_failures},
))
try:
claim_results = self._evaluate_claims(task_id, rebuilt)
except Exception as error:
failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID)
result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value}
if not self._commit_invocation(failed_state, [{
"event": "requirements_contract_execution_failed",
"candidate_id": candidate_id,
"stage_id": stage.stage_id,
"action_id": action.action_id,
"message": str(error)[:1000],
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(
ErrorCode.REQUIREMENTS_SPEC_INVALID,
"The frozen requirements contract could not be evaluated; no CAD repair was attempted.",
details={"diagnostic": str(error)[:1000]},
))
operation_results = self._operation_candidate_results(
action,
contract,
@@ -466,47 +527,11 @@ class ActionCommandHandler:
*self._candidate_blockers(task_id, action.requirement_ids, claim_results),
*[item for item in operation_results if item.get("status") != "pass"],
]
candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]}
candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]}
self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate)
if blockers:
failure_class_fingerprint = self._failure_class_fingerprint(
state.active_revision, action.atomic_id, ErrorCode.CLAIM_VERIFICATION_FAILED,
)
prior_failures = self._failure_class_events(task_id, failure_class_fingerprint)
if len(prior_failures) >= 2:
failed_state = transition(building, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value}
if not self._commit_invocation(failed_state, [{
"event": "no_progress_limit", "candidate_id": candidate_id, "stage_id": stage.stage_id,
"action_id": action.action_id, "checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id, "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value,
"failure_class_fingerprint": failure_class_fingerprint,
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same deterministic candidate failure made no progress after three attempts."))
failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED)
result = {"candidate_id": candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results}
if not self._commit_invocation(failed_state, [{
"event": "candidate_rejected",
"candidate_id": candidate_id,
"stage_id": stage.stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": fragment_hash,
"normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value,
"failure_exact_fingerprint": exact_fingerprint,
"failure_class_fingerprint": failure_class_fingerprint,
"attempt": len(prior_failures) + 1,
"reason": "deterministic_claim_failed",
"claim_results": claim_results,
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "Candidate violates a deterministic claim required by this action or a global invariant.", details={"claim_results": blockers}))
review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id)
result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results}
if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result):
result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures}
if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
except OSError as error:
@@ -592,7 +617,9 @@ class ActionCommandHandler:
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
references = self.runtime.reference_tokens(base)
cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids))
rebuilt = self.runtime.rebuild(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id)
rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id)
if "operation_failures" not in locals():
operation_failures = []
claim_results = self._evaluate_claims(task_id, rebuilt)
audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {}
contract = self.runtime.operation_contract(action.atomic_id)
@@ -611,6 +638,7 @@ class ActionCommandHandler:
"fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}),
"selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results,
"operation_verifier_results": operation_results,
"operation_failures": operation_failures,
"blockers": [
*self._candidate_blockers(task_id, action.requirement_ids, claim_results),
*[item for item in operation_results if item.get("status") != "pass"],
@@ -699,51 +727,9 @@ class ActionCommandHandler:
event="candidate_review_storage_failure",
message=str(error),
)
if review.verdict != "accept" or deterministic_fail:
if published_revision:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "A published candidate cannot be rejected during recovery.", retryable=True))
failure_class_fingerprint = self._failure_class_fingerprint(
state.active_revision, action.atomic_id, ErrorCode.CANDIDATE_REVIEW_REJECTED,
)
reviewed_fragment_hash = str(candidate.get("fragment_hash") or "")
failure_exact_fingerprint = canonical_hash({
"active_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": reviewed_fragment_hash,
}) if reviewed_fragment_hash else ""
prior_failures = self._failure_class_events(task_id, failure_class_fingerprint)
if len(prior_failures) >= 2:
next_state = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
result = {"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.NO_PROGRESS_LIMIT.value}
if not self._commit_invocation(next_state, [{
"event": "no_progress_limit", "candidate_id": state.candidate_id,
"stage_id": state.candidate_stage_id, "action_id": action.action_id,
"checkpoint_revision": state.active_revision, "atomic_id": action.atomic_id,
"normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
"failure_class_fingerprint": failure_class_fingerprint,
}], invocation, result):
return Rejected(self._stale())
return Rejected(WorkflowError(ErrorCode.NO_PROGRESS_LIMIT, "The same candidate-review failure made no progress after three attempts."))
next_state = transition(state, "candidate_rejected", error=ErrorCode.CANDIDATE_REVIEW_REJECTED, repair_required=True)
result = {"candidate_id": state.candidate_id, "status": "rejected"}
if not self._commit_invocation(next_state, [{
"event": "candidate_rejected",
"candidate_id": state.candidate_id,
"stage_id": state.candidate_stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"review_path": "candidate-review.json",
"deterministic_fail": deterministic_fail,
"checkpoint_revision": state.active_revision,
"atomic_id": action.atomic_id,
"fragment_hash": reviewed_fragment_hash,
"failure_exact_fingerprint": failure_exact_fingerprint,
"normalized_error_code": ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
"failure_class_fingerprint": failure_class_fingerprint,
"attempt": len(prior_failures) + 1,
}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else []
candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else []
needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers)
revision_id = self._next_revision(task_id)
if published_revision and published_revision != revision_id:
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True))
@@ -755,9 +741,9 @@ class ActionCommandHandler:
event="candidate_publish_storage_failure",
message=str(error),
)
next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=False)
event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual}
result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted"}
next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None)
event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair}
result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"}
if not self._commit_invocation(next_state, [event], invocation, result):
return Rejected(self._stale())
return Accepted(result)
@@ -833,21 +819,6 @@ class ActionCommandHandler:
invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None
if invocation is not None and invocation.status == "finished" and invocation.result is not None:
return Accepted(invocation.result)
if blockers:
failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED)
result = {"candidate_id": state.candidate_id, "status": "rejected", "code": ErrorCode.CLAIM_VERIFICATION_FAILED.value, "claim_results": claim_results}
event = {
"event": "candidate_recovered_rejected",
"candidate_id": state.candidate_id,
"stage_id": state.candidate_stage_id,
"action_id": action.action_id,
"working_head": action.working_head,
"claim_results": claim_results,
}
committed = self._commit_invocation(failed, [event], invocation, result) if invocation is not None else self.repository.compare_and_swap(failed, events=[event])
if not committed:
return Rejected(self._stale())
return Accepted(result)
review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id)
result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results}
committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}])
@@ -855,6 +826,43 @@ class ActionCommandHandler:
return Rejected(self._stale())
return Accepted(result)
def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected:
"""Publish the last executable checkpoint when further repair is bounded.
This is deliberately separate from strict final validation. It does
not claim that unmet acceptance targets passed; it makes the usable
model and its measured gaps durable instead of converting a planning
dead-end into a failed task with no deliverable.
"""
state = self.repository.get_state(task_id)
if state is None or not state.active_revision:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint."))
if state.phase == TaskPhase.COMPLETED:
return Accepted({"status": "completed", "revision_id": state.active_revision})
key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value})
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
if invocation.status == "finished" and invocation.result is not None:
return Accepted(invocation.result)
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
issues = [
f"{item.get('claim_kind')}: {item.get('status')}"
for item in claim_results
if item.get("status") != "pass"
]
next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False)
result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues}
if not self._commit_invocation(next_state, [{
"event": "completed_best_effort",
"revision_id": state.active_revision,
"termination_code": reason.value,
"message": "Further CAD repair was bounded; the last executable checkpoint was published.",
"issues": issues,
"claim_results": claim_results,
"completion_result_path": "completion-result.md",
}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected:
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.FINAL_VALIDATION:
@@ -901,6 +909,19 @@ class ActionCommandHandler:
message=str(error),
)
if review.verdict != "pass" or deterministic_fail or visual_not_passed:
accepted_fragment = next((
event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id))
if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision
), "")
atomic_id = next((
str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id))
if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision
), "")
exact_fingerprint = canonical_hash({
"active_revision": state.active_revision,
"atomic_id": atomic_id,
"fragment_hash": accepted_fragment,
}) if accepted_fragment and atomic_id else ""
next_state = transition(state, "final_repair", repair_required=True, error=ErrorCode.CLAIM_VERIFICATION_FAILED if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED)
result = {"status": "repair", "revision_id": state.active_revision}
if not self._commit_invocation(next_state, [{
@@ -909,6 +930,18 @@ class ActionCommandHandler:
"claim_results": claim_results,
"review_claim_coverage": [item.model_dump(mode="json") for item in review.claim_coverage],
"visual_not_passed": [str(item.get("claim_id") or "") for item in visual_not_passed],
"issues": list(review.issues),
"evidence": list(review.evidence),
"failed_checklist_items": [
str(requirement.get("statement") or "")
for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or ()
if isinstance(requirement, dict)
and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
],
"atomic_id": atomic_id,
"fragment_hash": accepted_fragment,
"failure_exact_fingerprint": exact_fingerprint,
"normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value,
}], invocation, result):
return Rejected(self._stale())
return Accepted(result)
@@ -9,12 +9,12 @@ from typing import Any, Literal
from app.cad_agent.application.llm_contracts import (
EmptyCommand,
ImageObservation,
RequirementsAuthorOutput,
MarkdownDocument,
StatelessCandidateReview,
StatelessGeometryConclusion,
StatelessRollbackCheckpoint,
StatelessTopologyRequest,
requirements_spec_schema,
compiled_requirements_schema,
stateless_final_review_schema,
stateless_next_action_schema,
)
@@ -37,7 +37,10 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
if not atomic_ids:
raise RuntimeError("Runtime has no operations for conformance")
tools = [
_tool("submit_requirements_spec", requirements_spec_schema(default_registry().expected_one_of_schema())),
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
_tool("propose_next_action", stateless_next_action_schema(atomic_ids)),
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
@@ -54,7 +57,7 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
payload = {"protocol": "cad.v3.spec.v1", "role": role, "tools": tools}
payload = {"protocol": "cad.v3.1.markdown-first", "role": role, "tools": tools}
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -37,6 +37,28 @@ class SpecRequirementInput(StrictDto):
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class MarkdownDocument(StrictDto):
"""A frozen human-readable design artifact, never an executable payload."""
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
class CompiledRequirementInput(StrictDto):
"""One verifier bundle for one server-parsed checklist item.
The checklist text, ordering, source bindings, and all identifiers are
intentionally absent: the service owns them after Markdown is frozen.
"""
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
class CompiledRequirementsSpec(StrictDto):
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
# Kept only so an interrupted process with an already imported old tool schema
# fails at the workflow boundary instead of failing module import. New v3.1
# tasks never expose or accept this aggregate specification.
class RequirementsSpec(StrictDto):
outcome: Literal["ready"]
summary: Annotated[str, Field(min_length=1, max_length=2000)]
@@ -61,7 +83,9 @@ class EmptyCommand(StrictDto):
class NextAction(StrictDto):
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
intent: ShortText
requirement_ids: list[Identifier] = Field(min_length=1, max_length=5)
# The server binds this list from every frozen checklist target. It is not
# author input, so a five-item UI-era limit must not reject a valid task.
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
atomic_id: Identifier
expected_change: ShortText
@@ -194,6 +218,66 @@ def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
return schema
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
schema = CompiledRequirementsSpec.model_json_schema()
definitions = schema.get("$defs", {})
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
if isinstance(requirement, dict):
claims = requirement.get("properties", {}).get("acceptance_claims")
if isinstance(claims, dict):
claims["items"] = deepcopy(claim_one_of)
requirements = schema.get("properties", {}).get("requirements")
if isinstance(requirements, dict):
requirements["minItems"] = target_count
requirements["maxItems"] = target_count
return schema
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
"""Drop harmless compiler chatter before strict requirements validation.
``compile_requirements_spec`` is a compiler stage: the service only needs
the ordered verifier bundles for the frozen checklist items. Real models
sometimes add explanatory fields such as a top-level ``assumptions`` or
per-item ``statement`` even when the dynamic tool schema forbids them. Those
fields are not executable and are not part of the frozen contract, so they
should not abort a task before modeling starts.
The verifier ``expected`` payload is intentionally not sanitized here. It
remains governed by the registry's strict per-claim schema because those
values drive deterministic validation.
"""
value = canonical_json_object(raw_arguments_json)
if isinstance(value, WorkflowError):
return value
requirements = value.get("requirements")
sanitized: dict[str, Any] = {}
if isinstance(requirements, list):
sanitized_requirements: list[Any] = []
for requirement in requirements:
if not isinstance(requirement, dict):
sanitized_requirements.append(requirement)
continue
item: dict[str, Any] = {}
if "assumptions" in requirement:
item["assumptions"] = requirement["assumptions"]
if "acceptance_claims" in requirement:
claims = requirement["acceptance_claims"]
if isinstance(claims, list):
item["acceptance_claims"] = [
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
if isinstance(claim, dict) else claim
for claim in claims
]
else:
item["acceptance_claims"] = claims
sanitized_requirements.append(item)
sanitized["requirements"] = sanitized_requirements
else:
sanitized["requirements"] = requirements
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
schema = StatelessNextAction.model_json_schema()
properties = schema.get("properties", {})
@@ -228,18 +312,6 @@ def topology_request_schema(working_head: str) -> dict[str, Any]:
return schema
def geometry_conclusion_schema(working_head: str, evidence_refs: list[str]) -> dict[str, Any]:
"""Bind a diagnostic conclusion to evidence generated for this head."""
schema = GeometryConclusion.model_json_schema()
properties = schema.get("properties", {})
if isinstance(properties, dict):
properties["working_head"] = {"const": working_head}
evidence = properties.get("evidence_refs")
if isinstance(evidence, dict):
evidence["items"] = {"enum": evidence_refs}
return schema
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
"""Bind a rollback request to immutable checkpoints in the active lineage."""
schema = RollbackCheckpoint.model_json_schema()
+210 -234
View File
@@ -1,26 +1,32 @@
"""One-pass requirements specification and server-owned contract artifacts."""
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
import re
from typing import Any, Callable
from app.cad_agent.application.llm_contracts import (
RequirementsAuthorOutput,
RequirementsClarification,
RequirementsSpec,
requirements_spec_schema,
)
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
from app.cad_agent.application.results import Accepted, Rejected
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
from app.cad_agent.domain.verifier_registry import VerifierRegistry
from app.cad_agent.ports import ArtifactStore, TaskRepository
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
class RequirementsCommandHandler:
"""Persist frozen documents and compile their checklist into a contract.
The model never names targets or internal objects during compilation. The
service derives those values strictly from the immutable checklist.
"""
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
self.repository = repository
self.artifacts = artifacts
@@ -28,283 +34,257 @@ class RequirementsCommandHandler:
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
def register_evaluation_contract_oracle(
self,
task_id: str,
required_claims: list[dict[str, Any]],
*,
validation_capability_gaps: list[dict[str, Any]] | None = None,
) -> None:
"""Retain release-evaluation metadata without changing production decisions."""
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
self._evaluation_capability_gaps[task_id] = [
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
for item in validation_capability_gaps or ()
if isinstance(item, dict)
for item in validation_capability_gaps or () if isinstance(item, dict)
]
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
claims = self._evaluation_contract_oracles.get(task_id)
if claims is None:
return None
return {
return None if claims is None else {
"evaluation_only": True,
"required_claims": deepcopy(claims),
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
}
def spec_schema(self) -> dict[str, Any]:
return requirements_spec_schema(self.registry.expected_one_of_schema())
@staticmethod
def document_schema() -> dict[str, Any]:
return MarkdownDocument.model_json_schema()
def submit_spec(self, task_id: str, output: RequirementsAuthorOutput, *, invocation_id: str) -> Accepted | Rejected | Waiting:
def compiler_schema(self, task_id: str) -> dict[str, Any]:
return compiled_requirements_schema(
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
len(self._checklist_items(task_id)),
)
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_MODELING_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != TaskPhase.DRAFTING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements are not expected in the current workflow phase."))
value = output.root
if isinstance(value, RequirementsClarification):
return self._record_clarification(task_id, state, value, invocation_id=invocation_id)
if not isinstance(value, RequirementsSpec):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements output is not a supported specification."))
field_errors: list[dict[str, str]] = []
for requirement_index, requirement in enumerate(value.requirements):
for claim_index, claim in enumerate(requirement.acceptance_claims):
try:
errors = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
errors = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
field_errors.extend({
"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{error['path']}",
"message": error["message"],
} for error in errors)
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
targets = self._checklist_items(task_id)
if len(output.requirements) != len(targets):
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
field_errors = self._claim_errors(normalized_output)
if field_errors:
return Rejected(WorkflowError(
ErrorCode.REQUIREMENTS_SPEC_INVALID,
"Requirements specification contains an unreadable or non-executable acceptance target.",
field_errors=tuple(field_errors),
))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_spec", state.working_head, value.model_dump(mode="json")),
)
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
source_ids = list(self.artifacts.read_source_index(task_id))
image_observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
warnings = [str(item) for item in image_observation.get("uncertainties") or () if str(item)]
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
warnings = [
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
*compiler_warnings,
]
requirements: list[dict[str, Any]] = []
claim_position = 1
for position, item in enumerate(value.requirements, 1):
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
claims: list[dict[str, Any]] = []
for claim in item.acceptance_claims:
deterministic = self.registry.definition(claim.claim_kind).deterministic
claims.append({
"claim_id": f"claim_{claim_position:03d}",
"claim_kind": claim.claim_kind,
"expected": claim.expected,
"verification_mode": "deterministic" if deterministic else "visual",
})
for claim in compiled.acceptance_claims:
definition = self.registry.definition(claim.claim_kind)
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
claim_position += 1
requirements.append({
"requirement_id": f"req_{position:03d}",
"source_ids": source_ids,
"statement": item.statement,
"assumptions": list(item.assumptions),
"acceptance_claims": claims,
})
spec_payload = {
"schema_version": "cad.requirements-spec.v1",
"summary": value.summary,
"assumptions": list(value.assumptions),
"requirements": [item.model_dump(mode="json") for item in value.requirements],
"image_observation_path": "documents/image-observation.json" if image_observation else "",
}
contract = {
"schema_version": "cad.requirements-contract.v3",
"task_id": task_id,
"summary": value.summary,
"assumptions": list(value.assumptions),
"requirements": requirements,
"verification_warnings": warnings,
}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
contract = {"schema_version": "cad.requirements-contract.v3.1", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
try:
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec_payload)
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(
state,
"requirements_approved",
requirements_spec_path=spec_path,
requirements_contract_path=contract_path,
clarification_path="",
)
result = Accepted({"phase": next_state.phase.value, "contract_path": contract_path})
if not self._commit(next_state, [{
"event": "requirements_contract_frozen",
"invocation_id": invocation_id,
"contract_hash": contract["contract_hash"],
"contract_path": contract_path,
"requirement_count": len(requirements),
"verification_warnings": warnings,
}], invocation, result):
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
return Rejected(self._stale())
self.ensure_rendered_contract_views(task_id, next_state)
return result
def ensure_rendered_contract_views(self, task_id: str, state: TaskState) -> None:
if not state.requirements_contract_path:
return
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
if not isinstance(contract, dict):
raise RuntimeError("Committed requirements contract is unavailable")
self.artifacts.write_requirements_contract(task_id, contract)
self.artifacts.write_text_once(task_id, "requirements.md", self._requirements_markdown(contract))
target = self._completion_target_markdown(contract)
self.artifacts.write_text_once(task_id, "completion-target.md", target)
def write_completion_result(
self,
task_id: str,
state: TaskState,
*,
claim_results: list[dict[str, Any]],
review: dict[str, Any],
) -> str:
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
visual = iter(review.get("visual_claims") or ())
rows = ["# Completion Result", "", f"Status: {'completed with risks' if contract.get('verification_warnings') else 'verified'}", ""]
rows = ["# Completion Result", "", "## Checklist", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
rows.append(f"## {requirement.get('statement')}")
statuses: list[str] = []
evidence: list[str] = []
for claim in requirement.get("acceptance_claims") or ():
if not isinstance(claim, dict):
continue
if claim.get("verification_mode") == "visual":
decision = next(visual, {})
status = str(decision.get("status") or "unknown")
evidence = str(decision.get("evidence") or "")
else:
result = by_id.get(str(claim.get("claim_id") or ""), {})
status = str(result.get("status") or "unknown")
evidence = json.dumps(result.get("evidence") or {}, ensure_ascii=False, sort_keys=True)
rows.append(f"- [{'x' if status == 'pass' else ' '}] {claim.get('claim_kind')}: {status}")
if evidence:
rows.append(f" - Evidence: {evidence}")
rows.append("")
warnings = [str(item) for item in contract.get("verification_warnings") or () if str(item)]
if warnings:
rows.extend(["## Verification Warnings", "", *[f"- {item}" for item in warnings], ""])
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
statuses.append(str(result.get("status") or "unknown"))
value = result.get("evidence")
if value:
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
rows.extend(f" - Evidence: {value}" for value in evidence)
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
def _record_clarification(
self,
task_id: str,
state: TaskState,
clarification: RequirementsClarification,
*,
invocation_id: str,
) -> Waiting | Rejected:
evidence = self.artifacts.read_source_requirements(task_id)
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
evidence += "\n" + json.dumps(observation, ensure_ascii=False)
missing = [quote for quote in clarification.source_quotes if quote not in evidence]
if missing:
return Rejected(WorkflowError(
ErrorCode.REQUIREMENTS_SPEC_INVALID,
"Clarification quotes must be copied from the user request or image observation.",
field_errors=tuple({"path": "/source_quotes", "message": f"Unknown quote: {quote}"} for quote in missing),
))
invocation = self.repository.begin_invocation(
task_id,
invocation_id,
self._key(task_id, "requirements_clarification", state.working_head, clarification.model_dump(mode="json")),
)
payload = {"schema_version": "cad.requirements-clarification.v1", **clarification.model_dump(mode="json")}
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
replay = self._replay(task_id, invocation_id)
if replay is not None:
return replay
state = self.repository.get_state(task_id)
if state is None or state.phase != phase:
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
errors = validator(document.markdown)
if errors:
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
if invocation.status == "finished" and invocation.result is not None:
return self._restore(invocation.result)
try:
path = self.artifacts.write_json_once(task_id, f"documents/requirements-clarification-{sha256(clarification.question.encode()).hexdigest()[:12]}.json", payload)
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
except OSError as error:
return self._park_for_storage_retry(state, str(error))
next_state = transition(state, "waiting_for_user", error=ErrorCode.WAITING_FOR_USER, clarification_path=path)
result = Waiting(WorkflowError(
ErrorCode.WAITING_FOR_USER,
clarification.question,
details={"questions": [clarification.question], "source_quotes": list(clarification.source_quotes)},
))
if not self._commit(next_state, [{
"event": "requirements_waiting_for_user",
"invocation_id": invocation_id,
"review_path": path,
"message": clarification.question,
"questions": [clarification.question],
"source_quotes": list(clarification.source_quotes),
}], invocation, result):
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
next_state = transition(state, event, **kwargs)
result = Accepted({"phase": next_state.phase.value, "path": written})
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
return Rejected(self._stale())
return result
def _replay(self, task_id: str, invocation_id: str) -> Accepted | Waiting | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
if invocation is None or invocation.status != "finished" or invocation.result is None:
return None
return self._restore(invocation.result)
def _checklist_items(self, task_id: str) -> list[str]:
path = self.artifacts.task_dir(task_id) / "completion-target.md"
text = path.read_text(encoding="utf-8") if path.is_file() else ""
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
for requirement_index, requirement in enumerate(output.requirements):
for claim_index, claim in enumerate(requirement.acceptance_claims):
try:
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
except ValueError:
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
return errors
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
normalized = output.model_copy(deep=True)
warnings: list[str] = []
for target, requirement in zip(targets, normalized.requirements, strict=True):
normalized_claims = []
for claim in requirement.acceptance_claims:
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
warnings.append(
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
)
claim.claim_kind = "visual"
claim.expected = {"description": target[:360]}
normalized_claims.append(claim)
continue
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
warnings.append(
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
)
continue
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
normalized_claims.append(claim)
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
"claim_kind": "visual",
"expected": {"description": target[:360]},
})]
return normalized, list(dict.fromkeys(warnings))
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted | Waiting:
if payload.get("result_type") == "waiting":
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
return Waiting(WorkflowError(
ErrorCode(str(error.get("code") or ErrorCode.WAITING_FOR_USER.value)),
str(error.get("message") or "Requirements need a user decision."),
tuple(error.get("field_errors") or ()),
bool(error.get("retryable")),
dict(error.get("details") or {}),
))
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
return False
if RequirementsCommandHandler._target_describes_finished_envelope(target):
return False
return any(
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
for item in claims
)
@staticmethod
def _target_describes_finished_envelope(target: str) -> bool:
lowered = target.lower()
return any(token in lowered for token in (
"overall",
"total",
"finished part",
"entire part",
"whole part",
"bounding box",
"envelope",
"",
"整体",
"成品",
"全高",
"包围盒",
))
@staticmethod
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
value = bbox_claim.expected.get("value")
if not isinstance(value, (int, float)):
return
for item in claims:
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
continue
expected = getattr(item, "expected", None)
if not isinstance(expected, dict) or "axial_span_mm" in expected:
continue
expected["axial_span_mm"] = value
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
return
@staticmethod
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
# Markdown is a human-facing semantic artifact. Its content is frozen
# verbatim and is not executable, so headings are guidance for the
# author rather than a server-enforced protocol.
return []
@staticmethod
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
errors: list[dict[str, str]] = []
if not values:
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
if len(values) != len(set(values)):
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
return errors
@staticmethod
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
return []
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
invocation = self.repository.get_invocation(task_id, invocation_id)
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
@staticmethod
def _restore(payload: dict[str, Any]) -> Accepted:
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted | Waiting) -> bool:
payload = {"result_type": "waiting", "error": result.error.payload()} if isinstance(result, Waiting) else {"result_type": "accepted", "payload": result.payload}
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result=payload)
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
@staticmethod
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest()
@staticmethod
def _requirements_markdown(contract: dict[str, Any]) -> str:
rows = ["# Requirements", "", str(contract.get("summary") or ""), ""]
assumptions = [str(item) for item in contract.get("assumptions") or () if str(item)]
if assumptions:
rows.extend(["## Assumptions", "", *[f"- {item}" for item in assumptions], ""])
rows.extend(["## Requirements", ""])
for item in contract.get("requirements") or ():
if not isinstance(item, dict):
continue
rows.append(f"- {item.get('statement')}")
rows.extend(f" - Assumption: {value}" for value in item.get("assumptions") or ())
return "\n".join(rows).rstrip() + "\n"
@staticmethod
def _completion_target_markdown(contract: dict[str, Any]) -> str:
rows = ["# Completion Target", ""]
for requirement in contract.get("requirements") or ():
if not isinstance(requirement, dict):
continue
rows.append(f"## {requirement.get('statement')}")
for claim in requirement.get("acceptance_claims") or ():
if isinstance(claim, dict):
rows.append(f"- [ ] {claim.get('claim_kind')}: {json.dumps(claim.get('expected') or {}, ensure_ascii=False, sort_keys=True)}")
rows.append("")
return "\n".join(rows).rstrip() + "\n"
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
@staticmethod
def _stale() -> WorkflowError:
@@ -312,9 +292,5 @@ class RequirementsCommandHandler:
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
self.repository.compare_and_swap(waiting, events=[{
"event": "waiting_retry",
"code": ErrorCode.STORAGE_FAILURE.value,
"message": message[:1000],
}])
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
+308 -45
View File
@@ -17,11 +17,12 @@ from pydantic import BaseModel
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.llm_contracts import (
CandidateReview, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, NextAction,
RequirementsAuthorOutput, RollbackCheckpoint, StatelessCandidateReview,
CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction,
RollbackCheckpoint, StatelessCandidateReview,
StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint,
StatelessTopologyRequest, TopologyRequest,
canonical_json_object, canonical_validate, canonical_validate_schema,
sanitize_compiled_requirements_arguments,
stateless_final_review_schema,
stateless_next_action_schema, stateless_rollback_checkpoint_schema,
raw_arguments_hash, validate_one_tool_call,
@@ -230,16 +231,6 @@ class WorkflowCoordinator:
if state is None:
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."}
return
if state.requirements_contract_path and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
try:
self.requirements.ensure_rendered_contract_views(task_id, state)
except Exception as error:
yield self._service_failure(
task_id,
state,
WorkflowError(ErrorCode.STORAGE_FAILURE, str(error)[:1000], retryable=True),
)
return
if state.phase == TaskPhase.COMPLETED:
yield "task_terminal", self._projected_terminal(task_id, state)
return
@@ -264,7 +255,7 @@ class WorkflowCoordinator:
yield self._service_failure(task_id, state, recovered.error)
return
continue
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
image_paths = self.artifacts.source_image_paths(task_id)
if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None:
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer")
@@ -299,8 +290,8 @@ class WorkflowCoordinator:
}])
yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"}
continue
spec_schema = self.requirements.spec_schema()
tools = [self._tool("submit_requirements_spec", spec_schema)]
document_schema = self.requirements.document_schema()
tools = [self._tool("write_requirements_document", document_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
@@ -321,8 +312,8 @@ class WorkflowCoordinator:
return
continue
name, raw, usage = result
validation = canonical_validate(raw, RequirementsAuthorOutput)
dynamic_error = canonical_validate_schema(raw, spec_schema) if not isinstance(validation, WorkflowError) else None
validation = canonical_validate(raw, MarkdownDocument)
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
@@ -333,7 +324,7 @@ class WorkflowCoordinator:
return
continue
invocation_id = self._invocation_id(task_id)
command = self.requirements.submit_spec(task_id, validation, invocation_id=invocation_id)
command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id)
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback)
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
@@ -341,7 +332,106 @@ class WorkflowCoordinator:
yield terminal
return
continue
yield "requirements_ready", self._event(task_id, name, self._result_payload(command), "waiting" if isinstance(command, Waiting) else "success", usage)
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
event_payload["markdown"] = validation.markdown
yield "requirements_document_ready", event_payload
feedback = []
continue
if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}:
document_schema = self.requirements.document_schema()
tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan"
tools = [self._tool(tool_name, document_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
validation = canonical_validate(raw, MarkdownDocument)
dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_plan(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool=tool_name)
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
event_payload = self._event(task_id, name, self._result_payload(command), "success", usage)
event_payload["markdown"] = validation.markdown
yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload
feedback = []
continue
if state.phase == TaskPhase.COMPILING_REQUIREMENTS:
compiler_schema = self.requirements.compiler_schema(task_id)
tools = [self._tool("compile_requirements_spec", compiler_schema)]
terminal = self._call_budget_terminal(task_id, state, call_budget, actor="author")
if terminal:
yield terminal
return
call_budget.record_attempt("author")
result = await self._author_turn(task_id, active_author, tools, feedback)
if isinstance(result, WorkflowError):
if result.code == ErrorCode.AUTHOR_FORMAT_INVALID:
terminal = self._format_failure(task_id, state, "author_turn", result, format_errors, feedback)
yield "tool_call", {"taskId": task_id, "tool": "author_turn", "status": "error", "result": result.payload()}
if terminal:
yield terminal
return
continue
active_author, terminal = self._transport_or_failure(task_id, state, result, active_author, transport_attempted)
if terminal:
yield terminal
return
continue
name, raw, usage = result
sanitized_raw = sanitize_compiled_requirements_arguments(raw)
if isinstance(sanitized_raw, WorkflowError):
validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw
else:
validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec)
dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None
if dynamic_error is not None:
validation = dynamic_error
if isinstance(validation, WorkflowError):
terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec")
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
if terminal:
yield terminal
return
continue
command = self.requirements.submit_compiled_spec(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback, tool="compile_requirements_spec")
yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage)
if terminal:
yield terminal
return
continue
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
feedback = []
continue
if state.phase == TaskPhase.AWAITING_ACTION:
@@ -472,11 +562,7 @@ class WorkflowCoordinator:
continue
name, raw, usage = result
if name == "record_geometry_conclusion":
diagnostic_schema = geometry_conclusion_schema(state.working_head, list(self.actions.diagnostic_evidence_refs(task_id, state)))
validation = canonical_validate(raw, GeometryConclusion)
dynamic_error = canonical_validate_schema(raw, diagnostic_schema) if not isinstance(validation, WorkflowError) else None
if dynamic_error is not None:
validation = dynamic_error
validation = canonical_validate(raw, StatelessGeometryConclusion)
if isinstance(validation, WorkflowError):
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage)
@@ -484,6 +570,13 @@ class WorkflowCoordinator:
yield terminal
return
continue
validation = GeometryConclusion(
working_head=state.working_head,
evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)),
root_cause=validation.root_cause,
decision=validation.decision,
corrective_intent=validation.corrective_intent,
)
command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id))
if isinstance(command, Rejected):
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
@@ -675,6 +768,11 @@ class WorkflowCoordinator:
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
continue
state = self.repository.get_state(task_id)
terminal = self._best_effort_terminal(task_id, state, ErrorCode.NO_PROGRESS_LIMIT, "The workflow reached its bounded turn limit.") if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
yield terminal
return
if state:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}])
@@ -683,6 +781,11 @@ class WorkflowCoordinator:
yield self._storage_failure(task_id, str(error))
except Exception as error:
state = self.repository.get_state(task_id)
terminal = self._best_effort_terminal(task_id, state, ErrorCode.FAILED_INTERNAL, str(error)[:1000]) if state else None
if terminal:
yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"}
yield terminal
return
if state and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL)
self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": str(error)[:1000]}])
@@ -711,6 +814,15 @@ class WorkflowCoordinator:
return None
details = budget.payload()
details["next_actor"] = actor
terminal = self._best_effort_terminal(
task_id,
state,
ErrorCode.CALL_BUDGET_EXHAUSTED,
"Configured model-call budget is exhausted; publishing the last executable checkpoint.",
budget=details,
)
if terminal:
return terminal
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
self.repository.compare_and_swap(failed, events=[{
"event": "call_budget_exhausted",
@@ -728,6 +840,49 @@ class WorkflowCoordinator:
"userActionRequired": False,
}
def _best_effort_terminal(
self,
task_id: str,
state: TaskState,
reason: ErrorCode,
message: str,
*,
budget: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any]] | None:
if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
return None
claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision))
visual_claims = [
{"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."}
for item in claim_results
if not item.get("deterministic")
]
try:
self.requirements.write_completion_result(
task_id,
state,
claim_results=claim_results,
review={"visual_claims": visual_claims},
)
except OSError:
return None
completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id))
if isinstance(completed, Rejected):
return None
return "task_terminal", {
"taskId": task_id,
"lifecycle": "completed",
"revisionId": state.active_revision,
"code": ErrorCode.BEST_EFFORT_COMPLETED.value,
"message": message,
"issues": completed.payload.get("issues") or [],
"verificationStatus": "completed_with_risks",
"verificationWarnings": completed.payload.get("issues") or [],
"completionResultPath": "completion-result.md",
"budget": budget or {},
"userActionRequired": False,
}
def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]:
"""Park a nonterminal task when a durable artifact operation fails."""
state = self.repository.get_state(task_id)
@@ -1045,6 +1200,8 @@ class WorkflowCoordinator:
return [self._tool("inspect_topology", StatelessTopologyRequest)]
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
description = "Submit exactly one CDSL feature for the pending action."
if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}:
description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane."
if action.atomic_id.startswith("hole_"):
description += (
" Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. "
@@ -1057,7 +1214,7 @@ class WorkflowCoordinator:
"feature.selector_tokens is required author input: copy one of the opaque tokens from "
"inspect_topology; the server resolves it to the host after validation."
)
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references))}}
fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}}
return [fragment]
def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
@@ -1076,18 +1233,41 @@ class WorkflowCoordinator:
state = self.repository.get_state(task_id)
if state is None:
return []
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS:
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
content = {
"protocol": "cad.v3.spec.v1",
"protocol": "cad.v3.1.markdown-first",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"user_clarifications": self._user_clarifications(task_id),
"instruction": (
"Return one complete bounded requirements specification. Use deterministic claims only when the supplied registry can execute them; otherwise use a scoped visual claim. "
"Unspecified design choices are assumptions and must not block generation. Return clarification only when two quoted source statements cannot both be followed and the user must choose; ask exactly one question. "
"Never return source, task, draft, requirement, claim, revision, candidate, action, head, or evidence identifiers. Do not solve semantic conflicts by changing a user value."
"Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. "
"Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers."
),
}
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
content = {
"protocol": "cad.v3.1.markdown-first",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"instruction": "Write # Completion Target with unique - [ ] checklist items. Each item must describe one independently observable final feature or condition. Do not add requirements not present in the frozen requirements document and do not include runtime identifiers.",
}
elif state.phase == TaskPhase.COMPILING_REQUIREMENTS:
content = {
"protocol": "cad.v3.1.markdown-first",
"source_requirements": self.artifacts.read_source_requirements(task_id),
"image_observation": self.artifacts.read_json(task_id, "documents/image-observation.json"),
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"verifier_registry": self.requirements.registry.expected_one_of_schema(),
"instruction": "Compile exactly one ordered verifier bundle for each checklist item. The checklist text and all IDs are service-owned: output only assumptions and acceptance claims. Use deterministic verifiers for measurable defaults recorded in Markdown; use visual only for non-measurable appearance.",
}
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
content = {
"protocol": "cad.v3.1.markdown-first",
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
"compiled_contract": self._requirements_contract(task_id, state),
"instruction": "Write # Modeling Plan with a short ordered list of feature-construction steps. It is a frozen execution guide only: do not add, remove, or reinterpret requirements and do not include runtime identifiers.",
}
else:
contract = self._requirements_contract(task_id, state) or {}
compact = [{
@@ -1117,7 +1297,7 @@ class WorkflowCoordinator:
for token, value in tokens.items() if token in allowed
][:16]
instruction = "The exact operation contract and eligible selector summary are attached. Submit one fragment; call inspect_topology only when the selector summary is marked truncated."
content = {"protocol": "cad.v3", "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
content = {"protocol": "cad.v3.1", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements_markdown": self._read_markdown(task_id, state.requirements_document_path), "completion_target_markdown": self._read_markdown(task_id, state.completion_target_path), "modeling_plan_markdown": self._read_markdown(task_id, state.modeling_plan_path), "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in self.actions.claim_summary(task_id, state)], "model_summary": self.actions.model_summary(task_id, state), "pending_action": self._public_pending_context(state), "operation_contract": self._public_operation_payload(operation_payload), "selector_summary": selector_summary, "selector_summary_truncated": bool(action is not None and selector_shape == "required" and len(self._selector_tokens_for_contract(operation or {}, self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)))) > len(selector_summary)), "recent_failures": self._recent_failure_constraints(task_id, state), "repair_diagnostics": self.actions.repair_diagnostics(task_id, state), "rollback_checkpoints": list(self.actions.checkpoint_tokens(task_id, state)) if self.actions.rollback_available(task_id, state) else [], "instruction": instruction}
messages: list[dict[str, Any]] = [{"role": "system", "content": "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON."}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}]
return [*messages, *feedback[-2:]]
@@ -1133,6 +1313,15 @@ class WorkflowCoordinator:
clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text})
return clarifications
def _read_markdown(self, task_id: str, relative_path: str) -> str:
if not relative_path:
return ""
try:
path = self.artifacts.artifact_path(task_id, relative_path)
return path.read_text(encoding="utf-8") if path.is_file() else ""
except (OSError, ValueError):
return ""
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
"""Expose the persisted requirement question when a task is parked.
@@ -1232,6 +1421,18 @@ class WorkflowCoordinator:
action = state.pending_action
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
@staticmethod
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
protocol = {
"system": "world_mm_right_handed",
"sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.",
"vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.",
"hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.",
}
if not state.active_revision:
protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y."
return protocol
@staticmethod
def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(payload, dict):
@@ -1265,7 +1466,7 @@ class WorkflowCoordinator:
topology = self.artifacts.read_topology(task_id, state.active_revision)
tokens = self.runtime.selector_tokens(topology)
references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision))
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references))}
return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
constraints: list[dict[str, Any]] = []
@@ -1334,10 +1535,25 @@ class WorkflowCoordinator:
def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None:
"""Bound no-side-effect model rejections; park real service failures."""
if error.code == ErrorCode.NO_PROGRESS_LIMIT:
current = self.repository.get_state(task_id) or state
terminal = self._best_effort_terminal(
task_id,
current,
ErrorCode.NO_PROGRESS_LIMIT,
"Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.",
)
if terminal:
return terminal
feedback[:] = [self._feedback(error)]
return None
if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID:
# Registry integrity is a deployment defect. Retrying a model with
# the same broken contract cannot repair it and must never consume
# the author-format budget.
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
if terminal:
return terminal
failed = transition(state, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "runtime_contract_invalid",
@@ -1350,6 +1566,27 @@ class WorkflowCoordinator:
"code": error.code.value,
"message": error.message,
}
if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID:
# A frozen verifier contract is service-owned input at this stage.
# CAD retries cannot repair it, so preserve the terminal diagnosis
# instead of parking the task or blaming the author fragment.
current = self.repository.get_state(task_id)
if current is not None and current.phase != TaskPhase.FAILED:
failed = transition(current, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "requirements_contract_execution_failed",
"tool": name,
"message": error.message,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": error.code.value,
"message": error.message,
"blockerType": "requirements_contract_invalid",
"userActionRequired": False,
"issues": [str(error.details.get("diagnostic") or error.message)],
}
if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED:
# A schema-valid fragment can still be impossible on the current
# geometry. This is not an author-format failure: preserve the
@@ -1368,8 +1605,8 @@ class WorkflowCoordinator:
if event.get("failure_class_fingerprint") == failure_class_fingerprint
]
if len(prior) >= 2:
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(failed, events=[{
next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT)
self.repository.compare_and_swap(next_state, events=[{
"event": "no_progress_limit",
"tool": name,
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
@@ -1379,15 +1616,16 @@ class WorkflowCoordinator:
"normalized_error_code": normalized_code,
"failure_class_fingerprint": failure_class_fingerprint,
}])
return "task_terminal", {
"taskId": task_id,
"lifecycle": "failed",
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
"message": "The same operation failure class made no progress after three attempts; the last checkpoint was preserved.",
"blockerType": "no_progress_limit",
"userActionRequired": False,
"revisionId": state.active_revision,
}
terminal = self._best_effort_terminal(
task_id,
next_state,
ErrorCode.NO_PROGRESS_LIMIT,
"The same operation failure made no progress; publishing the last executable checkpoint.",
)
if terminal:
return terminal
feedback[:] = [self._feedback(error)]
return None
next_state = transition(
state,
"runtime_precondition_rejected",
@@ -1419,6 +1657,9 @@ class WorkflowCoordinator:
return None
# A precondition result outside fragment submission is an invalid
# workflow implementation state, not a provider/service outage.
terminal = self._best_effort_terminal(task_id, state, error.code, error.message)
if terminal:
return terminal
failed = transition(state, "failed", error=error.code)
self.repository.compare_and_swap(failed, events=[{
"event": "runtime_precondition_rejected",
@@ -1432,6 +1673,16 @@ class WorkflowCoordinator:
"code": error.code.value,
"message": error.message,
}
if error.code in {
ErrorCode.CANDIDATE_BUILD_FAILED,
ErrorCode.CLAIM_VERIFICATION_FAILED,
ErrorCode.CANDIDATE_REVIEW_REJECTED,
}:
# The handler already preserved the last checkpoint and recorded
# the field/runtime diagnostic. Continue with that evidence; a
# planning miss is not an infrastructure terminal condition.
feedback[:] = [self._feedback(error)]
return None
model_rejection_codes = {
ErrorCode.AUTHOR_FORMAT_INVALID,
ErrorCode.AUTHOR_DECISION_REJECTED,
@@ -1446,6 +1697,14 @@ class WorkflowCoordinator:
feedback[:] = [self._feedback(error)]
if counters[name] < self.config.format_error_limit:
return None
terminal = self._best_effort_terminal(
task_id,
state,
ErrorCode.FAILED_AUTHOR_FORMAT,
f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.",
)
if terminal:
return terminal
failed = transition(state, "failed", error=ErrorCode.FAILED_AUTHOR_FORMAT)
self.repository.compare_and_swap(failed, events=[{"event": "failed_author_format", "tool": name, "field_errors": list(error.field_errors)}])
return "task_terminal", {
@@ -1464,6 +1723,8 @@ class WorkflowCoordinator:
error: WorkflowError,
counters: dict[str, int],
feedback: list[dict[str, Any]],
*,
tool: str = "compile_requirements_spec",
) -> tuple[str, dict[str, Any]] | None:
key = "requirements_spec"
counters[key] = counters.get(key, 0) + 1
@@ -1482,7 +1743,7 @@ class WorkflowCoordinator:
"lifecycle": "failed",
"code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value,
"message": "Requirements specification remained unreadable after one field-level correction.",
"tool": "submit_requirements_spec",
"tool": tool,
"field_errors": list(error.field_errors),
"userActionRequired": False,
}
@@ -1494,10 +1755,12 @@ class WorkflowCoordinator:
error: WorkflowError,
counters: dict[str, int],
feedback: list[dict[str, Any]],
*,
tool: str = "compile_requirements_spec",
) -> tuple[str, dict[str, Any]] | None:
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
return self._service_failure(task_id, state, error)
return self._requirements_format_failure(task_id, state, error, counters, feedback)
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
@staticmethod
def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
+5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
import shutil
from app.cad_agent.adapters.artifact_store import FileArtifactStore
from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
@@ -30,6 +31,10 @@ class V3Services:
def compose_v3(settings: Settings) -> V3Services:
repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3")
if repository.protocol_reset and settings.task_root.exists():
# Protocol 3.1 has no valid interpretation for structured-only task
# artifacts, so clear that task root together with its old database.
shutil.rmtree(settings.task_root)
artifacts = FileArtifactStore(settings.task_root)
runtime = ProfileCadRuntime(settings)
registry = default_registry()
+1
View File
@@ -29,6 +29,7 @@ class ErrorCode(StrEnum):
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
FAILED_INTERNAL = "FAILED_INTERNAL"
WAITING_FOR_USER = "WAITING_FOR_USER"
@@ -131,7 +131,7 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
raise OperationContractError("Operation candidate verifiers are duplicated")
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> dict[str, Any]:
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]:
"""Build the one-operation schema exposed for one pending action."""
validate_operation_contract(contract)
shape = contract["fragment_shape"]
@@ -174,15 +174,15 @@ def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], ref
properties: dict[str, Any] = {"feature": feature}
required = ["feature"]
if shape["sketch"] == "required":
properties["sketch"] = _sketch_schema()
properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"})
required.insert(0, "sketch")
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
Draft202012Validator.check_schema(schema)
return schema
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> list[dict[str, str]]:
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens)
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]:
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum)
return [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
@@ -193,10 +193,23 @@ def _point(size: int) -> dict[str, Any]:
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
def _sketch_schema() -> dict[str, Any]:
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
point2 = _point(2)
point3 = _point(3)
workplane = {"type": "object", "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, "required": ["origin_mm", "x_dir", "normal"], "additionalProperties": False}
workplane = {
"type": "object",
"description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.",
"properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)},
"required": ["origin_mm", "x_dir", "normal"],
"additionalProperties": False,
}
if root_xy_datum:
workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined."
workplane["properties"] = {
"origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3},
"x_dir": {"const": [1, 0, 0]},
"normal": {"const": [0, 0, 1]},
}
profile = {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
+27 -7
View File
@@ -9,7 +9,10 @@ from .errors import ErrorCode, WorkflowError
class TaskPhase(StrEnum):
DRAFTING_REQUIREMENTS = "DRAFTING_REQUIREMENTS"
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
AWAITING_ACTION = "AWAITING_ACTION"
ACTION_PENDING = "ACTION_PENDING"
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
@@ -47,6 +50,9 @@ class TaskState:
last_error: ErrorCode | None = None
retry_from_phase: TaskPhase | None = None
requirements_spec_path: str = ""
requirements_document_path: str = ""
completion_target_path: str = ""
modeling_plan_path: str = ""
clarification_path: str = ""
requirements_contract_path: str = ""
@@ -58,13 +64,16 @@ class TaskState:
# Legal state transitions. Events are intentionally terse persistence-neutral
# names used by command handlers and architecture tests.
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.DRAFTING_REQUIREMENTS, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS,
(TaskPhase.DRAFTING_REQUIREMENTS, "requirements_approved"): TaskPhase.AWAITING_ACTION,
(TaskPhase.DRAFTING_REQUIREMENTS, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.DRAFTING_MODELING_PLAN,
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
# User clarifications are durable task evidence. Resume on the same task
# so its frozen request remains authoritative
# instead of turning a clarification into a new CAD request.
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS,
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
@@ -83,6 +92,11 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
}
_TRANSITIONS.update({
(phase, "best_effort_completed"): TaskPhase.COMPLETED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_TRANSITIONS.update({
(phase, "failed"): TaskPhase.FAILED
for phase in TaskPhase
@@ -94,7 +108,10 @@ _TRANSITIONS.update({
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_RETRY_RESUMABLE_PHASES = frozenset({
TaskPhase.DRAFTING_REQUIREMENTS,
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
TaskPhase.DRAFTING_COMPLETION_TARGET,
TaskPhase.COMPILING_REQUIREMENTS,
TaskPhase.DRAFTING_MODELING_PLAN,
TaskPhase.AWAITING_ACTION,
TaskPhase.ACTION_PENDING,
TaskPhase.CANDIDATE_BUILDING,
@@ -122,7 +139,7 @@ def retry_resume_event(state: TaskState) -> str | None:
return f"resume_{state.retry_from_phase.value.lower()}"
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
"""Apply one legal transition and advance optimistic-concurrency version."""
target = _TRANSITIONS.get((state.phase, event))
if target is None:
@@ -144,6 +161,9 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
last_error=error,
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
)
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from copy import deepcopy
from math import atan2, isclose, pi, sqrt
from typing import Any, Callable
@@ -12,6 +13,7 @@ from jsonschema.exceptions import SchemaError
ClaimResult = dict[str, Any]
ClaimEvaluator = Callable[[dict[str, Any], dict[str, Any]], ClaimResult]
_DEFAULT_TOLERANCE_MM = 0.1
def _closed_object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
@@ -49,7 +51,13 @@ def _cylinder_axis_span(record: dict[str, Any]) -> float | None:
def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | None:
"""Return the inclusive axial interval of a cylindrical face's bounds."""
"""Return the inclusive axial interval of a cylindrical face's bounds.
B-rep face orientation is not a physical property of a cylindrical shell.
In particular, a two-sided extrusion can return its two half-walls with
opposite axis directions. Use one canonical direction for an undirected
cylinder axis so those halves share the same coordinate interval.
"""
geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {}
bbox = geometry.get("bbox_mm")
direction = _cylinder_axis_direction(record)
@@ -60,6 +68,9 @@ def _cylinder_axis_interval(record: dict[str, Any]) -> tuple[float, float] | Non
maximum = tuple(float(value) for value in bbox[3:])
except (TypeError, ValueError):
return None
dominant_index = max(range(3), key=lambda index: abs(direction[index]))
if direction[dominant_index] < 0:
direction = tuple(-component for component in direction)
projections = [
sum(direction[index] * point[index] for index in range(3))
for point in (
@@ -418,7 +429,7 @@ def _outer_cylindrical_surface(expected: dict[str, Any], facts: dict[str, Any])
max(interval[1] for interval in shell_intervals if interval is not None)
- min(interval[0] for interval in shell_intervals if interval is not None)
)
tolerance = float(expected["tolerance_mm"])
tolerance = float(expected.get("tolerance_mm", _DEFAULT_TOLERANCE_MM))
evidence.update({"axial_spans_mm": actual_spans, "tolerance_mm": tolerance})
return _pass(evidence) if all(abs(span - float(axial_span)) <= tolerance for span in actual_spans) else _fail({"diameter_mm": expected["diameter_mm"], "expected_axial_span_mm": axial_span, "actual_axial_spans_mm": actual_spans, "tolerance_mm": tolerance})
@@ -790,11 +801,13 @@ class VerifierRegistry:
except KeyError as error:
raise ValueError(f"VERIFIER_UNAVAILABLE: {claim_kind}") from error
def expected_one_of_schema(self) -> dict[str, Any]:
def expected_one_of_schema(self, *, exclude_claim_kinds: set[str] | frozenset[str] | tuple[str, ...] = ()) -> dict[str, Any]:
excluded = set(exclude_claim_kinds)
return {
"oneOf": [
_closed_object({"claim_kind": {"const": item.claim_kind}, "expected": item.expected_schema}, ["claim_kind", "expected"])
for item in self._definitions.values()
if item.claim_kind not in excluded
]
}
@@ -806,6 +819,19 @@ class VerifierRegistry:
for error in sorted(validator.iter_errors(expected), key=lambda item: (list(item.absolute_path), item.message))
]
def normalize_expected(self, claim_kind: str, expected: dict[str, Any]) -> dict[str, Any]:
"""Apply protocol defaults before a compiled contract is frozen.
These defaults describe verifier mechanics, never user geometry. The
outer-cylinder verifier can match a diameter without a tolerance, but
measuring its optional axial span needs one. Persist the default so
the resulting contract is complete and independently reproducible.
"""
normalized = deepcopy(expected)
if claim_kind == "outer_cylindrical_surface" and "axial_span_mm" in normalized:
normalized.setdefault("tolerance_mm", _DEFAULT_TOLERANCE_MM)
return normalized
def evaluate(self, claim_kind: str, expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
errors = self.validate_expected(claim_kind, expected)
if errors:
+164
View File
@@ -0,0 +1,164 @@
"""Run real-provider CAD tasks with a usable-model success criterion.
This evaluator is intentionally narrower than the release gate in ``live.py``.
It answers one operational question: can the current workflow reliably finish
ordinary prompts with a downloadable/previewable checkpoint, even if some
acceptance claims remain best-effort warnings.
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import replace
from datetime import datetime, timezone
import json
from pathlib import Path
import secrets
import sys
from typing import Any
from app.cad_agent.application.workflow import ModelIdentity
from app.cad_agent.composition import compose_v3
from app.settings import BACKEND_ROOT, get_settings
DEFAULT_PROMPTS = (
"生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。",
"生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。",
"生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。",
)
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.")
parser.add_argument("--author-provider")
parser.add_argument("--author-model")
parser.add_argument("--review-provider")
parser.add_argument("--review-model")
parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.")
parser.add_argument("--max-wall-seconds", type=int, default=1200)
parser.add_argument("--max-model-calls", type=int, default=60)
return parser.parse_args()
def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str:
terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {})
for source in (terminal, projection):
code = source.get("code") or source.get("last_error") if isinstance(source, dict) else ""
if isinstance(code, str) and code:
return code
return ""
def _artifact_ok(artifact_root: Path, revision_id: str) -> bool:
if not revision_id:
return False
revision_root = artifact_root / "revisions" / revision_id
return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json"))
def _classify_failure(code: str, projection: dict[str, Any]) -> str:
if str(projection.get("lifecycle") or "") == "waiting_retry":
return "configuration_or_service"
if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}:
return "code_or_flow"
if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}:
return "configuration_or_service"
if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}:
return "model_output_or_best_effort"
return "unknown"
async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]:
settings = get_settings()
try:
author_provider, author_model = settings.resolve_model(arguments.author_provider, arguments.author_model)
if arguments.review_provider or arguments.review_model:
review_provider, review_model = settings.resolve_model(arguments.review_provider, arguments.review_model)
else:
review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model)
except ValueError as error:
return {"status": "blocked", "error": str(error), "results": []}
isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations")
services = compose_v3(isolated)
services.workflow.config = replace(
services.workflow.config,
max_turns=max(8, arguments.max_model_calls * 3),
max_model_calls=arguments.max_model_calls,
)
prompts = tuple(arguments.prompt or DEFAULT_PROMPTS)
results: list[dict[str, Any]] = []
for index, prompt in enumerate(prompts, start=1):
task_id = f"cad_{secrets.token_hex(6)}"
services.workflow.create_task(task_id, prompt)
events: list[dict[str, Any]] = []
started = datetime.now(timezone.utc)
try:
async with asyncio.timeout(arguments.max_wall_seconds):
async for name, payload in services.workflow.run(
task_id=task_id,
author=ModelIdentity(author_provider.id, author_model.id),
reviewer=ModelIdentity(review_provider.id, review_model.id),
):
events.append({"name": name, "payload": payload})
except TimeoutError:
events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}})
projection = services.repository.get_task_projection(task_id) or {}
artifact_root = (isolated.task_root / task_id).resolve()
revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "")
code = _result_code(events, projection)
usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id)
results.append({
"index": index,
"task_id": task_id,
"prompt": prompt,
"success": usable,
"failure_layer": "" if usable else _classify_failure(code, projection),
"code": code,
"phase": projection.get("phase"),
"lifecycle": projection.get("lifecycle"),
"active_revision": revision_id,
"verification_status": projection.get("verification_status"),
"artifact_root": str(artifact_root),
"duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000),
"usage": services.repository.usage_summary(task_id),
"event_audit": [
{
"name": item.get("name"),
"status": (item.get("payload") or {}).get("status"),
"lifecycle": (item.get("payload") or {}).get("lifecycle"),
"code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"),
"tool": (item.get("payload") or {}).get("tool"),
}
for item in events
],
})
return {
"status": "passed" if results and all(item["success"] for item in results) else "failed",
"schema_version": "cad.usable-smoke.v1",
"author": {"provider": author_provider.id, "model": author_model.id},
"reviewer": {"provider": review_provider.id, "model": review_model.id},
"results": results,
}
def main() -> int:
arguments = _arguments()
report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
report_root.mkdir(parents=True, exist_ok=True)
try:
result = asyncio.run(_run(arguments, report_root))
except KeyboardInterrupt:
raise
except BaseException as error:
result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []}
result.update({"report_root": str(report_root.resolve())})
report_path = report_root / "usable-smoke-report.json"
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False))
return 0 if result["status"] == "passed" else 2
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -88,6 +88,7 @@ class CadRuntime(Protocol):
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: ...
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: ...
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
def rebuild_best_effort(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: ...
class ModelGateway(Protocol):
+25 -4
View File
@@ -154,11 +154,16 @@ async def read_task(task_id: str) -> JSONResponse:
task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None
task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None
task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary"))
task["requirements_markdown"] = (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").read_text(encoding="utf-8") if (agent.v3.artifacts.task_dir(safe_id) / "requirements.md").is_file() else None
target_path = agent.v3.artifacts.task_dir(safe_id) / "completion-target.md"
task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"])
requirements_path = agent.v3.artifacts.artifact_path(safe_id, state.requirements_document_path) if state is not None and state.requirements_document_path else None
task["requirements_markdown"] = requirements_path.read_text(encoding="utf-8") if requirements_path and requirements_path.is_file() else None
target_path = agent.v3.artifacts.artifact_path(safe_id, state.completion_target_path) if state is not None and state.completion_target_path else None
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.modeling_plan_path) if state is not None and state.modeling_plan_path else None
result_path = agent.v3.artifacts.task_dir(safe_id) / "completion-result.md"
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path.is_file() else None
task["completion_target_path"] = "completion-target.md" if target_path.is_file() else ""
task["completion_target_markdown"] = target_path.read_text(encoding="utf-8") if target_path and target_path.is_file() else None
task["completion_target_path"] = state.completion_target_path if target_path and target_path.is_file() else ""
task["modeling_plan_markdown"] = plan_path.read_text(encoding="utf-8") if plan_path and plan_path.is_file() else None
task["modeling_plan_path"] = state.modeling_plan_path if plan_path and plan_path.is_file() else ""
task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path.is_file() else None
task["completion_result_path"] = "completion-result.md" if result_path.is_file() else ""
task["usage"] = agent.v3.repository.usage_summary(safe_id)
@@ -203,6 +208,22 @@ def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[st
return result
def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Expose server-evaluated checklist progress without parsing Markdown."""
by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)}
progress: list[dict[str, Any]] = []
for requirement in (contract or {}).get("requirements") or ():
if not isinstance(requirement, dict):
continue
statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
progress.append({
"requirement_id": str(requirement.get("requirement_id") or ""),
"statement": str(requirement.get("statement") or ""),
"status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending",
})
return progress
@app.delete("/v1/tasks/{task_id}")
async def cancel_task(task_id: str) -> JSONResponse:
try:
+4 -1
View File
@@ -33,7 +33,10 @@ def _response_language(text: str) -> str:
_EVENT_LABELS = {
"image_observation": "参考图片观察",
"requirements_ready": "需求规格已就绪",
"requirements_document_ready": "需求文档已冻结",
"completion_target_ready": "完成目标已冻结",
"requirements_compiled": "需求合同已编译",
"modeling_plan_ready": "建模计划已冻结",
"completion_result_ready": "完成结果已就绪",
"model_protocol_check": "模型协议检查",
"action_selection": "动作选择",
+18 -1
View File
@@ -8,7 +8,24 @@ from __future__ import annotations
from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
try:
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
except ModuleNotFoundError as exc:
# The legacy rebuild façade optionally depends on cdsl_importer, which is
# not present in the standalone runtime distribution. Keep importing the
# executable session runtime possible; callers of the legacy façade get a
# precise import error when they invoke it.
if exc.name != "cdsl_importer":
raise
def _legacy_rebuild_unavailable(*_args, _missing=exc, **_kwargs):
raise RuntimeError("legacy rebuild facade requires the optional cdsl_importer package") from _missing
compare_with_gold = _legacy_rebuild_unavailable
compile_cdsl_to_pack = _legacy_rebuild_unavailable
run_cdsl_only = _legacy_rebuild_unavailable
run_engine = _legacy_rebuild_unavailable
run_rebuild = _legacy_rebuild_unavailable
from .semantic_validation import validate_semantic_cdsl
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
+454 -76
View File
@@ -20,11 +20,20 @@ from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher
from app.cad_agent.adapters.review_gateway import RenderedReviewGateway
from app.cad_agent.adapters.runtime import ProfileCadRuntime
from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository
from app.cad_agent.adapters.verifier import RegistryVerifierExecutor
from app.cad_agent.application.capabilities import cached_model_capability, conformance_hash, conformance_tools, verify_model_capability
from app.cad_agent.application.action_handlers import ActionCommandHandler
from app.cad_agent.application.llm_contracts import (
RequirementsAuthorOutput,
CandidateReview,
CompiledRequirementsSpec,
MarkdownDocument,
NextAction,
StatelessCandidateReview,
requirements_spec_schema,
StatelessGeometryConclusion,
canonical_validate,
canonical_validate_schema,
compiled_requirements_schema,
sanitize_compiled_requirements_arguments,
stateless_final_review_schema,
stateless_next_action_schema,
stateless_rollback_checkpoint_schema,
@@ -34,7 +43,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler
from app.cad_agent.application.results import Accepted, Rejected, Waiting
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
from app.cad_agent.domain.errors import ErrorCode
from app.cad_agent.domain.operation_contract import fragment_schema
from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment
from app.cad_agent.domain.state import TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
from app.cad_agent.domain.verifier_registry import default_registry
from app.models.contracts import ChatMessage
@@ -63,20 +72,48 @@ def settings(root: Path) -> Settings:
)
def ready_spec(*, spacing: float = 60.0) -> RequirementsAuthorOutput:
return RequirementsAuthorOutput.model_validate({
"outcome": "ready",
"summary": "Circular flange with an eight-hole pattern.",
"assumptions": ["Dimensions use millimetres."],
"requirements": [{
"statement": f"Use eight holes with a declared spacing of {spacing:g} degrees.",
"assumptions": [],
"acceptance_claims": [{
"claim_kind": "visual",
"expected": {"description": f"Eight holes are shown with the requested {spacing:g} degree declaration."},
}],
}],
})
def requirements_document() -> MarkdownDocument:
return MarkdownDocument(markdown="""# Design Understanding
Simple functional flange.
# Explicit User Requirements
- Create a simple flange.
# Engineering Defaults and Assumptions
- Use a circular body, central through bore, and four equally spaced mounting holes.
# Dimensions and Coordinate Convention
- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35.
# Open Uncertainties
- None.
""")
def completion_target() -> MarkdownDocument:
return MarkdownDocument(markdown="""# Completion Target
- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness.
- [ ] Centered 30 mm through bore.
- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius.
""")
def compiled_flange() -> CompiledRequirementsSpec:
return CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "single_connected_body", "expected": {}}, {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "tolerance_mm": 0.1}}, {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
]})
def modeling_plan() -> MarkdownDocument:
return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n")
def walk_keys(value: object) -> set[str]:
@@ -94,36 +131,62 @@ def walk_keys(value: object) -> set[str]:
class CadV3ProtocolTests(unittest.TestCase):
def test_state_machine_has_no_requirements_review_phase(self) -> None:
self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase})
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0)
approved = transition(state, "requirements_approved", requirements_contract_path="documents/contract.json")
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0)
document = transition(state, "requirements_document_written", requirements_document_path="requirements.md")
target = transition(document, "completion_target_written", completion_target_path="completion-target.md")
compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json")
approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md")
self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION)
self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()})
def test_waiting_retry_resumes_exact_source_phase(self) -> None:
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS, 0)
state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0)
waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING)
self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements")
self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document")
resumed = transition(waiting, retry_resume_event(waiting) or "")
self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS)
self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT)
self.assertIsNone(resumed.retry_from_phase)
def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
schemas = [
requirements_spec_schema(default_registry().expected_one_of_schema()),
MarkdownDocument.model_json_schema(),
compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2),
stateless_next_action_schema(list(runtime.supported_atomic_ids())),
StatelessCandidateReview.model_json_schema(),
StatelessGeometryConclusion.model_json_schema(),
stateless_final_review_schema(2),
]
forbidden = {
"task_id", "working_head", "requirement_id", "requirement_ids", "claim_id",
"candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids",
"draft_id", "attachment_id",
"draft_id", "attachment_id", "record_ids",
}
for schema in schemas:
self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden)
def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None:
schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)
raw = json.dumps({
"assumptions": ["top-level notes from the compiler are not executable"],
"requirements": [{
"statement": "model-added copy of the checklist text",
"assumptions": [],
"acceptance_claims": [{
"claim_kind": "single_connected_body",
"expected": {},
"evidence": "not part of the compiler contract",
}],
}],
})
sanitized = sanitize_compiled_requirements_arguments(raw)
self.assertIsInstance(sanitized, str)
self.assertIsNone(canonical_validate_schema(sanitized, schema))
parsed = canonical_validate(sanitized, CompiledRequirementsSpec)
self.assertIsInstance(parsed, CompiledRequirementsSpec)
self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body")
def test_dynamic_tokens_are_enum_constrained(self) -> None:
rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"])
self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]})
@@ -133,28 +196,363 @@ class CadV3ProtocolTests(unittest.TestCase):
selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"]
self.assertEqual(selector, {"enum": ["selector_one"]})
def test_requirements_spec_freezes_once_and_preserves_user_value(self) -> None:
def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
contract = runtime.operation_contract("extrude_add_blind")
fragment = {
"sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}},
}
self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True))
fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6]
self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), [])
def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
runtime = ProfileCadRuntime(settings(Path(temporary)))
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "partial_rebuild",
"geometry": {"sketches": [{
"id": "sketch_001",
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
}]},
"features": [
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": [], "sketch_id": "sketch_001"},
{"id": "feature_002", "atomic_id": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]},
],
}
rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate")
self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"])
self.assertEqual(len(failures), 1)
self.assertEqual(failures[0]["feature_id"], "feature_002")
def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None:
class PassingVerifier:
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
return [
{
"claim_id": str(claim.get("claim_id") or ""),
"claim_kind": str(claim.get("claim_kind") or ""),
"deterministic": True,
"status": "pass",
"evidence": {},
}
for claim in claims
]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
runtime = ProfileCadRuntime(settings(root))
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
actions = ActionCommandHandler(repository, artifacts, runtime, PassingVerifier())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a flange.")
artifacts.initialize_task(task_id, "Create a flange.")
requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
state = repository.get_state(task_id)
proposal = NextAction(
working_head=state.working_head,
intent="Create a two-feature batch.",
requirement_ids=["req_001"],
atomic_id="extrude_add_blind",
expected_change="Keep the executable part of the batch.",
)
self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted)
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}},
}
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "partial_batch",
"geometry": {"sketches": []},
"features": [
{"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []},
{"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]},
],
}
audit = {
"schema_version": "cad.v3.fragment-audit.v1",
"atomic_id": "extrude_add_blind",
"fragment_hash": "hash",
"contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"],
"assigned_feature_ids": ["feature_001", "feature_002"],
"assigned_sketch_ids": [],
"selector_snapshot_id": "",
"selector_tokens": [],
"reference_snapshot_id": "",
"reference_tokens": [],
}
rebuilt = {
"executed_feature_ids": ["feature_001"],
"health": {"solid_count": 1},
"topology": {"records": []},
"report": {},
"render_manifest": {},
"paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"},
}
operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}]
with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)):
result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment")
self.assertIsInstance(result, Accepted)
reviewing = repository.get_state(task_id)
self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW)
candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {}
self.assertEqual(candidate["executed_feature_ids"], ["feature_001"])
self.assertEqual(candidate["operation_failures"], operation_failures)
def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None:
state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True)
completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False)
self.assertEqual(completed.phase, TaskPhase.COMPLETED)
self.assertFalse(completed.repair_required)
def test_review_rejection_publishes_the_executable_checkpoint_for_repair(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
runtime = ProfileCadRuntime(settings(root))
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(default_registry()))
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a simple flange.")
artifacts.initialize_task(task_id, "Create a simple flange.")
requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
state = repository.get_state(task_id)
proposal = NextAction(
working_head=state.working_head,
intent="Create the base body.",
requirement_ids=["req_001"],
atomic_id="extrude_add_blind",
expected_change="Create the circular body.",
)
self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted)
fragment = {
"sketch": {
"workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 50},
},
"feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}},
}
self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted)
reviewing = repository.get_state(task_id)
candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {}
review = CandidateReview(
candidate_id=reviewing.candidate_id,
working_head=reviewing.pending_action.working_head,
verdict="reject",
claim_coverage=[
{"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []}
for item in candidate["claim_results"]
],
evidence=["Base body is executable."],
issues=["The bore and bolt holes remain to be added."],
)
result = actions.record_candidate_review(task_id, review, invocation_id="review")
self.assertIsInstance(result, Accepted)
self.assertEqual(result.payload["status"], "accepted_with_issues")
published = repository.get_state(task_id)
self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION)
self.assertEqual(published.active_revision, "rev_001")
self.assertTrue(published.repair_required)
completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort")
self.assertIsInstance(completed, Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED)
def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
configured = settings(root)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
runtime = ProfileCadRuntime(configured)
initial = repository.create_task("cad_123456abcdef", "Create a flange.")
document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md")
target = transition(document, "completion_target_written", completion_target_path="completion-target.md")
compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json")
awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True)
self.assertTrue(repository.compare_and_swap(document))
self.assertTrue(repository.compare_and_swap(target))
self.assertTrue(repository.compare_and_swap(compiled))
self.assertTrue(repository.compare_and_swap(awaiting, events=[{
"event": "geometry_conclusion",
"decision": "rollback",
"working_head": awaiting.working_head,
}]))
actions = ActionCommandHandler(repository, artifacts, runtime, default_registry())
self.assertFalse(actions.rollback_available(awaiting.task_id))
def test_geometry_conclusion_is_stateless_for_the_author(self) -> None:
schema = StatelessGeometryConclusion.model_json_schema()
self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema))
def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None:
def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]:
return {
"record_id": record_id,
"geometry": {
"surface_type": "cylinder",
"cylinder_role": "outer",
"radius_mm": 60.0,
"axis_origin_mm": [0.0, 0.0, 0.0],
"axis_direction": direction,
"bbox_mm": bbox,
},
}
facts = {"topology": {"records": [
outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]),
outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]),
]}}
result = default_registry().evaluate(
"outer_cylindrical_surface",
{"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0},
facts,
)
self.assertEqual(result["status"], "pass")
self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0])
self.assertEqual(result["evidence"]["tolerance_mm"], 0.1)
def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Use 8 holes around a full circle at 60 degrees.")
artifacts.initialize_task(task_id, "Use 8 holes around a full circle at 60 degrees.")
result = handler.submit_spec(task_id, ready_spec(spacing=60), invocation_id="requirements_once")
repository.create_task(task_id, "Create a flange.")
artifacts.initialize_task(task_id, "Create a flange.")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 100, "axial_span_mm": 10, "count": 1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 10, "count": 4, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
first_claim = contract["requirements"][0]["acceptance_claims"][0]
self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1)
def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a simple flange.")
artifacts.initialize_task(task_id, "Create a simple flange.")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coaxial", "expected": {"record_ids": ["outer", "bore"], "tolerance": 0.01}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}}]},
{"assumptions": [], "acceptance_claims": [{"claim_kind": "coplanar", "expected": {"record_ids": ["top_face", "bottom_face"], "tolerance_mm": 0.1}}]},
]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
claims = [claim for requirement in contract["requirements"] for claim in requirement["acceptance_claims"]]
self.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "visual"])
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "visual"])
self.assertTrue(any("coaxial verifier" in warning for warning in contract["verification_warnings"]))
self.assertTrue(any("coplanar verifier" in warning for warning in contract["verification_warnings"]))
def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a stepped hub adapter.")
artifacts.initialize_task(task_id, "Create a stepped hub adapter.")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target
- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness.
"""), invocation_id="completion_target")
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{
"assumptions": [],
"acceptance_claims": [
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}},
{"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.1}},
],
}]})
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="requirements_compile"), Accepted)
state = repository.get_state(task_id)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
claims = contract["requirements"][0]["acceptance_claims"]
self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"])
self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12)
self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"]))
def test_requirements_markdown_is_not_rejected_for_missing_headings(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a flange.")
artifacts.initialize_task(task_id, "Create a flange.")
result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown")
self.assertIsInstance(result, Accepted)
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET)
def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None:
action = NextAction(
working_head="cad_123456abcdef:root:v4",
intent="Create the flange body.",
requirement_ids=[f"req_{position:03d}" for position in range(1, 8)],
atomic_id="extrude_add_blind",
expected_change="Add the first solid body.",
)
self.assertEqual(len(action.requirement_ids), 7)
def test_markdown_documents_freeze_before_compiled_flange_contract(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a simple flange.")
artifacts.initialize_task(task_id, "Create a simple flange.")
self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted)
self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted)
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted)
self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), Accepted)
state = repository.get_state(task_id)
self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION)
contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
requirement = contract["requirements"][0]
self.assertIn("60 degrees", requirement["statement"])
self.assertIn("60 degree", requirement["acceptance_claims"][0]["expected"]["description"])
self.assertNotIn("45 degrees", requirement["statement"])
self.assertNotIn("applied_normalizations", contract)
self.assertEqual(len(contract["requirements"]), 3)
claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]}
self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds))
self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file())
self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file())
self.assertFalse((artifacts.task_dir(task_id) / "completion.md").exists())
self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file())
def test_invalid_verifier_contract_is_rejected_without_state_change(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -163,44 +561,18 @@ class CadV3ProtocolTests(unittest.TestCase):
artifacts = FileArtifactStore(root / "tasks")
handler = RequirementsCommandHandler(repository, artifacts, default_registry())
task_id = "cad_123456abcdef"
initial = repository.create_task(task_id, "Create one solid.")
repository.create_task(task_id, "Create one solid.")
artifacts.initialize_task(task_id, "Create one solid.")
value = RequirementsAuthorOutput.model_validate({
"outcome": "ready", "summary": "One solid", "assumptions": [],
"requirements": [{"statement": "One solid", "assumptions": [], "acceptance_claims": [
{"claim_kind": "solid_count_equals", "expected": {"value": 0}},
]}],
})
result = handler.submit_spec(task_id, value, invocation_id="invalid_spec")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3})
before = repository.get_state(task_id)
result = handler.submit_compiled_spec(task_id, invalid, invocation_id="invalid_spec")
self.assertIsInstance(result, Rejected)
self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID)
self.assertEqual(repository.get_state(task_id), initial)
self.assertEqual(repository.get_state(task_id), before)
def test_clarification_uses_same_task_and_one_question(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
artifacts = FileArtifactStore(root / "tasks")
runtime = ProfileCadRuntime(settings(root))
requirements = RequirementsCommandHandler(repository, artifacts, default_registry())
workflow = WorkflowCoordinator(WorkflowConfig(4, 2), repository, artifacts, runtime, None, None, requirements, None)
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Use four holes and use six holes.")
artifacts.initialize_task(task_id, "Use four holes and use six holes.")
value = RequirementsAuthorOutput.model_validate({
"outcome": "clarification", "source_quotes": ["four holes", "six holes"],
"question": "Should the part use four holes or six holes?",
})
result = requirements.submit_spec(task_id, value, invocation_id="clarify_once")
self.assertIsInstance(result, Waiting)
waiting = repository.get_state(task_id)
terminal = workflow.waiting_for_user_terminal(task_id, waiting)
self.assertEqual(terminal["questions"], ["Should the part use four holes or six holes?"])
self.assertTrue(terminal["userActionRequired"])
self.assertTrue(workflow.resume_with_user_clarification(task_id, "Use six holes.", message_id="user_reply"))
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS)
def test_contract_views_and_completion_result_share_one_spec(self) -> None:
def test_completion_result_reports_frozen_checklist(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
repository = SqliteTaskRepository(root / "state.sqlite3")
@@ -209,20 +581,23 @@ class CadV3ProtocolTests(unittest.TestCase):
task_id = "cad_123456abcdef"
repository.create_task(task_id, "Create a coherent flange.")
artifacts.initialize_task(task_id, "Create a coherent flange.")
handler.submit_spec(task_id, ready_spec(), invocation_id="requirements_once")
handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document")
handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target")
handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile")
handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan")
state = repository.get_state(task_id)
path = handler.write_completion_result(
task_id, state,
claim_results=[{"claim_id": "claim_001", "deterministic": False, "status": "pending"}],
review={"visual_claims": [{"status": "pass", "evidence": "Reference and render match."}]},
claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)],
review={"visual_claims": []},
)
self.assertEqual(path, "completion-result.md")
result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8")
target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8")
requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8")
self.assertIn("eight-hole pattern", requirements)
self.assertIn("visual", target)
self.assertIn("visual: pass", result)
self.assertIn("Engineering Defaults", requirements)
self.assertIn("Centered 30 mm through bore", target)
self.assertIn("Centered 30 mm through bore.: pass", result)
def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
@@ -231,6 +606,9 @@ class CadV3ProtocolTests(unittest.TestCase):
with sqlite3.connect(database) as connection:
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")}
self.assertIn("requirements_spec_path", columns)
self.assertIn("requirements_document_path", columns)
self.assertIn("completion_target_path", columns)
self.assertIn("modeling_plan_path", columns)
self.assertIn("clarification_path", columns)
self.assertNotIn("requirements_draft_path", columns)
self.assertNotIn("requirements_review_path", columns)
@@ -242,7 +620,7 @@ class CadV3ProtocolTests(unittest.TestCase):
reviewer = conformance_tools(runtime, role="reviewer")
author_names = {item["function"]["name"] for item in author}
reviewer_names = {item["function"]["name"] for item in reviewer}
self.assertIn("submit_requirements_spec", author_names)
self.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(author_names))
self.assertNotIn("review_requirements", author_names | reviewer_names)
self.assertNotIn("get_cdsl_operation_contract", author_names)
self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"})
@@ -336,7 +714,7 @@ class CadV3ProtocolTests(unittest.TestCase):
))
self.assertIsNone(result)
self.assertEqual(verify.await_count, 2)
self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS)
self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT)
events = [queue.get_nowait(), queue.get_nowait()]
self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"])
+3
View File
@@ -0,0 +1,3 @@
output/
__pycache__/
*.pyc
+16
View File
@@ -0,0 +1,16 @@
# CADFS to CDSL
Converts the local CADFS FeatureScript RP corpus to self-contained CDSL 1.1,
rebuilds runtime-eligible documents with the current CDSL engine, and compares
the rebuilt B-rep with the source STEP.
```bash
PYTHONPATH=backend:. python -m cadfs_to_cdsl scan
PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline --sample-id 00000173
PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline --limit 100 --seed 0
PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline
```
All stages are resumable. Use `--force` after changing converter behavior.
The original CADFS directory is read-only; generated evidence is written under
`cadfs_to_cdsl/output/samples/<sample_id>/`.
+4
View File
@@ -0,0 +1,4 @@
"""CADFS FeatureScript to CDSL conversion and validation pipeline."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import argparse, json
from pathlib import Path
from .pipeline import load_samples, run_stage, scan, select_samples
from .reports import generate_reports, read_json
DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test")
DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output")
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP")
commands = parser.add_subparsers(dest="command", required=True)
for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline"):
command = commands.add_parser(name)
command.add_argument("--input", type=Path, default=DEFAULT_INPUT)
command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
if name != "scan":
command.add_argument("--sample-id", action="append")
command.add_argument("--offset", type=int, default=0)
command.add_argument("--limit", type=int)
command.add_argument("--seed", type=int)
command.add_argument("--workers", type=int, default=1)
command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp")
command.add_argument("--force", action="store_true")
command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv); args.output.mkdir(parents=True, exist_ok=True)
if args.command == "scan":
records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")}
elif args.command == "report":
manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()]
result = generate_reports(args.output, records)
else:
if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8")
samples = select_samples(load_samples(args.input, args.output), sample_ids=args.sample_id, offset=args.offset, limit=args.limit, seed=args.seed)
if args.timeout_seconds <= 0: raise ValueError("--timeout-seconds must be positive")
records = run_stage(args.command, samples, args.output, force=args.force, compare_mode=args.compare_mode, timeout_seconds=args.timeout_seconds, workers=args.workers)
counts: dict[str, int] = {}
for record in records: counts[record["status"]] = counts.get(record["status"], 0) + 1
result = {"sample_count": len(records), "statuses": counts, "summary": str(args.output / "summary.json")}
print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True)); return 0
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
STRICT = {"surface_mm": 0.01, "bbox_mm": 0.01, "volume_relative": 1e-5, "area_relative": 1e-5}
RP = {"surface_mm": 0.02, "bbox_mm": 0.02, "volume_relative": 0.005, "area_relative": 0.005}
def _assess(report: dict[str, Any], limits: dict[str, float]) -> dict[str, Any]:
surface = report["surface"]; metrics = report["metrics"]
maximum = max(surface["gold_to_rebuilt"]["max_mm"], surface["rebuilt_to_gold"]["max_mm"])
p99 = max(surface["gold_to_rebuilt"]["p99_mm"], surface["rebuilt_to_gold"]["p99_mm"])
checks = {
"surface_max": maximum <= limits["surface_mm"], "surface_p99": p99 <= limits["surface_mm"],
"bbox": metrics["bbox_max_delta_mm"] <= limits["bbox_mm"],
"volume": metrics["volume_relative_error"] <= limits["volume_relative"],
"area": metrics["surface_area_relative_error"] <= limits["area_relative"],
"solid_count": metrics["gold_solid_count"] == metrics["rebuilt_solid_count"],
}
return {"passed": all(checks.values()), "limits": limits, "checks": checks}
def compare_steps(gold_step: Path, rebuilt_step: Path, *, surface_tessellation_mm: float = 0.05) -> dict[str, Any]:
from onshape_to_cdsl.src.onshape_to_cdsl.compare import strict_compare
raw = strict_compare(gold_step, rebuilt_step, surface_tolerance_mm=surface_tessellation_mm)
strict, rp = _assess(raw, STRICT), _assess(raw, RP)
decision = "strict_pass" if strict["passed"] else "approximate_pass" if rp["passed"] else "rejected"
return {"schema": "cadfs_to_cdsl.comparison.v1", "gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "raw": raw, "strict": strict, "rp": rp, "decision": decision}
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import hashlib, json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
MODALITIES = {
"featurescript": ("featurescript_rp", ".txt"),
"step": ("step_abc", ".step"),
"stl": ("stl_abc", ".stl"),
"image": ("multiview_images_abc", ".png"),
"annotation": ("text_annotations", ".txt"),
}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk)
return digest.hexdigest()
@dataclass
class Sample:
sample_id: str
files: dict[str, str] = field(default_factory=dict)
hashes: dict[str, str] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
diagnostics: list[dict[str, Any]] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]: return asdict(self)
def _files(root: Path, directory: str, suffix: str) -> dict[str, Path]:
base = root / directory; result: dict[str, Path] = {}
if not base.exists(): return result
for path in base.rglob(f"*{suffix}"):
if path.is_file() and path.stem.isdigit(): result[path.stem] = path
return result
def _jsonl_records(path: Path) -> list[dict[str, Any]]:
if not path.exists(): return []
records = []
for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
try:
value = json.loads(line); value["_line"] = index; records.append(value)
except json.JSONDecodeError:
records.append({"_line": index, "_invalid": True})
return records
def _assistant_script(record: dict[str, Any]) -> str | None:
for message in record.get("messages") or []:
if message.get("role") == "assistant" and isinstance(message.get("content"), str) and "FeatureScript" in message["content"]:
return message["content"]
return None
def scan_dataset(root: Path, *, include_hashes: bool = True) -> list[Sample]:
if not root.is_dir(): raise FileNotFoundError(f"CADFS input directory does not exist: {root}")
by_modality = {name: _files(root, directory, suffix) for name, (directory, suffix) in MODALITIES.items()}
ids = sorted(set().union(*(set(values) for values in by_modality.values())))
text_records = _jsonl_records(root / "CADFS_text_test.jsonl")
image_records = _jsonl_records(root / "CADFS_image_test.jsonl")
text_by_hash = {hashlib.sha256(script.encode()).hexdigest(): record for record in text_records if (script := _assistant_script(record))}
samples: list[Sample] = []
for index, sample_id in enumerate(ids):
sample = Sample(sample_id)
for name, values in by_modality.items():
path = values.get(sample_id)
if path is None: sample.diagnostics.append({"code": "missing_modality", "modality": name}); continue
sample.files[name] = str(path)
if include_hashes: sample.hashes[name] = sha256(path)
fs_path = by_modality["featurescript"].get(sample_id)
if fs_path:
script_hash = hashlib.sha256(fs_path.read_text(encoding="utf-8").encode()).hexdigest()
record = text_by_hash.get(script_hash)
if record:
sample.metadata["text_jsonl"] = {"line": record.get("_line"), "cad_file_id": record.get("cad_file_id")}
elif index < len(text_records):
fallback = text_records[index]; sample.metadata["text_jsonl"] = {"line": fallback.get("_line"), "cad_file_id": fallback.get("cad_file_id"), "alignment": "fallback"}
sample.diagnostics.append({"code": "alignment_fallback", "modality": "text_jsonl"})
if index < len(image_records):
record = image_records[index]
sample.metadata["image_jsonl"] = {"line": record.get("_line"), "cad_file_id": record.get("cad_file_id")}
samples.append(sample)
return samples
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
@dataclass
class Diagnostic:
code: str
message: str
severity: str = "error"
feature_id: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return asdict(self)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import dataclass
import re
@dataclass(frozen=True)
class Token:
kind: str
value: str
line: int
column: int
_RE = re.compile(r"(?P<ws>\s+)|(?P<comment>//[^\n]*|/\*.*?\*/)|(?P<string>\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*')|(?P<number>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)|(?P<ident>[A-Za-z_][A-Za-z0-9_.]*)|(?P<op>==|!=|<=|>=|=>|[{}\[\]():,;=+*/-])", re.S)
def lex(source: str) -> list[Token]:
out: list[Token] = []
pos = 0; line = 1; column = 1
while pos < len(source):
match = _RE.match(source, pos)
if not match:
raise SyntaxError(f"unexpected character at {line}:{column}: {source[pos]!r}")
kind, value = match.lastgroup or "", match.group(0)
if kind not in {"ws", "comment"}:
out.append(Token(kind, value, line, column))
nl = value.count("\n")
if nl: line, column = line + nl, len(value.rsplit("\n", 1)[-1]) + 1
else: column += len(value)
pos = match.end()
out.append(Token("eof", "", line, column))
return out
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
from typing import Any
from .featurescript_lexer import Token, lex
from .ir import Call, FeatureIR, ModelIR, SketchIR
def _string(value: Any) -> Any:
if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]:
try:
return bytes(value[1:-1], "utf-8").decode("unicode_escape")
except Exception:
return value[1:-1]
return value
class Parser:
def __init__(self, source: str):
self.source, self.tokens, self.i = source, lex(source), 0
def peek(self) -> Token: return self.tokens[self.i]
def pop(self) -> Token:
token = self.tokens[self.i]; self.i += 1; return token
def accept(self, value: str) -> bool:
if self.peek().value == value: self.i += 1; return True
return False
def primary(self) -> Any:
token = self.pop()
if token.value == "(":
value = self.expression(); self.accept(")"); return value
if token.kind == "string": return _string(token.value)
if token.kind == "number": return float(token.value)
if token.kind == "ident":
if self.accept("("):
args = []
while self.peek().kind != "eof" and self.peek().value != ")":
args.append(self.expression());
if not self.accept(","): break
self.accept(")")
return Call(token.value, args, token.line)
return token.value
if token.value == "[":
values = []
while self.peek().kind != "eof" and self.peek().value != "]":
values.append(self.expression());
if not self.accept(","): break
self.accept("]"); return values
if token.value == "{":
obj: dict[str, Any] = {}
while self.peek().kind != "eof" and self.peek().value != "}":
key = _string(self.pop().value); self.accept(":"); obj[str(key)] = self.expression()
if not self.accept(","): break
self.accept("}"); return obj
return token.value
def expression(self, minimum: int = 0) -> Any:
left = self.primary()
precedence = {"+": 10, "-": 10, "*": 20, "/": 20}
while self.peek().value in precedence and precedence[self.peek().value] >= minimum:
op = self.pop(); right = self.expression(precedence[op.value] + 1)
left = Call("__binary__", [left, op.value, right], op.line)
return left
def statements(self) -> list[Call]:
found: list[Call] = []; environment: dict[str, Any] = {}
while self.peek().kind != "eof":
if self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "=":
name = self.pop().value; self.pop()
try:
environment[name] = self.expression()
if isinstance(environment[name], Call) and environment[name].name == "newSketch":
value = environment[name]
value.args = [_resolve(arg, environment) for arg in value.args]
found.append(value)
except Exception: pass
elif self.peek().kind == "ident" and self.i + 1 < len(self.tokens) and self.tokens[self.i + 1].value == "(":
value = self.expression()
if isinstance(value, Call):
value.args = [_resolve(arg, environment) for arg in value.args]
found.append(value)
else: self.i += 1
return found
def _resolve(value: Any, environment: dict[str, Any], seen: set[str] | None = None) -> Any:
seen = seen or set()
if isinstance(value, str) and value in environment and value not in seen:
return _resolve(environment[value], environment, seen | {value})
if isinstance(value, Call): return Call(value.name, [_resolve(arg, environment, seen) for arg in value.args], value.line, value.raw)
if isinstance(value, list): return [_resolve(arg, environment, seen) for arg in value]
if isinstance(value, dict): return {key: _resolve(arg, environment, seen) for key, arg in value.items()}
return value
def symbolic_string(value: Any) -> str:
if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "+":
return symbolic_string(value.args[0]) + symbolic_string(value.args[2])
if isinstance(value, str): return "" if value == "id" else value
return str(value)
def _arg_map(call: Call) -> dict[str, Any]:
# Object literals are parsed as dictionaries; FeatureScript operation calls
# conventionally put the definition map in the final argument.
return next((arg for arg in reversed(call.args) if isinstance(arg, dict)), {})
def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
parser = Parser(source); calls = parser.statements(); model = ModelIR(sample_id, raw_source=source)
for call in calls:
if call.name == "newSketch":
definition = _arg_map(call)
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"sketch_{len(model.sketches)}"
sketch_ir = SketchIR(fid, definition.get("sketchPlane"), [])
model.sketches.append(sketch_ir); model.steps.append(sketch_ir)
elif call.name in {"skLineSegment", "skCircle", "skArc", "skEllipse", "skFitSpline", "skPoint"}:
# Attach sketch entities to the most recently declared sketch.
if model.sketches:
args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}"
model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name))
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies"}:
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}"
feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name)
model.features.append(feature_ir); model.steps.append(feature_ir)
return model
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Call:
name: str
args: list[Any] = field(default_factory=list)
line: int = 0
raw: str = ""
@dataclass
class FeatureIR:
feature_id: str
operation: str
params: dict[str, Any]
queries: list[Any] = field(default_factory=list)
line_start: int = 0
line_end: int = 0
raw_source: str = ""
@dataclass
class SketchIR:
feature_id: str
workplane: Any
entities: list[FeatureIR] = field(default_factory=list)
@dataclass
class ModelIR:
sample_id: str
features: list[FeatureIR] = field(default_factory=list)
sketches: list[SketchIR] = field(default_factory=list)
steps: list[Any] = field(default_factory=list)
raw_source: str = ""
+313
View File
@@ -0,0 +1,313 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from .featurescript_parser import symbolic_string
from .ir import Call, FeatureIR, ModelIR, SketchIR
from .query_parser import parse_query, walk_calls
UNSUPPORTED = {"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", "moveFace", "replaceFace", "deleteFace", "import", "derive"}
PLANES = {
"Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]},
"Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]},
"Right": {"origin_mm": [0., 0., 0.], "x_dir": [0., 1., 0.], "normal": [1., 0., 0.]},
}
@dataclass
class LoweringResult:
cdsl: dict[str, Any] | None
status: str
diagnostics: list[dict[str, Any]]
history: list[dict[str, Any]]
def plain(value: Any) -> Any:
if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line}
if isinstance(value, list): return [plain(item) for item in value]
if isinstance(value, dict): return {key: plain(item) for key, item in value.items()}
return value
def _bool(value: Any) -> bool:
return value is True or (isinstance(value, str) and value.lower() == "true")
def _number(value: Any, units: bool = False) -> float:
if isinstance(value, (float, int)): return float(value)
if isinstance(value, str):
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8}
if value in constants: return constants[value]
return float(value)
if isinstance(value, Call) and value.name == "__binary__":
left, op, right = value.args; a, b = _number(left, units), _number(right, units)
return {"+": a + b, "-": a - b, "*": a * b, "/": a / b}[str(op)]
raise ValueError(f"not a constant number: {plain(value)!r}")
def _point(value: Any) -> list[float]:
if isinstance(value, Call) and value.name == "__binary__" and value.args[1] == "*":
scale = _number(value.args[2], True); point = _point(value.args[0]); return [v * scale for v in point]
if isinstance(value, Call) and value.name in {"v", "vector"} and len(value.args) >= 2:
return [_number(value.args[0]), _number(value.args[1])]
if isinstance(value, list) and len(value) >= 2: return [_number(value[0]), _number(value[1])]
raise ValueError(f"not a 2D point: {plain(value)!r}")
def _cross(a: list[float], b: list[float]) -> list[float]:
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"])
def _global(plane: dict[str, Any], point: list[float]) -> list[float]:
y = _y_dir(plane); return [plane["origin_mm"][i] + plane["x_dir"][i]*point[0] + y[i]*point[1] for i in range(3)]
def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]:
return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]}
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any]:
for call in walk_calls(value):
if call.name in {"makeId", "qCreatedBy"}:
text = " ".join(symbolic_string(arg) for arg in call.args)
for name, plane in PLANES.items():
if f"{name}.planeOp" in text: return dict(plane)
query = parse_query(value)
frame = feature_frames.get(query.owner_feature or "")
if frame:
return dict(frame["start" if query.is_start is not False else "end"])
raise ValueError("unsupported or unresolved sketch workplane")
def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]:
ax, ay = start; bx, by = mid; cx, cy = end
d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by))
if abs(d) < 1e-9: raise ValueError("collinear arc points")
ux = ((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/d
uy = ((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/d
cross = (mid[0]-start[0])*(end[1]-mid[1])-(mid[1]-start[1])*(end[0]-mid[0])
return {"type": "arc", "start": start, "end": end, "center": [ux, uy], "radius_mm": math.hypot(ax-ux, ay-uy), "clockwise": cross < 0}
def _endpoint(segment: dict[str, Any], end: bool = False) -> tuple[int, int]:
point = segment["end" if end else "start"]; return round(point[0]*1e5), round(point[1]*1e5)
def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
circles = [{"role": "unknown", "closed": True, "segments": [item]} for item in segments if item["type"] == "circle"]
edges = [item for item in segments if item["type"] != "circle"]; unused = set(range(len(edges))); contours = []; construction = []
while unused:
idx = unused.pop(); contour = [edges[idx]]; first = _endpoint(contour[0]); tail = _endpoint(contour[-1], True)
while tail != first:
match = next((j for j in unused if _endpoint(edges[j]) == tail or _endpoint(edges[j], True) == tail), None)
if match is None:
construction.extend(contour); break
unused.remove(match); item = dict(edges[match])
if _endpoint(item, True) == tail:
item["start"], item["end"] = item["end"], item["start"]
if item["type"] == "arc": item["clockwise"] = not item["clockwise"]
contour.append(item); tail = _endpoint(item, True)
if tail == first: contours.append({"role": "unknown", "closed": True, "segments": contour})
return contours + circles, construction
def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; unsupported = []
for entity in sketch.entities:
p = entity.params
if entity.operation == "skPoint": entities[entity.feature_id] = {"type": "point", "point": _point(p["position"])}; continue
if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])}
elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)}
elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"]))
else: unsupported.append(entity.operation); continue
(explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item
if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported))))
if not segments:
profile: dict[str, Any] = {"type": "analytic_contours", "contours": []}
if explicit_construction: profile["construction"] = explicit_construction
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction:
profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]}
else:
contours, construction = _contours(segments); construction.extend(explicit_construction)
if not contours:
profile = {"type": "analytic_contours", "contours": [], "construction": construction}
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
profile = {"type": "analytic_contours", "contours": contours}
if construction: profile["construction"] = construction
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile}, entities
def _queries(value: Any) -> list[Any]:
if isinstance(value, Call) and value.name == "qUnion" and value.args and isinstance(value.args[0], list): return value.args[0]
return [value]
def _source_refs(value: Any) -> list[tuple[str, str]]:
refs = []
for call in walk_calls(value):
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
refs.append((symbolic_string(call.args[0]).split(".", 1)[0], str(call.args[2])))
return refs
def _source_sketch(params: dict[str, Any]) -> str | None:
for key in ("entities", "sheetProfilesArray"):
if key in params:
query = parse_query(params[key])
if query.source_sketch: return query.source_sketch
return None
def _profile_executable(sketch: dict[str, Any]) -> bool:
profile = sketch.get("profile") or {}
if profile.get("type") == "circle": return True
if profile.get("type") == "polygon": return len(profile.get("vertices") or []) >= 3
return bool(profile.get("contours"))
def _default_plane(value: Any) -> dict[str, Any] | None:
for call in walk_calls(value):
text = " ".join(symbolic_string(arg) for arg in call.args)
for name, plane in PLANES.items():
if f"{name}.planeOp" in text: return dict(plane)
return None
def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
diagnostics: list[dict[str, Any]] = []; history = []
sketches: list[dict[str, Any]] = []; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {}
feature_frames: dict[str, dict[str, Any]] = {}
features: list[dict[str, Any]] = []; complete = True; previous: list[str] = []
for step in model.steps:
if isinstance(step, SketchIR):
history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]})
try:
plane = _plane_from_query(step.workplane, feature_frames)
lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
except Exception as exc:
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
continue
item = step
history.append({"feature_id": item.feature_id, "operation": item.operation, "source_span": {"line_start": item.line_start, "line_end": item.line_end or item.line_start}, "parameters": plain(item.params), "raw_source": item.raw_source})
if item.operation in UNSUPPORTED:
diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue
try:
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
if item.operation == "cPlane":
base = _plane_from_query(p.get("entities"), feature_frames); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"}
feature_frames[item.feature_id] = {"start": plane, "end": plane}
elif item.operation == "extrude":
source = _source_sketch(p)
if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved")
if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile")
depth = _number(p.get("depth"), True); operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection"))
atomic = "extrude_cut_blind" if any(x in operation for x in ("REMOVE", "CUT")) else "extrude_add_two_sided" if _bool(p.get("hasSecondDirection")) else "extrude_add_blind"
params = {"distance_mm": depth, "reverse": reverse}
if atomic == "extrude_add_two_sided": params["reverse_distance_mm"] = _number(p.get("secondDirectionDepth", depth), True)
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"}
plane = sketch_by_source[source]["workplane"]; feature_frames[item.feature_id] = {"start": plane, "end": _shift_plane(plane, -depth if reverse else depth)}
elif item.operation == "revolve":
source = _source_sketch(p)
if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved")
if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile")
axis_q = parse_query(p.get("axis")); axis_entity = (entity_by_sketch.get(axis_q.source_sketch or "") or {}).get(axis_q.source_entity or "")
if not axis_entity or axis_entity.get("type") != "line": raise ValueError("revolve axis is unresolved")
plane = sketch_by_source[axis_q.source_sketch]["workplane"]; start, end = _global(plane, axis_entity["start"]), _global(plane, axis_entity["end"])
direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction]
operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add"
full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0))
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
elif item.operation in {"fillet", "chamfer"}:
key = "radius" if item.operation == "fillet" else "width"; amount = _number(p.get(key), True); selectors = []
for index, query_value in enumerate(_queries(p.get("entities"))):
query = parse_query(query_value); owner = query.owner_feature
if not owner: raise ValueError("selector owner is unresolved")
refs = _source_refs(query_value)
source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
geometry: dict[str, Any] = {}
frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"]
if source_entity and cap:
if query.topology_type == "SWEPT_EDGE" and frame:
local_point = source_entity.get("point") if source_entity["type"] == "point" else None
if len(refs) >= 2:
left = (entity_by_sketch.get(refs[0][0]) or {}).get(refs[0][1]); right = (entity_by_sketch.get(refs[1][0]) or {}).get(refs[1][1])
if left and right and left.get("type") == right.get("type") == "line":
local_point = next((a for a in (left["start"], left["end"]) for b in (right["start"], right["end"]) if math.dist(a, b) <= 1e-5), None)
if local_point is None: raise ValueError("swept edge source intersection is unresolved")
start, end = _global(frame["start"], local_point), _global(frame["end"], local_point)
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
elif source_entity["type"] == "circle":
# OCC/build123d commonly splits a closed circular edge into four
# quarter-circle records. Bind all four deterministic arc centres.
radius = source_entity["radius_mm"]; center = source_entity["center"]
for quadrant, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))):
local = [center[0] + sx*radius/math.sqrt(2), center[1] + sy*radius/math.sqrt(2)]
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}_{quadrant}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"curve_type": "circle", "center_mm": _global(cap, local)}})
continue
elif source_entity["type"] == "line":
start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"])
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
if not geometry: raise ValueError("selector geometry is unresolved")
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount}
if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation"))
feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"}
elif item.operation == "hole":
locations = _queries(p.get("locations")); positions = []; host_plane = None
for location in locations:
query = parse_query(location); source = query.source_sketch
entity = (entity_by_sketch.get(source or "") or {}).get(query.source_entity or "")
if not source or source not in sketch_by_source or not entity or entity.get("type") != "point": raise ValueError("hole location is unresolved")
positions.append({"mm": [entity["point"][0], entity["point"][1], 0.0]}); host_plane = sketch_by_source[source]["workplane"]
if not positions or host_plane is None: raise ValueError("hole has no resolved locations")
frame = {**host_plane, "y_dir": _y_dir(host_plane)}
if _bool(p.get("oppositeDirection")): frame = {**frame, "normal": [-v for v in frame["normal"]]}
style = str(p.get("style") or "SIMPLE").split(".")[-1].lower(); end = str(p.get("endStyle") or "BLIND").upper()
condition = "through_all_both" if "BOTH" in end else "through_all" if "THROUGH" in end else "blind"
depth_value = p.get("holeDepth") or p.get("tappedDepth")
if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved")
depth = _number(depth_value, True) if depth_value is not None else 1.0
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": 1}, "positions": positions, "host_face": {"frame": frame}}
if "COUNTERSINK" in style.upper():
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or 90.0))}
if "COUNTERBORE" in style.upper():
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth"), True)}
if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True}
feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"}
elif item.operation == "mirror":
owners = []
for call in walk_calls(p.get("entities")):
if call.name == "makeQuery" and call.args:
owner = symbolic_string(call.args[0]);
if "F" in owner:
source_id = "f_" + owner[owner.find("F"):].split(".", 1)[0]
if source_id in previous and source_id not in owners: owners.append(source_id)
if not owners: raise ValueError("mirror source features are unresolved")
plane_query = p.get("mirrorPlane"); plane_info = parse_query(plane_query); plane_owner = f"f_{plane_info.owner_feature}" if plane_info.owner_feature else None
if plane_owner and any(existing["id"] == plane_owner and existing["atomic_id"] == "reference_plane" for existing in features):
mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0}
else:
plane = _default_plane(plane_query)
if plane is None: raise ValueError("mirror plane is not a default or reference plane")
plane_owner = f"{fid}_plane"
features.append({"id": plane_owner, "name": f"{item.feature_id} plane", "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"})
previous.append(plane_owner)
mirror_plane = {"kind": "plane", "owner_feature_id": plane_owner, "stable_id": f"cadfs_{fid}_plane", "source": "runtime_snapshot", "confidence": 1.0}
feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"}
else:
raise ValueError(f"operation mapping not implemented: {item.operation}")
features.append(feature); previous.append(fid)
except Exception as exc:
diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history)
cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id,
"meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})},
"geometry": {"sketches": sketches}, "features": features}
return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history)
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import hashlib, multiprocessing, random
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from .compare import compare_steps
from .dataset import Sample, scan_dataset
from .featurescript_parser import parse_featurescript
from .lowering import lower_model
from .rebuild import rebuild_candidate
from .reports import generate_reports, read_json, write_json, write_manifest
PIPELINE_VERSION = "cadfs_to_cdsl.v1"
def _fingerprint(sample: Sample) -> str:
value = PIPELINE_VERSION + "|" + "|".join(f"{key}:{value}" for key, value in sorted(sample.hashes.items()))
return hashlib.sha256(value.encode()).hexdigest()
def _sample_dir(output: Path, sample_id: str) -> Path: return output / "samples" / sample_id
def scan(input_root: Path, output: Path) -> list[dict[str, Any]]:
records = [sample.as_dict() for sample in scan_dataset(input_root)]
write_json(output / "dataset_index.json", {"schema": "cadfs_to_cdsl.dataset_index.v1", "input": str(input_root), "sample_count": len(records), "records": records})
initial = [{"sample_id": item["sample_id"], "status": "scanned", "diagnostics": item["diagnostics"]} for item in records]
write_manifest(output / "manifest.jsonl", initial)
return records
def load_samples(input_root: Path, output: Path) -> list[Sample]:
index = output / "dataset_index.json"
records = read_json(index)["records"] if index.exists() else scan(input_root, output)
return [Sample(**{key: value for key, value in item.items() if key in {"sample_id", "files", "hashes", "metadata", "diagnostics"}}) for item in records]
def select_samples(samples: list[Sample], *, sample_ids: list[str] | None = None, offset: int = 0, limit: int | None = None, seed: int | None = None) -> list[Sample]:
if sample_ids:
wanted = set(sample_ids); chosen = [sample for sample in samples if sample.sample_id in wanted]
missing = wanted - {sample.sample_id for sample in chosen}
if missing: raise ValueError("unknown sample ids: " + ", ".join(sorted(missing)))
return chosen
chosen = list(samples)
if seed is not None and limit is not None:
chosen = random.Random(seed).sample(chosen, min(limit, len(chosen))); chosen.sort(key=lambda item: item.sample_id); return chosen
return chosen[offset:None if limit is None else offset + limit]
def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[str, Any]:
directory = _sample_dir(output, sample.sample_id); directory.mkdir(parents=True, exist_ok=True)
fingerprint = _fingerprint(sample); status_path = directory / "status.json"
if not force and status_path.exists():
cached = read_json(status_path)
if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"):
return cached
diagnostics = list(sample.diagnostics)
try:
feature_path = Path(sample.files["featurescript"])
model = parse_featurescript(feature_path.read_text(encoding="utf-8"), sample.sample_id)
provenance = {"source_featurescript": str(feature_path), **{f"source_{key}_sha256": value for key, value in sample.hashes.items()}, "jsonl": sample.metadata}
result = lower_model(model, provenance); diagnostics.extend(result.diagnostics)
write_json(directory / "history.json", result.history); write_json(directory / "diagnostics.json", diagnostics)
if result.cdsl is not None:
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
semantic = validate_semantic_cdsl(result.cdsl); write_json(directory / "candidate.cdsl.json", result.cdsl)
else: semantic = None
status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": result.status, "conversion_status": result.status, "input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics)}
except Exception as exc:
missing = isinstance(exc, FileNotFoundError)
diagnostics.append({"code": "source_missing" if missing else "parse_or_lowering_failed", "message": str(exc), "type": type(exc).__name__})
write_json(directory / "diagnostics.json", diagnostics)
final_status = "source_missing" if missing else "parse_failed"
status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": final_status, "conversion_status": final_status, "input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics)}
write_json(status_path, status); return status
def _rebuild_worker(candidate: str, step: str, result: str) -> None:
from .rebuild import rebuild_candidate
write_json(Path(result), rebuild_candidate(read_json(Path(candidate)), Path(step)))
def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> bool:
result_path.unlink(missing_ok=True)
context = multiprocessing.get_context("spawn")
process = context.Process(target=target, args=args)
process.start(); process.join(timeout_seconds)
if process.is_alive():
process.terminate(); process.join(5)
if process.is_alive(): process.kill(); process.join()
return False
return process.exitcode == 0 and result_path.exists()
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json"
status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force)
if status.get("conversion_status") != "converted_complete": return status
rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step"
if not force and rebuild_path.exists() and status.get("rebuild_status"):
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
worker_result = directory / "rebuild.worker.json"
completed = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
if completed: result = read_json(worker_result); worker_result.unlink(missing_ok=True)
else:
step_path.unlink(missing_ok=True)
result = {"status": "rebuild_timeout", "error": {"type": "TimeoutError", "message": f"rebuild exceeded {timeout_seconds:g} seconds"}}
write_json(rebuild_path, result)
status["rebuild_status"] = result["status"]; status["status"] = result["status"]
write_json(status_path, status); return status
def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mode: str = "rp", timeout_seconds: float = 60.0) -> dict[str, Any]:
directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json"
status = read_json(status_path) if status_path.exists() else rebuild_one(sample, output, force=force)
if status.get("rebuild_status") != "rebuilt": return status
comparison_path = directory / "comparison.json"
if not force and status.get("status") in {"comparison_failed", "comparison_timeout"}: return status
if force or not comparison_path.exists():
worker_result = directory / "comparison.worker.json"
completed = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
if completed: comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
else:
status["status"] = "comparison_timeout"; status["comparison_error"] = {"type": "TimeoutError", "message": f"comparison exceeded {timeout_seconds:g} seconds"}; write_json(status_path, status); return status
else: comparison = read_json(comparison_path)
status["comparison_decision"] = comparison["decision"]
accepted = comparison[compare_mode]["passed"]
status["status"] = "rebuilt_approximate" if accepted else "rebuilt_rejected"
write_json(status_path, status); return status
def run_stage(stage: str, samples: list[Sample], output: Path, *, force: bool = False, compare_mode: str = "rp", timeout_seconds: float = 30.0, workers: int = 1) -> list[dict[str, Any]]:
def process(sample: Sample) -> dict[str, Any]:
if stage == "convert": record = convert_one(sample, output, force=force)
elif stage == "rebuild": record = rebuild_one(sample, output, force=force, timeout_seconds=timeout_seconds)
elif stage == "compare": record = compare_one(sample, output, force=force, compare_mode=compare_mode, timeout_seconds=max(60.0, timeout_seconds))
elif stage == "pipeline":
convert_one(sample, output, force=force); rebuild_one(sample, output, force=force, timeout_seconds=timeout_seconds); record = compare_one(sample, output, force=force, compare_mode=compare_mode, timeout_seconds=max(60.0, timeout_seconds))
else: raise ValueError(f"unknown stage {stage!r}")
return record
if workers == 1: records = [process(sample) for sample in samples]
else:
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cadfs") as executor:
records = list(executor.map(process, samples))
manifest_path = output / "manifest.jsonl"
existing = {}
if manifest_path.exists():
import json
existing = {item["sample_id"]: item for line in manifest_path.read_text(encoding="utf-8").splitlines() if line.strip() for item in [json.loads(line)]}
existing.update({item["sample_id"]: item for item in records})
merged = [existing[key] for key in sorted(existing)]
write_manifest(manifest_path, merged); generate_reports(output, merged)
return records
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any
from .featurescript_parser import symbolic_string
from .ir import Call
@dataclass
class QueryInfo:
kind: str | None = None
owner_feature: str | None = None
topology_type: str | None = None
source_sketch: str | None = None
source_entity: str | None = None
is_start: bool | None = None
calls: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]: return asdict(self)
def walk_calls(value: Any):
if isinstance(value, Call):
yield value
for arg in value.args: yield from walk_calls(arg)
elif isinstance(value, list):
for item in value: yield from walk_calls(item)
elif isinstance(value, dict):
for item in value.values(): yield from walk_calls(item)
def parse_query(value: Any) -> QueryInfo:
info = QueryInfo()
for call in walk_calls(value):
info.calls.append(call.name)
if call.name in {"makeQuery", "qCreatedBy"} and call.args:
owner = symbolic_string(call.args[0])
if "F" in owner:
tail = owner[owner.find("F"):].split(".", 1)[0]
info.owner_feature = tail
if call.name == "makeQuery" and len(call.args) > 2:
info.topology_type = str(call.args[1]); info.kind = str(call.args[2]).lower()
definition = next((arg for arg in call.args if isinstance(arg, dict)), {})
if isinstance(definition.get("isStart"), str): info.is_start = definition["isStart"].lower() == "true"
elif "isStart" in definition: info.is_start = bool(definition["isStart"])
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0]
info.kind = str(call.args[1]).lower(); info.source_entity = str(call.args[2])
if call.name == "qSketchRegion" and call.args:
info.source_sketch = symbolic_string(call.args[0]); info.kind = "face"
return info
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
analysis = analyze_cdsl(cdsl)
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
if not analysis.runtime_eligible:
return {"status": "runtime_ineligible", "analysis": analysis_dict}
try:
result = rebuild_cdsl(cdsl, output, strict=True)
return {"status": "rebuilt", "analysis": analysis_dict, "result": result}
except Exception as exc:
detail = {"type": type(exc).__name__, "message": str(exc)}
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
return {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import csv, json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8"))
def write_manifest(path: Path, records: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("".join(json.dumps(item, ensure_ascii=True, sort_keys=True) + "\n" for item in records), encoding="utf-8")
def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, Any]:
statuses = Counter(str(item.get("status") or "unknown") for item in records)
operations: Counter[str] = Counter(); reasons: Counter[str] = Counter(); gaps: dict[str, list[str]] = defaultdict(list)
rows = []
for item in records:
sample_dir = output / "samples" / str(item["sample_id"])
diagnostics_path = sample_dir / "diagnostics.json"
if diagnostics_path.exists():
for diagnostic in read_json(diagnostics_path):
reasons[str(diagnostic.get("code") or "unknown")] += 1
if diagnostic.get("operation"): gaps[str(diagnostic["operation"])].append(str(item["sample_id"]))
history_path = sample_dir / "history.json"
if history_path.exists():
for step in read_json(history_path): operations[str(step.get("operation") or "unknown")] += 1
comparison_path = sample_dir / "comparison.json"
if comparison_path.exists():
comparison = read_json(comparison_path); metrics = comparison["raw"]["metrics"]
rows.append({"sample_id": item["sample_id"], "decision": comparison["decision"], "bbox_max_delta_mm": metrics["bbox_max_delta_mm"], "volume_relative_error": metrics["volume_relative_error"], "surface_area_relative_error": metrics["surface_area_relative_error"]})
summary = {"schema": "cadfs_to_cdsl.summary.v1", "total_models": len(records), "statuses": dict(statuses), "operation_counts": dict(operations), "failure_reasons": dict(reasons), "capability_gap_counts": {key: len(set(value)) for key, value in gaps.items()}}
write_json(output / "summary.json", summary)
gap_payload = {key: {"sample_count": len(set(ids)), "sample_ids": sorted(set(ids))} for key, ids in sorted(gaps.items())}; write_json(output / "capability_gaps.json", gap_payload)
lines = ["# Unsupported CADFS capabilities", ""]
for name, value in gap_payload.items(): lines.extend([f"## {name}", "", f"Affected models: {value['sample_count']}", "", "Sample IDs: " + ", ".join(value["sample_ids"]), ""])
(output / "unsupported_capabilities.md").write_text("\n".join(lines), encoding="utf-8")
with (output / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["sample_id", "decision", "bbox_max_delta_mm", "volume_relative_error", "surface_area_relative_error"]); writer.writeheader(); writer.writerows(rows)
return summary
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import math
from typing import Any
def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
scores: list[float] = []
for key in ("center_mm", "start_mm", "end_mm", "normal", "axis_direction"):
if key in expected:
left, right = expected[key], actual.get(key)
if not isinstance(right, (list, tuple)) or len(left) != len(right): return None
delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right)))
scores.append(max(0.0, 1.0 - delta / 0.05))
for key in ("radius_mm", "plane_offset_mm"):
if key in expected:
try: delta = abs(float(expected[key]) - float(actual[key]))
except Exception: return None
scores.append(max(0.0, 1.0 - delta / 0.05))
return sum(scores) / len(scores) if scores else 0.0
def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], records: list[dict[str, Any]], *, minimum_score: float = 0.8) -> dict[str, Any]:
candidates = []
for record in records:
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
if record.get("kind") != kind or owner_feature_id not in owners: continue
score = _score(geometry, record.get("geometry") or {})
if score is not None and score >= minimum_score: candidates.append((score, record))
candidates.sort(key=lambda item: (-item[0], str(item[1].get("record_id"))))
if not candidates: raise ValueError("selector_not_found")
if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous")
score, record = candidates[0]
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "cadfs_featurescript", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
View File
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import tempfile, unittest
from pathlib import Path
from cadfs_to_cdsl.compare import compare_steps
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
class IntegrationTests(unittest.TestCase):
def test_known_rp_roundtrip_00000173(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
cdsl = lower_model(parse_featurescript(feature.read_text(), "00000173"), {}).cdsl
from engine.cdsl_engine.runtime import rebuild_cdsl
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"; rebuild_cdsl(cdsl, rebuilt, strict=True)
comparison = compare_steps(gold, rebuilt)
self.assertFalse(comparison["strict"]["passed"])
self.assertTrue(comparison["rp"]["passed"])
self.assertEqual(comparison["decision"], "approximate_pass")
if __name__ == "__main__": unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import json, tempfile, unittest
from pathlib import Path
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.lowering import lower_model
from cadfs_to_cdsl.pipeline import convert_one
from cadfs_to_cdsl.dataset import Sample
from cadfs_to_cdsl.dataset import scan_dataset
from cadfs_to_cdsl.tests.test_parser import SOURCE
class LoweringTests(unittest.TestCase):
def test_missing_dataset_fails_explicitly(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(FileNotFoundError): scan_dataset(Path(tmp) / "missing")
def test_circle_extrude_is_valid_cdsl_11(self):
result = lower_model(parse_featurescript(SOURCE, "00000173"), {})
self.assertEqual(result.status, "converted_complete")
self.assertEqual(result.cdsl["schema_version"], "1.1.0")
self.assertEqual(result.cdsl["geometry"]["sketches"][0]["profile"]["radius_mm"], 9.53)
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
def test_unsupported_operation_is_audited_not_invented(self):
source = SOURCE.replace('extrude(context, id + "F1",', 'shell(context, id + "F1",')
result = lower_model(parse_featurescript(source, "00000173"), {})
self.assertEqual(result.status, "deferred_no_executable_feature")
self.assertIsNone(result.cdsl)
self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation")
def test_conversion_writes_status_and_sidecars(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
sample = Sample("00000173", {"featurescript": str(source)}, {"featurescript": "x"})
status = convert_one(sample, root / "out", force=True)
directory = root / "out/samples/00000173"
self.assertEqual(status["conversion_status"], "converted_complete")
for name in ("candidate.cdsl.json", "history.json", "diagnostics.json", "status.json"):
self.assertTrue((directory / name).exists(), name)
self.assertEqual(json.loads((directory / "candidate.cdsl.json").read_text())["part_id"], "00000173")
if __name__ == "__main__": unittest.main()
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import unittest
from cadfs_to_cdsl.featurescript_lexer import lex
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.ir import Call
from cadfs_to_cdsl.query_parser import parse_query
from cadfs_to_cdsl.units import length_mm
SOURCE = r'''
FeatureScript 1511;
export const f = defineFeature(function(context, id, definition) {
{ var Q0; Q0=qCreatedBy(makeId("Top.planeOp"), FACE);
var sketch = newSketch(context, id + "F0", {"sketchPlane":qUnion([Q0])});
skCircle(sketch, "E0", {"center":v(0, 0) * mm, "radius":9.53 * mm}); skSolve(sketch); }
{ var Q0; Q0=qSketchRegion(id + "F0", true);
extrude(context, id + "F1", {"entities":qUnion([Q0]), "depth":120 * mm}); }
});
'''
class ParserTests(unittest.TestCase):
def test_lexer_ignores_comments_and_preserves_lines(self):
tokens = lex('// a\nfoo(/*b*/"x")')
self.assertEqual([token.value for token in tokens[:-1]], ["foo", "(", '"x"', ")"])
self.assertEqual(tokens[0].line, 2)
def test_nested_feature_script(self):
model = parse_featurescript(SOURCE, "00000173")
self.assertEqual([step.feature_id for step in model.steps], ["F0", "F1"])
self.assertEqual(model.sketches[0].entities[0].operation, "skCircle")
self.assertIsInstance(model.features[0].params["entities"], Call)
def test_query_parser(self):
query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}])
value = parse_query(query)
self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0"))
self.assertFalse(value.is_start)
def test_safe_units(self):
self.assertEqual(length_mm("2 * inch"), 50.8)
self.assertEqual(length_mm("25.4 / 2 * mm"), 12.7)
with self.assertRaises(ValueError): length_mm("external.value * mm")
if __name__ == "__main__": unittest.main()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import ast
import operator
import re
from typing import Any
SCALE = {"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "cm": 10.0, "m": 1000.0,
"meter": 1000.0, "meters": 1000.0, "in": 25.4, "inch": 25.4, "inches": 25.4,
"ft": 304.8, "foot": 304.8, "feet": 304.8}
_NUM_UNIT = re.compile(r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?:\*\s*)?([A-Za-z]+)\s*$")
def _eval(node: ast.AST, names: dict[str, float]) -> float:
if isinstance(node, ast.Expression): return _eval(node.body, names)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return float(node.value)
if isinstance(node, ast.Name) and node.id in names: return names[node.id]
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
value = _eval(node.operand, names); return value if isinstance(node.op, ast.UAdd) else -value
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
left, right = _eval(node.left, names), _eval(node.right, names)
return {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}[type(node.op)](left, right)
raise ValueError("expression is not a safe constant")
def length_mm(expression: Any, names: dict[str, float] | None = None) -> float:
if not isinstance(expression, str): raise ValueError("missing length expression")
text = expression.strip().replace("millimeter", "mm").replace("inch", "in")
match = _NUM_UNIT.match(text)
if match:
unit = match.group(2).lower()
if unit not in SCALE: raise ValueError(f"unsupported unit {unit!r}")
return float(match.group(1)) * SCALE[unit]
env = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "inch": 25.4, "in": 25.4, **(names or {})}
try:
return _eval(ast.parse(text, mode="eval"), env)
except Exception as exc:
raise ValueError(f"unsupported length expression {expression!r}") from exc
+2
View File
@@ -2,3 +2,5 @@
Architecture decisions, API contracts, engine usage, data formats, and
development notes belong here.
- [CDSL format structure](cdsl-format.md)
+71
View File
@@ -0,0 +1,71 @@
# CDSL 格式说明
CDSL`cad.cdsl.llm.v1`)是描述**单个零件建模历史**的 JSON 格式。当前版本为
`1.1.0`:先定义草图,再按依赖顺序定义基体、切除、孔和修饰等特征。
## 基本结构
```text
根对象
|- 身份信息:schema、schema_version、kind、part_id、meta
|- geometry.sketches:二维草图及其三维工作平面
`- features:按依赖顺序执行的建模特征
```
下面的示例创建一个半径 20 mm、高 8 mm 的圆柱:
```json
{
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "cylinder-001",
"meta": {"unit": "mm"},
"geometry": {
"sketches": [
{
"id": "sk_base",
"workplane": {
"origin_mm": [0, 0, 0],
"x_dir": [1, 0, 0],
"normal": [0, 0, 1]
},
"profile": {
"type": "circle",
"center": [0, 0],
"radius_mm": 20
}
}
]
},
"features": [
{
"id": "base_add",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"sketch_id": "sk_base",
"params": {"distance_mm": 8},
"execution_status": "supported"
}
]
}
```
## 字段说明
| 部分 | 关键字段 | 含义 |
| --- | --- | --- |
| 根对象 | `part_id``meta.unit` | 零件 ID;v1.1 的单位固定为 `mm`。 |
| 草图 | `id``workplane``profile` | 草图 ID、所在平面和二维轮廓。工作平面使用三维坐标与方向;轮廓可为 `circle``polygon``analytic_contours`。 |
| 特征 | `id``atomic_id``params` | 特征 ID、操作类型及其尺寸/参数。 |
| 引用 | `sketch_id``depends_on` | `sketch_id` 引用草图;`depends_on` 指向必须先完成的特征。 |
| 拓扑选择 | `selectors` 或参数内的选择器 | 对已有面、边、轴或基准面进行孔、圆角、倒角等操作时使用。 |
常见 `atomic_id``extrude_add_blind`(拉伸增加)、`extrude_cut_blind`(拉伸切除)、`revolve_add` / `revolve_cut`(旋转)、`hole_blind`(孔)、`fillet`(圆角)、`chamfer`(倒角)、`pattern_linear` / `pattern_mirror`(阵列)。
## 编写规则
- 所有长度字段使用 mm;二维点为草图平面坐标 `[u, v]`,三维点/方向为 `[x, y, z]`
- 草图和特征 ID 必须唯一。特征必须在其 `depends_on` 所引用的特征之后出现。
- 拉伸和旋转需要 `sketch_id`;孔、圆角、倒角等操作通常需要选择已有的面或边。
- `execution_status``supported``deferred`;它记录导出状态,实际能否重建仍取决于当前引擎预检。
+9
View File
@@ -393,6 +393,15 @@ button:disabled {
.theme-button { display: grid; width: 30px; place-items: center; padding: 0; }
.config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; }
.studio-main { display: flex; min-height: 0; flex: 1; }
.task-documents { border-top: 1px solid var(--ui-border); max-height: 34vh; overflow: auto; background: var(--ui-panel-bg); }
.task-checklist { padding: 8px 12px; border-bottom: 1px solid var(--ui-border); display: grid; gap: 5px; }
.task-checklist-item { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 8px; font-size: 12px; line-height: 1.35; }
.task-checklist-item > span { color: var(--ui-muted); }
.task-checklist-item.is-pass > span { color: #16784d; }
.task-checklist-item.is-fail > span { color: #b73c32; }
.task-document { border-bottom: 1px solid var(--ui-border); padding: 8px 12px; font-size: 13px; }
.task-document summary { cursor: pointer; font-weight: 600; }
.task-document > :not(summary) { margin-top: 8px; }
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
+23 -1
View File
@@ -21,6 +21,7 @@ import type {
} from "@/lib/cad-types";
import { AgentThread } from "./agent-thread";
import { CadViewerPreview } from "./cad-viewer-preview";
import { MarkdownDocument } from "./rich-content";
import type { AssistantRuntime } from "@assistant-ui/react";
type LoadState = "loading" | "ready" | "error";
@@ -282,6 +283,7 @@ export function AgentStudio() {
onCadError={handleError}
onSelectionChange={setViewerSelection}
taskRunning={taskRunning}
taskRecord={taskRecord}
/>
</AgentRuntime>
);
@@ -513,6 +515,7 @@ function StudioShell({
onCadError,
onSelectionChange,
taskRunning,
taskRecord,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
@@ -532,6 +535,7 @@ function StudioShell({
onCadError: (error: CadError) => void;
onSelectionChange: (selection: ViewerSelectionContext | null) => void;
taskRunning: boolean;
taskRecord: TaskRecord | null;
}) {
const running = useAuiState((state) => state.thread.isRunning) || taskRunning;
const provider = config?.providers.find((item) => item.id === providerId);
@@ -555,7 +559,10 @@ function StudioShell({
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span></span></div> : null}
{config?.autonomous_generation && !config.review_configured ? <div className="config-warning"><AlertCircle size={16} /><span>{config.review_error || "请配置独立视觉模型。"}</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} /></aside>
<aside className="agent-pane">
<AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
<TaskDocuments task={taskRecord} />
</aside>
<section className="preview-pane">
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
</section>
@@ -564,6 +571,21 @@ function StudioShell({
);
}
function TaskDocuments({ task }: { task: TaskRecord | null }) {
const documents = [
["需求文档", task?.requirements_markdown],
["完成目标", task?.completion_target_markdown],
["建模计划", task?.modeling_plan_markdown],
] as const;
if (!documents.some(([, markdown]) => markdown)) return null;
return <section className="task-documents" aria-label="任务文档">
{task?.checklist_progress?.length ? <div className="task-checklist" aria-label="验收进度">
{task.checklist_progress.map((item) => <div key={item.requirement_id || item.statement} className={`task-checklist-item is-${item.status}`}><span>{item.status === "pass" ? "完成" : item.status === "fail" ? "未通过" : "待验证"}</span>{item.statement}</div>)}
</div> : null}
{documents.map(([title, markdown]) => markdown ? <details key={title} className="task-document"><summary>{title}</summary><MarkdownDocument>{markdown}</MarkdownDocument></details> : null)}
</section>;
}
function StudioLoading() {
return (
<main className="boot-screen">
+2 -2
View File
@@ -46,7 +46,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => {
event: "cad_error",
data: {
stage: "generation",
tool: "submit_requirements_spec",
tool: "compile_requirements_spec",
message: "Author repeatedly failed the schema.",
fieldErrors: [{ path: "/patches", message: "Field required" }],
},
@@ -54,7 +54,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => {
assert.equal(chunk?.type, "data-cad-error");
assert.deepEqual("data" in chunk! ? chunk.data : null, {
stage: "generation",
tool: "submit_requirements_spec",
tool: "compile_requirements_spec",
message: "Author repeatedly failed the schema.",
fieldErrors: [{ path: "/patches", message: "Field required" }],
});
+2 -2
View File
@@ -20,7 +20,7 @@ export function backendEventToUiChunk(
data: { ...item.data, sequence },
};
}
if (["image_observation", "requirements_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) {
if (["image_observation", "requirements_document_ready", "completion_target_ready", "requirements_compiled", "modeling_plan_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) {
const review = item.data.review && typeof item.data.review === "object"
? item.data.review as Record<string, unknown>
: null;
@@ -44,7 +44,7 @@ export function backendEventToUiChunk(
type: "data-cad-progress",
id: `event_${eventId}`,
data: { step: item.event, label: ({
image_observation: "参考图片观察", requirements_ready: "需求规格已就绪", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务",
image_observation: "参考图片观察", requirements_document_ready: "需求文档已冻结", completion_target_ready: "完成目标已冻结", requirements_compiled: "需求合同已编译", modeling_plan_ready: "建模计划已冻结", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务",
} as Record<string, string>)[item.event], status, ...metadata, message: String(
item.data.message || item.data.reason
|| (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join("") : "")
+8
View File
@@ -121,8 +121,11 @@ export type TaskRecord = {
requirements_contract?: Record<string, unknown> | null;
requirements_contract_path?: string;
requirements_markdown?: string | null;
requirements_document_path?: string;
completion_target_markdown?: string | null;
completion_target_path?: string;
modeling_plan_markdown?: string | null;
modeling_plan_path?: string;
completion_result_markdown?: string | null;
completion_result_path?: string;
claim_summary?: Array<{
@@ -133,6 +136,11 @@ export type TaskRecord = {
status: "pass" | "pending" | "fail" | "unavailable" | string;
evidence?: Record<string, unknown>;
}>;
checklist_progress?: Array<{
requirement_id: string;
statement: string;
status: "pass" | "pending" | "fail" | string;
}>;
pending_action?: { action_id: string; working_head: string; intent: string; requirement_ids: string[]; atomic_id: string; expected_change: string; contract_hash: string } | null;
action_ledger_summary?: Array<Record<string, unknown>>;
usage?: { calls: number; prompt_tokens: number; completion_tokens: number; context_chars: number };
+40
View File
@@ -47,3 +47,43 @@ the normalized full source filename. Files that normalize to the same part ID
receive a deterministic relative-path hash suffix, so every input record maps
to a distinct output. When a matching STEP file exists, it is resolved through
the same relative subdirectory as its source record.
## Onshape API samples
`download_onshape_samples.py` downloads raw Onshape v9 feature-list responses
for a small set of public ABC Part Studios. Create a personal API key in the
Onshape developer settings, then keep the credentials out of the repository:
```bash
export ONSHAPE_ACCESS_KEY='...'
export ONSHAPE_SECRET_KEY='...'
python json_to_cdsl/download_onshape_samples.py --count 3
```
If the environment variables are absent, the program prompts without echoing
the values. Output is written under
`json_to_cdsl/input/onshape_api_samples/<abc_id>/features.json`; this input
directory is ignored by Git. Use `--url ID=URL` for another Part Studio or
`--url-file` to read ABC `objects_*.yml` mappings.
## Complete Onshape sample
`download_onshape_complete.py` saves the complete public-API representation
of one Part Studio. It is the appropriate input for building a CDSL converter:
the directory includes the feature tree, sketch definitions and constraints,
FeatureScript representation, parts, body/topology data, mass properties,
tessellations, previews, native Parasolid, STL, and an independently exported
AP242 STEP reference model.
```bash
export ONSHAPE_ACCESS_KEY='...'
export ONSHAPE_SECRET_KEY='...'
python json_to_cdsl/download_onshape_complete.py --id 00000352
```
The result is written to
`json_to_cdsl/input/onshape_complete/00000352/manifest.json`. Every successful
artifact has a byte count and SHA-256 in that manifest. Failed endpoints are
also recorded, rather than silently omitted. This is all data exposed by the
public API for the selected Part Studio; it is not an internal `.onshape`
document backup, which Onshape does not expose as a download format.
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""Download every relevant public API artifact for one Onshape Part Studio.
The output is a self-describing directory for developing and validating an
Onshape-feature-tree-to-CDSL converter. It intentionally collects both the
editable source representation (features, sketches, parameters and queries)
and independent reconstruction targets (STEP, Parasolid, meshes, topology and
mass properties). Credentials are read from environment variables or hidden
terminal prompts and are never written to the output directory.
"""
from __future__ import annotations
import argparse
import base64
import getpass
import hashlib
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable
from download_onshape_samples import PartStudioRef, parse_part_studio_url
API_VERSION = "v17"
DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_complete")
DEFAULT_SAMPLE_ID = "00000352"
DEFAULT_SAMPLE_URL = (
"https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/"
"w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325"
)
JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09"
USER_AGENT = "cdsl-cad-onshape-complete-downloader/1.0"
@dataclass
class DownloadResult:
name: str
status: str
path: str | None = None
url: str | None = None
bytes: int | None = None
sha256: str | None = None
http_status: int | None = None
content_type: str | None = None
message: str | None = None
class OnshapeClient:
def __init__(self, ref: PartStudioRef, authorization: str, timeout: float) -> None:
self.ref = ref
self.authorization = authorization
self.timeout = timeout
self.context = ssl.create_default_context(cafile=self._ca_bundle())
@staticmethod
def _ca_bundle() -> str | None:
try:
import certifi
except ImportError:
return None
return certifi.where()
def url(self, path: str, query: dict[str, Any] | None = None) -> str:
encoded = urllib.parse.urlencode(query or {}, doseq=True)
suffix = f"?{encoded}" if encoded else ""
return f"https://{self.ref.stack}/api/{API_VERSION}{path}{suffix}"
def request(
self,
method: str,
path: str,
query: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
accept: str = JSON_ACCEPT,
) -> tuple[bytes, str | None, str]:
payload = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
self.url(path, query),
data=payload,
method=method,
headers={
"Accept": accept,
"Authorization": self.authorization,
"Content-Type": JSON_ACCEPT,
"User-Agent": USER_AGENT,
},
)
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
return response.read(), response.headers.get_content_type(), response.geturl()
def get_json(self, path: str, query: dict[str, Any] | None = None) -> Any:
raw, _, _ = self.request("GET", path, query)
return json.loads(raw.decode("utf-8"))
def credentials() -> tuple[str, str]:
access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ")
secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ")
if not access_key or not secret_key:
raise ValueError("both Onshape access and secret keys are required")
return access_key, secret_key
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
def file_info(path: Path) -> tuple[int, str]:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return path.stat().st_size, digest.hexdigest()
def relative_path(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def save_json(
client: OnshapeClient,
root: Path,
name: str,
path: str,
filename: str,
query: dict[str, Any] | None = None,
) -> DownloadResult:
try:
value = client.get_json(path, query)
destination = root / filename
destination.parent.mkdir(parents=True, exist_ok=True)
write_json(destination, value)
size, digest = file_info(destination)
return DownloadResult(name, "downloaded", relative_path(destination, root), client.url(path, query), size, digest)
except urllib.error.HTTPError as exc:
return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
return DownloadResult(name, "error", url=client.url(path, query), message=str(exc))
def save_binary(
client: OnshapeClient,
root: Path,
name: str,
path: str,
filename: str,
query: dict[str, Any] | None = None,
accept: str = "application/octet-stream",
) -> DownloadResult:
try:
payload, content_type, final_url = client.request("GET", path, query, accept=accept)
destination = root / filename
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(payload)
size, digest = file_info(destination)
return DownloadResult(name, "downloaded", relative_path(destination, root), final_url, size, digest, content_type=content_type)
except urllib.error.HTTPError as exc:
return DownloadResult(name, "http_error", url=client.url(path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError) as exc:
return DownloadResult(name, "error", url=client.url(path, query), message=str(exc))
def part_studio_path(ref: PartStudioRef, suffix: str) -> str:
return f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}{suffix}"
def collect_sketch_artifacts(
client: OnshapeClient,
root: Path,
sketches: Any,
) -> list[DownloadResult]:
if not isinstance(sketches, list):
return []
results: list[DownloadResult] = []
seen_ids: set[str] = set()
for sketch in sketches:
if not isinstance(sketch, dict):
continue
sketch_id = sketch.get("sketchId") or sketch.get("featureId")
if not isinstance(sketch_id, str) or not sketch_id or sketch_id in seen_ids:
continue
seen_ids.add(sketch_id)
quoted_id = urllib.parse.quote(sketch_id, safe="")
base = part_studio_path(client.ref, f"/sketches/{quoted_id}")
results.append(
save_json(client, root, f"sketch/{sketch_id}/bounding_box", f"{base}/boundingboxes", f"sketches/{sketch_id}/bounding_box.json")
)
results.append(
save_json(client, root, f"sketch/{sketch_id}/tessellation", f"{base}/tessellatedentities", f"sketches/{sketch_id}/tessellated_entities.json")
)
return results
def export_step(client: OnshapeClient, root: Path, sample_id: str, poll_seconds: float, poll_limit: int) -> DownloadResult:
path = part_studio_path(client.ref, "/export/step")
body = {
"destinationName": f"{sample_id}.step",
"grouping": True,
"notifyUser": False,
"storeInDocument": False,
"triggerAutoDownload": False,
"stepUnit": "METER",
"stepVersionString": "AP242",
}
try:
raw, _, _ = client.request("POST", path, body=body)
translation = json.loads(raw.decode("utf-8"))
translation_id = translation.get("id")
if not isinstance(translation_id, str) or not translation_id:
return DownloadResult("step", "error", url=client.url(path), message="STEP export response has no translation id")
request_path = "/translations/" + urllib.parse.quote(translation_id, safe="")
for _ in range(poll_limit):
state = client.get_json(request_path)
request_state = state.get("requestState") if isinstance(state, dict) else None
if request_state == "DONE":
external_ids = state.get("resultExternalDataIds", [])
if not isinstance(external_ids, list) or not external_ids:
return DownloadResult("step", "error", url=client.url(request_path), message="completed STEP export has no external data id")
external_id = external_ids[0]
if not isinstance(external_id, str):
return DownloadResult("step", "error", url=client.url(request_path), message="invalid STEP external data id")
return save_binary(
client,
root,
"step",
f"/documents/d/{client.ref.did}/externaldata/{urllib.parse.quote(external_id, safe='')}",
"model.step",
accept="application/step, application/octet-stream",
)
if request_state == "FAILED":
return DownloadResult("step", "http_error", url=client.url(request_path), message=str(state.get("failureReason", "translation failed")))
time.sleep(poll_seconds)
return DownloadResult("step", "error", url=client.url(request_path), message=f"STEP translation did not finish after {poll_limit} polls")
except urllib.error.HTTPError as exc:
return DownloadResult("step", "http_error", url=client.url(path), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
return DownloadResult("step", "error", url=client.url(path), message=str(exc))
def export_stl(client: OnshapeClient, root: Path) -> DownloadResult:
"""Export a Part Studio STL, retrying the two API-supported encodings.
Some older public documents reject one encoding even though the Part Studio
can otherwise be read. The retry is recorded in the URL and the manifest
retains a useful error if neither server-side export succeeds.
"""
path = part_studio_path(client.ref, "/stl")
attempts = (
{"mode": "binary", "grouping": "true", "units": "METER"},
{"mode": "text", "grouping": "true", "units": "METER"},
{"mode": "binary", "grouping": "false", "units": "METER"},
)
failures: list[str] = []
for query in attempts:
result = save_binary(client, root, "stl", path, "model.stl", query)
if result.status == "downloaded":
return result
detail = result.message or result.status
failures.append(f"{urllib.parse.urlencode(query)}: {detail}")
return DownloadResult("stl", "error", url=client.url(path), message=" | ".join(failures))
def save_shaded_preview(root: Path) -> DownloadResult:
"""Extract the API's base64 PNG view as a locally inspectable preview."""
shaded_path = root / "shaded_views.json"
try:
value = json.loads(shaded_path.read_text(encoding="utf-8"))
images = value.get("images", []) if isinstance(value, dict) else []
encoded = images[0] if isinstance(images, list) and images else None
if not isinstance(encoded, str):
return DownloadResult("shaded_preview", "error", message="shaded view response contains no PNG")
payload = base64.b64decode(encoded, validate=True)
if not payload.startswith(b"\x89PNG\r\n\x1a\n"):
return DownloadResult("shaded_preview", "error", message="shaded view response is not a PNG")
destination = root / "shaded_preview.png"
destination.write_bytes(payload)
size, digest = file_info(destination)
return DownloadResult("shaded_preview", "downloaded", relative_path(destination, root), "shaded_views.json#images[0]", size, digest, content_type="image/png")
except (OSError, ValueError, json.JSONDecodeError) as exc:
return DownloadResult("shaded_preview", "error", message=str(exc))
def complete_download(ref: PartStudioRef, authorization: str, output: Path, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]:
root = output / ref.sample_id
root.mkdir(parents=True, exist_ok=True)
client = OnshapeClient(ref, authorization, timeout)
request_info = {
**asdict(ref),
"api_version": API_VERSION,
"download_contract": "all relevant data exposed by the public API for this Part Studio, not an internal Onshape document backup",
}
write_json(root / "request.json", request_info)
common = {"rollbackBarIndex": -1}
resources: list[tuple[str, str, str, dict[str, Any] | None]] = [
("document", f"/documents/{ref.did}", "document.json", None),
("workspaces", f"/documents/d/{ref.did}/workspaces", "workspaces.json", None),
("elements", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/elements", "elements.json", None),
("unit_info", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/unitinfo", "unit_info.json", None),
("configuration", f"/elements/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/configuration", "configuration.json", None),
("parts", f"/parts/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}", "parts.json", {"withThumbnails": "true", "includeFlatParts": "true"}),
("features", part_studio_path(ref, "/features"), "features.json", {**common, "includeGeometryIds": "true", "noSketchGeometry": "false"}),
("featurescript_representation", part_studio_path(ref, "/featurescriptrepresentation"), "featurescript_representation.json", common),
("feature_specs", part_studio_path(ref, "/featurespecs"), "feature_specs.json", None),
("body_details", part_studio_path(ref, "/bodydetails"), "body_details.json", {**common, "includeSurfaces": "true", "includeCompositeParts": "true", "includeGeometricData": "true"}),
("bounding_boxes", part_studio_path(ref, "/boundingboxes"), "bounding_boxes.json", {"includeHidden": "true", "includeWireBodies": "true"}),
("mass_properties", part_studio_path(ref, "/massproperties"), "mass_properties.json", {**common, "massAsGroup": "true"}),
("sketches", part_studio_path(ref, "/sketches"), "sketches.json", {"includeGeometry": "true", "output3D": "true", "curvePoints": "true"}),
("named_views", f"/partstudios/d/{ref.did}/e/{ref.eid}/namedViews", "named_views.json", None),
("tessellated_faces", part_studio_path(ref, "/tessellatedfaces"), "tessellated_faces.json", {**common, "outputVertexNormals": "true", "outputFacetNormals": "true", "outputIndexTable": "true", "outputErrorFaces": "true"}),
("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common),
("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true", "includeSurfaces": "true", "useAntiAliasing": "true"}),
]
results = [save_json(client, root, *resource) for resource in resources]
sketches_result = next((item for item in results if item.name == "sketches" and item.status == "downloaded"), None)
if sketches_result and sketches_result.path:
sketches = json.loads((root / sketches_result.path).read_text(encoding="utf-8"))
results.extend(collect_sketch_artifacts(client, root, sketches))
results.extend(
[
save_binary(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"),
export_stl(client, root),
save_binary(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true", "outputFaceAppearances": "true"}, "model/gltf+json, model/gltf-binary, application/octet-stream"),
save_binary(client, root, "thumbnail", f"/thumbnails/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/s/512x512", "thumbnail.png", {"rejectEmpty": "true"}, "image/png, image/*, application/octet-stream"),
]
)
results.append(save_shaded_preview(root))
results.append(export_step(client, root, ref.sample_id, poll_seconds, poll_limit))
manifest = {
"schema": "onshape.complete_sample.v1",
"source": request_info,
"resource_count": len(results),
"downloaded_count": sum(item.status == "downloaded" for item in results),
"unavailable_count": sum(item.status != "downloaded" for item in results),
"resources": [asdict(item) for item in results],
}
write_json(root / "manifest.json", manifest)
return manifest
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=DEFAULT_SAMPLE_URL, help="Onshape Part Studio URL")
parser.add_argument("--id", default=DEFAULT_SAMPLE_ID, help="local sample identifier")
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output root")
parser.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds")
parser.add_argument("--poll-seconds", type=float, default=3.0, help="STEP translation poll interval")
parser.add_argument("--poll-limit", type=int, default=40, help="maximum STEP translation polls")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.poll_seconds <= 0 or args.poll_limit < 1:
raise ValueError("poll interval must be positive and poll limit must be at least 1")
ref = parse_part_studio_url(args.id, args.url)
access_key, secret_key = credentials()
token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
manifest = complete_download(ref, f"Basic {token}", args.out, args.timeout, args.poll_seconds, args.poll_limit)
print(json.dumps({key: manifest[key] for key in ("resource_count", "downloaded_count", "unavailable_count")}, indent=2))
return 0 if manifest["downloaded_count"] else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Download a few raw Onshape feature-list responses for format inspection."""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
API_BASE = "https://cad.onshape.com"
DEFAULT_OUTPUT = Path("json_to_cdsl/input/onshape_api_samples")
DEFAULT_SAMPLES = (
(
"00000352",
"https://cad.onshape.com/documents/4185972a944744d8a7a0f2b4/"
"w/d82d7eef8edf4342b7e49732/e/b6d6b562e8b64e7ea50d8325",
),
(
"00000001",
"https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/"
"w/6e295017d1b34be684565c40/e/8df255ee6705423d8e85234e",
),
(
"00000002",
"https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/"
"w/6e295017d1b34be684565c40/e/bb398e4615fe4025b34ea8f0",
),
)
ONSHAPE_URL_RE = re.compile(
r"^https://(?P<stack>[^/]+)/documents/(?P<did>[^/]+)/"
r"(?P<wvm>w|v|m)/(?P<wvmid>[^/]+)/e/(?P<eid>[^/?#]+)"
)
@dataclass(frozen=True)
class PartStudioRef:
sample_id: str
source_url: str
stack: str
did: str
wvm: str
wvmid: str
eid: str
def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef:
match = ONSHAPE_URL_RE.match(url.strip())
if match is None:
raise ValueError(f"not an Onshape Part Studio URL: {url}")
return PartStudioRef(sample_id=sample_id, source_url=url.strip(), **match.groupdict())
def read_url_file(path: Path) -> list[tuple[str, str]]:
"""Read either ABC objects YAML lines or plain '<id> <url>' lines."""
records: list[tuple[str, str]] = []
url_pattern = re.compile(r"https://cad\.onshape\.com/documents/[^'\"\s]+")
id_pattern = re.compile(r"^\s*['\"]?(\d+)['\"]?\s*[:\s]")
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
url_match = url_pattern.search(line)
if url_match is None:
continue
id_match = id_pattern.search(line)
if id_match is None:
raise ValueError(f"{path}:{line_number}: URL has no leading sample ID")
records.append((id_match.group(1).zfill(8), url_match.group(0)))
if not records:
raise ValueError(f"no Onshape Part Studio URLs found in {path}")
return records
def credentials() -> tuple[str, str]:
access_key = os.environ.get("ONSHAPE_ACCESS_KEY")
secret_key = os.environ.get("ONSHAPE_SECRET_KEY")
if not access_key:
access_key = getpass.getpass("Onshape access key: ")
if not secret_key:
secret_key = getpass.getpass("Onshape secret key: ")
if not access_key or not secret_key:
raise ValueError("both Onshape access and secret keys are required")
return access_key, secret_key
def api_url(ref: PartStudioRef) -> str:
query = urllib.parse.urlencode(
{
"rollbackBarIndex": -1,
"includeGeometryIds": "true",
"noSketchGeometry": "false",
}
)
return (
f"https://{ref.stack}/api/v9/partstudios/d/{ref.did}/"
f"{ref.wvm}/{ref.wvmid}/e/{ref.eid}/features?{query}"
)
def fetch_json(url: str, authorization: str, timeout: float) -> Any:
request = urllib.request.Request(
url,
headers={
"Accept": "application/json;charset=UTF-8; qs=0.09",
"Authorization": authorization,
"User-Agent": "cdsl-cad-onshape-sample-downloader/1.0",
},
)
context = ssl.create_default_context(cafile=_ca_bundle())
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
return json.load(response)
def _ca_bundle() -> str | None:
try:
import certifi
except ImportError:
return None
return certifi.where()
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
def download_one(
ref: PartStudioRef,
authorization: str,
output_dir: Path,
timeout: float,
) -> dict[str, Any]:
sample_dir = output_dir / ref.sample_id
sample_dir.mkdir(parents=True, exist_ok=True)
request_url = api_url(ref)
metadata = {
**asdict(ref),
"api_version": "v9",
"features_url": request_url,
}
write_json(sample_dir / "request.json", metadata)
try:
payload = fetch_json(request_url, authorization, timeout)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return {
"sample_id": ref.sample_id,
"status": "http_error",
"http_status": exc.code,
"message": body[:1000],
}
except (urllib.error.URLError, TimeoutError) as exc:
return {
"sample_id": ref.sample_id,
"status": "network_error",
"message": str(exc),
}
output_path = sample_dir / "features.json"
write_json(output_path, payload)
features = payload.get("features", []) if isinstance(payload, dict) else []
feature_types: dict[str, int] = {}
for feature in features:
if not isinstance(feature, dict):
continue
feature_type = str(feature.get("featureType", "unknown"))
feature_types[feature_type] = feature_types.get(feature_type, 0) + 1
return {
"sample_id": ref.sample_id,
"status": "downloaded",
"output": str(output_path),
"feature_count": len(features),
"feature_types": feature_types,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--url",
action="append",
default=[],
metavar="ID=URL",
help="Onshape Part Studio URL with a stable local sample ID; repeatable",
)
parser.add_argument(
"--url-file",
type=Path,
help="ABC objects YAML or plain text file containing '<id> <url>' records",
)
parser.add_argument("--count", type=int, default=3, help="maximum samples to download")
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output directory")
parser.add_argument("--timeout", type=float, default=60.0, help="request timeout in seconds")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.count < 1:
raise ValueError("--count must be at least 1")
records: list[tuple[str, str]] = []
for value in args.url:
if "=" not in value:
raise ValueError("--url must use ID=URL syntax")
records.append(tuple(value.split("=", 1)))
if args.url_file:
records.extend(read_url_file(args.url_file))
if not records:
records.extend(DEFAULT_SAMPLES)
refs = [parse_part_studio_url(sample_id, url) for sample_id, url in records[: args.count]]
access_key, secret_key = credentials()
token = base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
authorization = f"Basic {token}"
args.out.mkdir(parents=True, exist_ok=True)
results = [download_one(ref, authorization, args.out, args.timeout) for ref in refs]
manifest = {
"schema": "onshape.api.samples.v1",
"api_base": API_BASE,
"sample_count": len(results),
"results": results,
}
write_json(args.out / "manifest.json", manifest)
print(json.dumps(manifest, ensure_ascii=True, indent=2))
return 0 if all(item["status"] == "downloaded" for item in results) else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)
+94
View File
@@ -0,0 +1,94 @@
# Onshape to CDSL
This package produces an auditable CDSL dataset from official public Onshape
Part Studio URL mappings. It never treats a feature tree as sufficient proof:
each accepted record has its complete raw API evidence, original AP242 STEP,
CDSL-only rebuilt STEP, and an independent strict geometry report.
```text
scan -> select -> download -> convert -> validate -> dataset/<id>.cdsl.json
```
Run from the repository root. The only runtime path setup needed is the engine
and this source package; no new packaging tool is introduced.
```bash
export ONSHAPE_ACCESS_KEY='...'
export ONSHAPE_SECRET_KEY='...'
export PYTHONPATH=backend/engine:onshape_to_cdsl/src
python -m onshape_to_cdsl scan --url-file /path/to/objects.yml --limit 500 --workers 1
python -m onshape_to_cdsl select --count 100 --seed 0
python -m onshape_to_cdsl download
python -m onshape_to_cdsl convert
python -m onshape_to_cdsl validate
```
To find more than one URL range yields, scan non-overlapping batches and merge
them before deterministic selection:
```bash
python -m onshape_to_cdsl scan --url-file objects_0000.yml --offset 0 --limit 500 --out data/scan/00000.json
python -m onshape_to_cdsl scan --url-file objects_0000.yml --offset 500 --limit 500 --out data/scan/00500.json
python -m onshape_to_cdsl merge-scans --scan data/scan/00000.json --scan data/scan/00500.json --out data/scan/merged.json
python -m onshape_to_cdsl select --scan data/scan/merged.json --count 100 --seed 0
```
For hosts that limit foreground process duration, download the deterministic
selection in resumable batches. Existing artifacts are reused by SHA-256:
```bash
python -m onshape_to_cdsl download --offset 0 --limit 3
python -m onshape_to_cdsl download --offset 3 --limit 3
```
Or run all stages in sequence:
```bash
python -m onshape_to_cdsl run --url-file /path/to/objects.yml --scan-limit 500 --scan-workers 1 --count 100 --seed 0
```
`--url-file` accepts `id URL`, `id: URL`, and the same-line ID/URL records in
ABC `objects_*.yml`. URLs are deliberately not hard-coded in the repository.
Credentials are read from `ONSHAPE_ACCESS_KEY`/`ONSHAPE_SECRET_KEY` or hidden
terminal prompts, and are never written to files or logs.
The official ABC mapping archive is published by NYU as
`abc_objects_00-49.7z` and `abc_objects_50-99.7z`. Each contained
`objects_*.yml` file has 10,000 URLs. Start with `--scan-limit 500`; the
pipeline stratifies those API-inspected candidates and selects 100 complete
downloads. The client honors `429 Retry-After` responses; use one scan worker
unless the API quota explicitly permits higher concurrency.
All generated data lives in ignored `onshape_to_cdsl/data/`:
```text
scan/manifest.json feature classifications for all source URLs
selection/manifest.json deterministic, stratified selected candidates
raw/<id>/ complete public API responses, meshes, STEP and hashes
converted/<id>/ candidate CDSL, rebuild STEP, conversion/validation reports
dataset/<id>.cdsl.json only strict-validation accepted CDSL
rejected/<id>/ candidate/rebuild/report evidence for every failure
reconstruction_issues.md generated rejection and engine capability work queue
reconstruction_issues.json machine-readable version of the same register
```
The v1 executable converter intentionally supports only solved `newSketch`
geometry composed of lines, arcs and circles plus standard, one-direction,
no-draft `BLIND` extrudes (`NEW`, `ADD`, `REMOVE`). The registry audits all
other feature types. Unsupported standard operations are `deferred`; custom
FeatureScript, imports and external/derived dependencies are
`not_admissible`. Their raw feature trees, FeatureScript payload, B-rep and
STEP remain in `raw/`, but they cannot enter `dataset/`.
Admission uses a separate comparator, not the engine's preview comparator.
It requires bidirectional B-rep surface sample maximum and P99 distances at
most `0.01 mm`, six-coordinate bounding-box error at most `0.01 mm`, volume
and surface-area relative error at most `1e-5`, and equal solid counts.
Topology counts/types are report-only diagnostics because kernel exports may
split equivalent faces differently.
`validate` regenerates `reconstruction_issues.md` on every run. It classifies
each failure as a source dependency limitation, converter gap, engine
capability gap, engine execution failure, or strict geometric regression, and
adds engine-specific blockers to an explicit implementation work queue.
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Download AP242 STEP files for public Onshape Part Studios only."""
from __future__ import annotations
import argparse
import base64
import getpass
import hashlib
import json
import os
import re
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
API_VERSION = "v17"
DEFAULT_OUTPUT = Path("onshape_to_cdsl/input/raw")
DEFAULT_SAMPLES = (
(
"00000001",
"https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/"
"w/6e295017d1b34be684565c40/e/8df255ee6705423d8e85234e",
),
(
"00000002",
"https://cad.onshape.com/documents/1ffb81a71e5b402e966b9341/"
"w/6e295017d1b34be684565c40/e/bb398e4615fe4025b34ea8f0",
),
)
ONSHAPE_URL_RE = re.compile(
r"^https://(?P<stack>[^/]+)/documents/(?P<did>[^/]+)/"
r"(?P<wvm>w|v|m)/(?P<wvmid>[^/]+)/e/(?P<eid>[^/?#]+)"
)
JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09"
STEP_ACCEPT = "application/step, application/octet-stream"
USER_AGENT = "cdsl-cad-onshape-step-downloader/1.0"
@dataclass(frozen=True)
class PartStudioRef:
sample_id: str
source_url: str
stack: str
did: str
wvm: str
wvmid: str
eid: str
@dataclass
class StepResult:
status: str
path: str | None = None
bytes: int | None = None
sha256: str | None = None
translation_id: str | None = None
http_status: int | None = None
message: str | None = None
def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef:
match = ONSHAPE_URL_RE.match(url.strip())
if match is None:
raise ValueError(f"not an Onshape Part Studio URL: {url}")
return PartStudioRef(sample_id=sample_id, source_url=url.strip(), **match.groupdict())
def read_url_file(path: Path) -> list[tuple[str, str]]:
"""Read ABC objects YAML lines or plain '<id> <url>' records."""
records: list[tuple[str, str]] = []
url_pattern = re.compile(r"https://cad\.onshape\.com/documents/[^'\"\s]+")
id_pattern = re.compile(r"^\s*['\"]?([A-Za-z0-9_-]+)['\"]?\s*[:\s]")
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
url_match = url_pattern.search(line)
if url_match is None:
continue
id_match = id_pattern.search(line)
if id_match is None:
raise ValueError(f"{path}:{line_number}: URL has no leading sample ID")
records.append((id_match.group(1), url_match.group(0)))
if not records:
raise ValueError(f"no Onshape Part Studio URLs found in {path}")
return records
def credentials() -> tuple[str, str]:
access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ")
secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ")
if not access_key or not secret_key:
raise ValueError("both Onshape access and secret keys are required")
return access_key, secret_key
def ca_bundle() -> str | None:
try:
import certifi
except ImportError:
return None
return certifi.where()
class OnshapeClient:
def __init__(self, ref: PartStudioRef, authorization: str, timeout: float) -> None:
self.ref = ref
self.authorization = authorization
self.timeout = timeout
self.context = ssl.create_default_context(cafile=ca_bundle())
def url(self, path: str) -> str:
return f"https://{self.ref.stack}/api/{API_VERSION}{path}"
def request(
self,
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
accept: str = JSON_ACCEPT,
) -> bytes:
payload = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
self.url(path),
data=payload,
method=method,
headers={
"Accept": accept,
"Authorization": self.authorization,
"Content-Type": JSON_ACCEPT,
"User-Agent": USER_AGENT,
},
)
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
return response.read()
def get_json(self, path: str) -> Any:
return json.loads(self.request("GET", path).decode("utf-8"))
def sha256(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def export_step(
client: OnshapeClient,
root: Path,
*,
poll_seconds: float,
poll_limit: int,
) -> StepResult:
ref = client.ref
export_path = f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/export/step"
body = {
"destinationName": f"{ref.sample_id}.step",
"grouping": True,
"notifyUser": False,
"storeInDocument": False,
"triggerAutoDownload": False,
"stepUnit": "METER",
"stepVersionString": "AP242",
}
try:
translation = json.loads(client.request("POST", export_path, body=body).decode("utf-8"))
translation_id = translation.get("id")
if not isinstance(translation_id, str) or not translation_id:
return StepResult("error", message="STEP export response has no translation id")
translation_path = f"/translations/{urllib.parse.quote(translation_id, safe='')}"
for _ in range(poll_limit):
state = client.get_json(translation_path)
request_state = state.get("requestState") if isinstance(state, dict) else None
if request_state == "DONE":
external_ids = state.get("resultExternalDataIds", [])
if not isinstance(external_ids, list) or not external_ids or not isinstance(external_ids[0], str):
return StepResult("error", translation_id=translation_id, message="completed STEP export has no external data id")
payload = client.request(
"GET",
f"/documents/d/{ref.did}/externaldata/{urllib.parse.quote(external_ids[0], safe='')}",
accept=STEP_ACCEPT,
)
if not payload.startswith(b"ISO-10303-21"):
return StepResult("invalid_step", translation_id=translation_id, message="download is empty or lacks the ISO-10303-21 STEP header")
destination = root / "model.step"
destination.write_bytes(payload)
return StepResult(
"downloaded",
path=destination.name,
bytes=len(payload),
sha256=sha256(payload),
translation_id=translation_id,
)
if request_state == "FAILED":
return StepResult("translation_failed", translation_id=translation_id, message=str(state.get("failureReason") or "translation failed"))
time.sleep(poll_seconds)
return StepResult("translation_timeout", translation_id=translation_id, message=f"STEP translation did not finish after {poll_limit} polls")
except urllib.error.HTTPError as exc:
return StepResult("http_error", http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except (urllib.error.URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc:
return StepResult("error", message=str(exc))
def download_one(ref: PartStudioRef, authorization: str, output: Path, *, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]:
root = output / ref.sample_id
root.mkdir(parents=True, exist_ok=True)
result = export_step(OnshapeClient(ref, authorization, timeout), root, poll_seconds=poll_seconds, poll_limit=poll_limit)
manifest = {
"schema": "onshape.step_sample.v1",
"source": asdict(ref),
"artifact": {"name": "step", **asdict(result)},
}
(root / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
return {"sample_id": ref.sample_id, **asdict(result)}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", action="append", default=[], metavar="ID=URL", help="Onshape Part Studio URL; repeatable")
parser.add_argument("--url-file", type=Path, help="ABC objects YAML or text file containing ID and URL records")
parser.add_argument(
"--count",
type=int,
help="maximum samples to download (defaults to the two built-in samples; custom URLs are appended)",
)
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT, help="output directory")
parser.add_argument("--timeout", type=float, default=60.0, help="per-request timeout in seconds")
parser.add_argument("--poll-seconds", type=float, default=3.0, help="STEP translation poll interval")
parser.add_argument("--poll-limit", type=int, default=40, help="maximum STEP translation polls")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if (args.count is not None and args.count < 1) or args.poll_seconds <= 0 or args.poll_limit < 1:
raise ValueError("count and poll limit must be positive; poll interval must be greater than zero")
records: list[tuple[str, str]] = list(DEFAULT_SAMPLES)
for value in args.url:
if "=" not in value:
raise ValueError("--url must use ID=URL syntax")
records.append(tuple(value.split("=", 1)))
if args.url_file:
records.extend(read_url_file(args.url_file))
limit = args.count if args.count is not None else len(records)
if limit < 1:
raise ValueError("count must be positive")
refs = [parse_part_studio_url(sample_id, url) for sample_id, url in records[:limit]]
access_key, secret_key = credentials()
authorization = "Basic " + base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
results = [download_one(ref, authorization, args.out, timeout=args.timeout, poll_seconds=args.poll_seconds, poll_limit=args.poll_limit) for ref in refs]
summary = {
"schema": "onshape.step_download_summary.v1",
"requested_count": len(refs),
"downloaded_count": sum(item["status"] == "downloaded" for item in results),
"failed_count": sum(item["status"] != "downloaded" for item in results),
"results": results,
}
args.out.mkdir(parents=True, exist_ok=True)
(args.out / "manifest.json").write_text(json.dumps(summary, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, ensure_ascii=True, indent=2))
return 0 if summary["failed_count"] == 0 else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)
@@ -0,0 +1,3 @@
"""Reproducible Onshape Part Studio to CDSL dataset pipeline."""
__version__ = "0.1.0"
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+161
View File
@@ -0,0 +1,161 @@
"""Command line interface for the Onshape to CDSL dataset pipeline."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from .convert import convert
from .download import download
from .manifests import output_is_current, read_json, sha256_file
from .merge_scans import merge_scans
from .scan import scan
from .select import select
from .validate import validate
DEFAULT_DATA = Path("onshape_to_cdsl/data")
def _data_path(data: Path, value: str) -> Path:
return data / value
def _cached_scan(path: Path, url_file: Path) -> dict | None:
if not path.exists():
return None
value = read_json(path)
return value if value.get("url_file_sha256") == sha256_file(url_file) else None
def _cached_selection(path: Path, scan_path: Path, count: int, seed: int) -> dict | None:
if not path.exists():
return None
value = read_json(path)
return value if value.get("scan_manifest_sha256") == sha256_file(scan_path) and value.get("requested_count") == count and value.get("seed") == seed else None
def _cached_download(raw_root: Path, selection_path: Path) -> dict | None:
path = raw_root / "manifest.json"
if not path.exists():
return None
value = read_json(path)
if value.get("selection_sha256") != sha256_file(selection_path):
return None
for record in value.get("records", []):
sample = raw_root / str(record.get("sample_id"))
if not (sample / "model.step").is_file():
return None
return value
def _cached_conversion(converted_root: Path) -> dict | None:
path = converted_root / "manifest.json"
if not path.exists():
return None
value = read_json(path)
for record in value.get("records", []):
if record.get("status") == "converted":
candidate = converted_root / str(record.get("sample_id")) / "candidate.cdsl.json"
if not output_is_current(candidate, record.get("cdsl_sha256")):
return None
return value
def _cached_validation(converted_root: Path, dataset_root: Path) -> dict | None:
path = converted_root / "validation-manifest.json"
if not path.exists():
return None
value = read_json(path)
for record in value.get("records", []):
if record.get("status") == "accepted" and not output_is_current(Path(record["dataset_cdsl"]), record.get("dataset_cdsl_sha256")):
return None
return value
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Resumable Onshape Part Studio to validated CDSL pipeline")
parser.add_argument("--data-root", type=Path, default=DEFAULT_DATA, help="ignored local output root (default: onshape_to_cdsl/data)")
commands = parser.add_subparsers(dest="command", required=True)
scan_parser = commands.add_parser("scan", help="download feature trees only and classify candidates")
scan_parser.add_argument("--url-file", required=True, type=Path)
scan_parser.add_argument("--out", type=Path)
scan_parser.add_argument("--timeout", type=float, default=60.0)
scan_parser.add_argument("--limit", type=int, help="scan only the first N official URLs")
scan_parser.add_argument("--offset", type=int, default=0, help="zero-based source URL offset")
scan_parser.add_argument("--workers", type=int, default=1, help="bounded parallel API requests (default: 1; safest for API quota)")
merge_parser = commands.add_parser("merge-scans", help="combine completed scan batches before selection")
merge_parser.add_argument("--scan", action="append", required=True, type=Path, help="scan manifest; repeatable")
merge_parser.add_argument("--out", required=True, type=Path)
select_parser = commands.add_parser("select", help="deterministically stratify scan results")
select_parser.add_argument("--scan", type=Path)
select_parser.add_argument("--out", type=Path)
select_parser.add_argument("--count", type=int, default=100)
select_parser.add_argument("--seed", type=int, default=0)
download_parser = commands.add_parser("download", help="download all public API evidence and original STEP")
download_parser.add_argument("--selection", type=Path)
download_parser.add_argument("--raw-root", type=Path)
download_parser.add_argument("--timeout", type=float, default=60.0)
download_parser.add_argument("--poll-seconds", type=float, default=3.0)
download_parser.add_argument("--poll-limit", type=int, default=40)
download_parser.add_argument("--offset", type=int, default=0, help="zero-based position in selection")
download_parser.add_argument("--limit", type=int, help="download at most N selected samples")
convert_parser = commands.add_parser("convert", help="convert locally executable histories to CDSL")
convert_parser.add_argument("--raw-root", type=Path)
convert_parser.add_argument("--converted-root", type=Path)
validate_parser = commands.add_parser("validate", help="CDSL-only rebuild and strict STEP admission")
validate_parser.add_argument("--converted-root", type=Path)
validate_parser.add_argument("--dataset-root", type=Path)
validate_parser.add_argument("--rejected-root", type=Path)
run_parser = commands.add_parser("run", help="execute scan, select, download, convert, and validate")
run_parser.add_argument("--url-file", required=True, type=Path)
run_parser.add_argument("--count", type=int, default=100)
run_parser.add_argument("--seed", type=int, default=0)
run_parser.add_argument("--timeout", type=float, default=60.0)
run_parser.add_argument("--poll-seconds", type=float, default=3.0)
run_parser.add_argument("--poll-limit", type=int, default=40)
run_parser.add_argument("--scan-limit", type=int, default=500, help="number of official URLs to scan before stratifying (default: 500)")
run_parser.add_argument("--scan-workers", type=int, default=1, help="bounded parallel scan requests (default: 1; safest for API quota)")
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
data = args.data_root
scan_path, selection_path, raw_root, converted_root = _data_path(data, "scan/manifest.json"), _data_path(data, "selection/manifest.json"), _data_path(data, "raw"), _data_path(data, "converted")
if args.command == "scan":
result = scan(args.url_file, args.out or scan_path, timeout=args.timeout, limit=args.limit, offset=args.offset, workers=args.workers)
elif args.command == "merge-scans":
result = merge_scans(args.scan, args.out)
elif args.command == "select":
if args.count < 1:
raise ValueError("--count must be positive")
result = select(args.scan or scan_path, args.out or selection_path, count=args.count, seed=args.seed)
elif args.command == "download":
result = download(args.selection or selection_path, args.raw_root or raw_root, timeout=args.timeout, poll_seconds=args.poll_seconds, poll_limit=args.poll_limit, offset=args.offset, limit=args.limit)
elif args.command == "convert":
result = convert(args.raw_root or raw_root, args.converted_root or converted_root)
elif args.command == "validate":
result = validate(args.converted_root or converted_root, args.dataset_root or _data_path(data, "dataset"), args.rejected_root or _data_path(data, "rejected"))
else:
# A stage is reused only when its manifest proves that the material it
# consumed/produced still has the recorded hash. This keeps `run`
# restartable after an interrupted 100-sample network acquisition.
cached_scan = _cached_scan(scan_path, args.url_file)
if cached_scan and (cached_scan.get("scan_limit") != args.scan_limit or cached_scan.get("scan_workers") != args.scan_workers):
cached_scan = None
result = {"scan": cached_scan or scan(args.url_file, scan_path, timeout=args.timeout, limit=args.scan_limit, workers=args.scan_workers)}
result["selection"] = _cached_selection(selection_path, scan_path, args.count, args.seed) or select(scan_path, selection_path, count=args.count, seed=args.seed)
result["download"] = _cached_download(raw_root, selection_path) or download(selection_path, raw_root, timeout=args.timeout, poll_seconds=args.poll_seconds, poll_limit=args.poll_limit)
result["convert"] = _cached_conversion(converted_root) or convert(raw_root, converted_root)
dataset_root = _data_path(data, "dataset")
result["validate"] = _cached_validation(converted_root, dataset_root) or validate(converted_root, dataset_root, _data_path(data, "rejected"))
# Detailed reports remain on disk. Do not flood a terminal with runtime
# topology evidence for every selected Part Studio.
if args.command == "run":
printable = {stage: {key: value for key, value in report.items() if key in {"schema", "sample_count", "selected_count", "converted_count", "accepted_count", "rejected_count"}} for stage, report in result.items()}
else:
printable = {key: value for key, value in result.items() if key in {"schema", "sample_count", "selected_count", "converted_count", "accepted_count", "rejected_count"}}
print(json.dumps(printable, ensure_ascii=True, indent=2, sort_keys=True))
return 0
@@ -0,0 +1,106 @@
"""Independent strict STEP comparator used for dataset admission only."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any, Iterable
TOLERANCE_MM = 0.01
RELATIVE_TOLERANCE = 1e-5
def _point(value: Any) -> tuple[float, float, float]:
return (float(value.X), float(value.Y), float(value.Z))
def _surface_points(shape: Any, tolerance_mm: float) -> list[tuple[float, float, float]]:
"""Deterministically sample vertices and triangle centroids on every face."""
points: dict[tuple[int, int, int], tuple[float, float, float]] = {}
def add(point: tuple[float, float, float]) -> None:
points.setdefault(tuple(round(value / 1e-8) for value in point), point)
for face in shape.faces():
try:
vertices, triangles = face.tessellate(tolerance_mm)
rendered = [_point(vertex) for vertex in vertices]
for vertex in rendered:
add(vertex)
for triangle in triangles:
indices = list(triangle)
if len(indices) >= 3:
first, second, third = (rendered[int(index)] for index in indices[:3])
add(tuple((first[axis] + second[axis] + third[axis]) / 3.0 for axis in range(3)))
except Exception:
# A malformed individual face should not make the comparison pass.
for vertex in face.vertices():
add(_point(vertex))
if not points:
raise ValueError("STEP contains no sampleable faces")
return list(points.values())
def _distances(points: Iterable[tuple[float, float, float]], target: Any) -> list[float]:
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.gp import gp_Pnt
values: list[float] = []
for x, y, z in points:
vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex()
distance = BRepExtrema_DistShapeShape()
distance.LoadS1(vertex)
distance.LoadS2(target.wrapped)
distance.Perform()
if not distance.IsDone() or distance.NbSolution() < 1:
raise ValueError("OCC point-to-B-rep distance computation failed")
values.append(float(distance.Value()))
return values
def _percentile(values: list[float], percentile: float) -> float:
if not values:
return math.inf
ordered = sorted(values)
index = min(len(ordered) - 1, math.ceil(percentile * len(ordered)) - 1)
return ordered[index]
def _bbox(shape: Any) -> list[float]:
box = shape.bounding_box()
return [float(box.min.X), float(box.min.Y), float(box.min.Z), float(box.max.X), float(box.max.Y), float(box.max.Z)]
def strict_compare(gold_step: Path, rebuilt_step: Path, *, surface_tolerance_mm: float = 0.05) -> dict[str, Any]:
"""Compare source and rebuilt B-reps without the engine's permissive rules."""
from build123d import import_step
gold, rebuilt = import_step(str(gold_step)), import_step(str(rebuilt_step))
gold_samples = _surface_points(gold, surface_tolerance_mm)
rebuilt_samples = _surface_points(rebuilt, surface_tolerance_mm)
forward, reverse = _distances(gold_samples, rebuilt), _distances(rebuilt_samples, gold)
gold_box, rebuilt_box = _bbox(gold), _bbox(rebuilt)
bbox_delta = max(abs(a - b) for a, b in zip(gold_box, rebuilt_box))
volume_error = abs(float(gold.volume) - float(rebuilt.volume)) / max(abs(float(gold.volume)), 1e-12)
area_error = abs(float(gold.area) - float(rebuilt.area)) / max(abs(float(gold.area)), 1e-12)
forward_max, reverse_max = max(forward), max(reverse)
forward_p99, reverse_p99 = _percentile(forward, 0.99), _percentile(reverse, 0.99)
reasons = []
if max(forward_max, reverse_max) > TOLERANCE_MM:
reasons.append("surface_max_exceeds_0.01mm")
if max(forward_p99, reverse_p99) > TOLERANCE_MM:
reasons.append("surface_p99_exceeds_0.01mm")
if bbox_delta > TOLERANCE_MM:
reasons.append("bbox_exceeds_0.01mm")
if volume_error > RELATIVE_TOLERANCE:
reasons.append("volume_relative_error_exceeds_1e-5")
if area_error > RELATIVE_TOLERANCE:
reasons.append("surface_area_relative_error_exceeds_1e-5")
gold_solids, rebuilt_solids = len(gold.solids()), len(rebuilt.solids())
if gold_solids != rebuilt_solids:
reasons.append("solid_count_mismatch")
return {
"schema": "onshape_to_cdsl.strict_step_compare.v1", "tolerance_mm": TOLERANCE_MM, "surface_tessellation_tolerance_mm": surface_tolerance_mm,
"gold_step": str(gold_step), "rebuilt_step": str(rebuilt_step), "passed": not reasons, "failure_reasons": reasons,
"surface": {"gold_to_rebuilt": {"sample_count": len(forward), "max_mm": forward_max, "p95_mm": _percentile(forward, .95), "p99_mm": forward_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in forward)}, "rebuilt_to_gold": {"sample_count": len(reverse), "max_mm": reverse_max, "p95_mm": _percentile(reverse, .95), "p99_mm": reverse_p99, "over_tolerance_count": sum(value > TOLERANCE_MM for value in reverse)}},
"metrics": {"gold_bbox_mm": gold_box, "rebuilt_bbox_mm": rebuilt_box, "bbox_max_delta_mm": bbox_delta, "gold_volume_mm3": float(gold.volume), "rebuilt_volume_mm3": float(rebuilt.volume), "volume_relative_error": volume_error, "gold_surface_area_mm2": float(gold.area), "rebuilt_surface_area_mm2": float(rebuilt.area), "surface_area_relative_error": area_error, "gold_solid_count": gold_solids, "rebuilt_solid_count": rebuilt_solids},
"diagnostic_topology": {"gold_faces": len(gold.faces()), "rebuilt_faces": len(rebuilt.faces()), "gold_edges": len(gold.edges()), "rebuilt_edges": len(rebuilt.edges()), "gold_surface_types": sorted(str(face.geom_type) for face in gold.faces()), "rebuilt_surface_types": sorted(str(face.geom_type) for face in rebuilt.faces())},
}
@@ -0,0 +1,112 @@
"""Convert fully-audited standard feature histories into self-contained CDSL."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from .manifests import read_json, sha256_file, write_json
from .registry import rule_for
from .sketches import sketch_to_cdsl
from .units import length_mm
_SKETCH_QUERY = re.compile(r'qSketchRegion\(id\+"(?P<id>[^"]+)"')
def _parameters(feature: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {str(item.get("parameterId")): item for item in feature.get("parameters") or [] if isinstance(item, dict) and item.get("parameterId")}
def _value(parameters: dict[str, dict[str, Any]], name: str) -> Any:
return (parameters.get(name) or {}).get("value")
def _sketch_id(parameters: dict[str, dict[str, Any]]) -> str:
queries = (parameters.get("entities") or {}).get("queries") or []
matches = [match.group("id") for query in queries if isinstance(query, dict) for match in _SKETCH_QUERY.finditer(str(query.get("queryString") or ""))]
if len(set(matches)) != 1:
raise ValueError("extrude profile query does not uniquely name one sketch")
return matches[0]
def _extrude(feature: dict[str, Any], previous: list[str]) -> dict[str, Any]:
parameters = _parameters(feature)
end_bound = _value(parameters, "endBound")
operation = _value(parameters, "operationType")
if end_bound != "BLIND":
raise ValueError(f"only BLIND extrudes are executable, got {end_bound!r}")
if _value(parameters, "hasDraft") is True or _value(parameters, "hasSecondDirection") is True:
raise ValueError("draft and two-direction extrudes are not executable in v1")
atomic = "extrude_cut_blind" if operation in {"REMOVE", "CUT"} else "extrude_add_blind" if operation in {"NEW", "ADD"} else None
if atomic is None:
raise ValueError(f"unsupported extrude operation {operation!r}")
expression = (parameters.get("depth") or {}).get("expression")
return {"id": "f_" + str(feature["featureId"]).replace("-", "_"), "name": str(feature.get("name") or feature["featureId"]), "atomic_id": atomic, "depends_on": list(previous[-1:]), "sketch_id": _sketch_id(parameters), "params": {"distance_mm": length_mm(expression), "reverse": bool(_value(parameters, "oppositeDirection"))}, "execution_status": "supported"}
def convert_one(raw_dir: Path, converted_root: Path) -> dict[str, Any]:
sample_id = raw_dir.name
output = converted_root / sample_id
output.mkdir(parents=True, exist_ok=True)
diagnostics: list[dict[str, Any]] = []
try:
features_payload = read_json(raw_dir / "features.json")
sketches_payload = read_json(raw_dir / "sketches.json")
features = features_payload.get("features") or []
solved = {str(item.get("featureId")): item for item in sketches_payload.get("sketches") or [] if isinstance(item, dict) and item.get("featureId")}
cdsl_sketches: dict[str, dict[str, Any]] = {}
cdsl_features: list[dict[str, Any]] = []
history: list[str] = []
admissible = True
for index, feature in enumerate(features):
if not isinstance(feature, dict):
continue
feature_type = str(feature.get("featureType") or "unknown")
rule = rule_for(feature_type)
feature_id = str(feature.get("featureId") or f"index_{index}")
if feature_type == "newSketch":
if feature_id not in solved:
diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": "deferred", "reason": "solved sketch payload missing"})
admissible = False
continue
try:
cdsl_sketches[feature_id] = sketch_to_cdsl(solved[feature_id])
except ValueError as exc:
diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": "deferred", "reason": str(exc)})
admissible = False
continue
if feature_type == "extrude":
try:
converted = _extrude(feature, history)
if converted["sketch_id"] not in cdsl_sketches:
raise ValueError("referenced sketch is missing or not executable")
cdsl_features.append(converted)
history.append(converted["id"])
except ValueError as exc:
diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": "deferred", "reason": str(exc)})
admissible = False
continue
diagnostics.append({"feature_id": feature_id, "feature_type": feature_type, "status": rule.status, "reason": rule.reason})
if rule.status != "supported":
admissible = False
provenance = {name: sha256_file(raw_dir / name) for name in ("features.json", "sketches.json", "model.step") if (raw_dir / name).exists()}
result: dict[str, Any] = {"schema": "onshape_to_cdsl.conversion.v1", "sample_id": sample_id, "raw_dir": str(raw_dir), "source_sha256": provenance, "diagnostics": diagnostics, "status": "converted" if admissible and cdsl_features else "rejected"}
if result["status"] == "converted":
cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.0.0", "kind": "part", "part_id": sample_id, "meta": {"source": "onshape", "provenance": provenance}, "geometry": {"sketches": list(cdsl_sketches.values())}, "features": cdsl_features}
write_json(output / "candidate.cdsl.json", cdsl)
result["cdsl_sha256"] = sha256_file(output / "candidate.cdsl.json")
write_json(output / "conversion.json", result)
return result
except Exception as exc:
result = {"schema": "onshape_to_cdsl.conversion.v1", "sample_id": sample_id, "raw_dir": str(raw_dir), "status": "failed", "failure_reason": f"{type(exc).__name__}: {exc}"}
write_json(output / "conversion.json", result)
return result
def convert(raw_root: Path, converted_root: Path) -> dict[str, Any]:
records = [convert_one(path, converted_root) for path in sorted(raw_root.iterdir()) if path.is_dir() and (path / "manifest.json").exists()]
manifest = {"schema": "onshape_to_cdsl.convert.v1", "raw_root": str(raw_root), "sample_count": len(records), "converted_count": sum(record["status"] == "converted" for record in records), "records": records}
write_json(converted_root / "manifest.json", manifest)
return manifest
@@ -0,0 +1,167 @@
"""Complete Part Studio acquisition with per-artifact hashes and safe resume."""
from __future__ import annotations
import base64
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass
import json
import os
from pathlib import Path
import time
import urllib.error
import urllib.parse
from typing import Any
from .manifests import output_is_current, sha256_file, write_json
from .onshape_api import JSON_ACCEPT, OnshapeClient, authorization_from_environment, part_studio_path
from .source_urls import PartStudioRef
@dataclass
class Artifact:
name: str
status: str
path: str | None = None
sha256: str | None = None
bytes: int | None = None
url: str | None = None
http_status: int | None = None
message: str | None = None
def _save(client: OnshapeClient, root: Path, name: str, api_path: str, filename: str, query: dict[str, Any] | None = None, accept: str = JSON_ACCEPT) -> Artifact:
target = root / filename
if target.exists():
return Artifact(name, "reused", filename, sha256_file(target), target.stat().st_size)
try:
payload, url = client.request("GET", api_path, query=query, accept=accept)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
return Artifact(name, "downloaded", filename, sha256_file(target), len(payload), url)
except urllib.error.HTTPError as exc:
return Artifact(name, "http_error", url=client.url(api_path, query), http_status=exc.code, message=exc.read().decode("utf-8", "replace")[:1000])
except Exception as exc:
return Artifact(name, "error", url=client.url(api_path, query), message=f"{type(exc).__name__}: {exc}")
def _export_step(client: OnshapeClient, root: Path, sample_id: str, poll_seconds: float, poll_limit: int) -> Artifact:
target = root / "model.step"
if target.exists() and target.read_bytes()[:12] == b"ISO-10303-21":
return Artifact("step", "reused", "model.step", sha256_file(target), target.stat().st_size)
path = part_studio_path(client.ref, "/export/step")
body = {"destinationName": f"{sample_id}.step", "grouping": True, "notifyUser": False, "storeInDocument": False, "triggerAutoDownload": False, "stepUnit": "METER", "stepVersionString": "AP242"}
try:
# STEP generation is asynchronous and can outlive a short-lived CLI
# process. Keep only the opaque server job id locally so a resumed
# download polls the existing export instead of creating duplicates.
state_file = root / ".step-translation.json"
try:
translation_id = json.loads(state_file.read_text(encoding="utf-8")).get("translation_id")
except (OSError, json.JSONDecodeError):
translation_id = None
if not isinstance(translation_id, str):
response, _ = client.request("POST", path, body=body)
translation_id = json.loads(response.decode("utf-8")).get("id")
if isinstance(translation_id, str):
write_json(state_file, {"translation_id": translation_id})
if not isinstance(translation_id, str):
return Artifact("step", "error", message="STEP export response has no translation id")
translation_path = "/translations/" + urllib.parse.quote(translation_id, safe="")
for _ in range(poll_limit):
state = client.get_json(translation_path)
if state.get("requestState") == "DONE":
ids = state.get("resultExternalDataIds") or []
if not ids or not isinstance(ids[0], str):
return Artifact("step", "error", message="STEP translation has no external data id")
artifact = _save(client, root, "step", f"/documents/d/{client.ref.did}/externaldata/{urllib.parse.quote(ids[0], safe='')}", "model.step", accept="application/step, application/octet-stream")
if artifact.status in {"downloaded", "reused"}:
state_file.unlink(missing_ok=True)
return artifact
if state.get("requestState") == "FAILED":
state_file.unlink(missing_ok=True)
return Artifact("step", "translation_failed", message=str(state.get("failureReason") or "translation failed"))
time.sleep(poll_seconds)
return Artifact("step", "translation_timeout", message=f"not complete after {poll_limit} polls")
except Exception as exc:
return Artifact("step", "error", message=f"{type(exc).__name__}: {exc}")
def _sketch_artifacts(client: OnshapeClient, root: Path) -> list[Artifact]:
sketches_path = root / "sketches.json"
if not sketches_path.exists():
return []
try:
sketches = json.loads(sketches_path.read_text(encoding="utf-8")).get("sketches", [])
except (OSError, json.JSONDecodeError):
return []
results: list[Artifact] = []
for sketch in sketches if isinstance(sketches, list) else []:
feature_id = sketch.get("featureId") if isinstance(sketch, dict) else None
if not isinstance(feature_id, str):
continue
base = part_studio_path(client.ref, f"/sketches/{urllib.parse.quote(feature_id, safe='')}")
results.append(_save(client, root, f"sketch/{feature_id}/bounding_box", base + "/boundingboxes", f"sketches/{feature_id}/bounding_box.json"))
results.append(_save(client, root, f"sketch/{feature_id}/tessellation", base + "/tessellatedentities", f"sketches/{feature_id}/tessellated_entities.json"))
return results
def download_one(ref: PartStudioRef, authorization: str, raw_root: Path, *, timeout: float, poll_seconds: float, poll_limit: int) -> dict[str, Any]:
root = raw_root / ref.sample_id
root.mkdir(parents=True, exist_ok=True)
client = OnshapeClient(ref, authorization, timeout)
common = {"rollbackBarIndex": -1}
artifacts: list[Artifact] = []
resources = [
("document", f"/documents/{ref.did}", "document.json", None), ("workspaces", f"/documents/d/{ref.did}/workspaces", "workspaces.json", None),
("elements", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/elements", "elements.json", None), ("unit_info", f"/documents/d/{ref.did}/{ref.wvm}/{ref.wvmid}/unitinfo", "unit_info.json", None),
("configuration", f"/elements/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}/configuration", "configuration.json", None),
("parts", f"/parts/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}", "parts.json", {"withThumbnails": "true", "includeFlatParts": "true"}),
("features", part_studio_path(ref, "/features"), "features.json", {**common, "includeGeometryIds": "true", "noSketchGeometry": "false"}),
("featurescript_representation", part_studio_path(ref, "/featurescriptrepresentation"), "featurescript_representation.json", common),
("feature_specs", part_studio_path(ref, "/featurespecs"), "feature_specs.json", None),
("body_details", part_studio_path(ref, "/bodydetails"), "body_details.json", {**common, "includeSurfaces": "true", "includeCompositeParts": "true", "includeGeometricData": "true"}),
("bounding_boxes", part_studio_path(ref, "/boundingboxes"), "bounding_boxes.json", {"includeHidden": "true", "includeWireBodies": "true"}),
("mass_properties", part_studio_path(ref, "/massproperties"), "mass_properties.json", {**common, "massAsGroup": "true"}),
("sketches", part_studio_path(ref, "/sketches"), "sketches.json", {"includeGeometry": "true", "output3D": "true", "curvePoints": "true"}),
("named_views", f"/partstudios/d/{ref.did}/e/{ref.eid}/namedViews", "named_views.json", None),
("tessellated_faces", part_studio_path(ref, "/tessellatedfaces"), "tessellated_faces.json", {**common, "outputVertexNormals": "true", "outputFacetNormals": "true", "outputIndexTable": "true", "outputErrorFaces": "true"}),
("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common),
("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true"}),
]
# These document and Part Studio evidence endpoints are independent. A
# bounded fan-out keeps an interactive acquisition from being cut off
# before it can reach the asynchronous STEP export, while preserving the
# same per-artifact resume and error records.
worker_count = max(1, min(16, int(os.environ.get("ONSHAPE_DOWNLOAD_WORKERS", "4"))))
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="onshape-download") as executor:
artifacts.extend(executor.map(lambda item: _save(client, root, *item), resources))
artifacts.extend(_sketch_artifacts(client, root))
artifacts.extend([
_save(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"),
_save(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true"}, "model/gltf+json, application/octet-stream"),
_export_step(client, root, ref.sample_id, poll_seconds, poll_limit),
])
manifest = {"schema": "onshape_to_cdsl.raw_sample.v1", "source": ref.as_dict(), "resource_count": len(artifacts), "downloaded_count": sum(item.status in {"downloaded", "reused"} for item in artifacts), "unavailable_count": sum(item.status not in {"downloaded", "reused"} for item in artifacts), "resources": [asdict(item) for item in artifacts]}
write_json(root / "manifest.json", manifest)
return manifest
def download(selection: Path, raw_root: Path, *, timeout: float = 60.0, poll_seconds: float = 3.0, poll_limit: int = 40, offset: int = 0, limit: int | None = None) -> dict[str, Any]:
from .manifests import read_json
if offset < 0 or limit is not None and limit < 1:
raise ValueError("download offset must be non-negative and limit must be positive")
authorization = authorization_from_environment()
records = []
selected = list(read_json(selection).get("records", []))[offset: None if limit is None else offset + limit]
for item in selected:
ref = PartStudioRef(**item["source"])
manifest = download_one(ref, authorization, raw_root, timeout=timeout, poll_seconds=poll_seconds, poll_limit=poll_limit)
records.append({"sample_id": ref.sample_id, "status": "downloaded" if (raw_root / ref.sample_id / "model.step").exists() else "incomplete", "manifest": str(raw_root / ref.sample_id / "manifest.json"), "resource_count": manifest["resource_count"]})
summary = {"schema": "onshape_to_cdsl.download.v1", "selection": str(selection), "selection_sha256": sha256_file(selection), "offset": offset, "limit": limit, "sample_count": len(records), "records": records}
# A batch manifest remains useful evidence even when an interactive host
# enforces a short process lifetime. The next batch reuses all artifacts.
write_json(raw_root / f"manifest-{offset:05d}.json", summary)
if offset == 0 and limit is None:
write_json(raw_root / "manifest.json", summary)
return summary
@@ -0,0 +1,112 @@
"""Generate a durable reconstruction issue register from pipeline evidence."""
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from .manifests import read_json, write_json
_ENGINE_FEATURES = {
"revolve", "hole", "fillet", "chamfer", "linearPattern", "circularPattern", "mirror", "draft",
"loft", "sweep", "shell", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "rib",
"booleanBodies", "sheetMetal",
}
def _issue(sample_id: str, category: str, subsystem: str, detail: str, *, feature_id: str | None = None, feature_type: str | None = None) -> dict[str, str]:
result = {"sample_id": sample_id, "category": category, "subsystem": subsystem, "detail": detail}
if feature_id:
result["feature_id"] = feature_id
if feature_type:
result["feature_type"] = feature_type
return result
def _conversion_issues(sample_id: str, conversion: dict[str, Any]) -> list[dict[str, str]]:
issues: list[dict[str, str]] = []
for diagnostic in conversion.get("diagnostics") or []:
if not isinstance(diagnostic, dict):
continue
feature_type = str(diagnostic.get("feature_type") or "unknown")
detail = str(diagnostic.get("reason") or "no diagnostic reason")
status = diagnostic.get("status")
if status == "not_admissible":
category, subsystem = "source_not_admissible", "source_dependency"
elif feature_type in _ENGINE_FEATURES:
category, subsystem = "engine_capability_gap", "cdsl_engine"
else:
category, subsystem = "converter_gap", "onshape_converter"
issues.append(_issue(sample_id, category, subsystem, detail, feature_id=str(diagnostic.get("feature_id") or ""), feature_type=feature_type))
if conversion.get("status") == "failed":
issues.append(_issue(sample_id, "converter_failure", "onshape_converter", str(conversion.get("failure_reason") or "unknown conversion failure")))
return issues
def _validation_issues(sample_id: str, validation: dict[str, Any]) -> list[dict[str, str]]:
if validation.get("status") != "rejected":
return []
comparison = validation.get("comparison")
if isinstance(comparison, dict):
reasons = comparison.get("failure_reasons") or ["strict_comparison_failed"]
return [_issue(sample_id, "geometric_regression", "strict_step_comparator", str(reason)) for reason in reasons]
reason = str(validation.get("failure_reason") or "unknown validation failure")
if reason == "conversion_not_executable":
return [] # The detailed conversion diagnostics above are the source of truth.
return [_issue(sample_id, "engine_execution_failure", "cdsl_engine", reason)]
def _markdown(issues: list[dict[str, str]], summary: dict[str, int]) -> str:
lines = [
"# Reconstruction Issue Register",
"",
"This file is generated by `onshape_to_cdsl validate`. It records rejected samples and separates source limitations, converter gaps, engine capability gaps, engine execution failures, and strict geometric regressions.",
"",
"## Summary",
"",
"| Category | Count |",
"| --- | ---: |",
]
lines.extend(f"| {category} | {count} |" for category, count in sorted(summary.items()))
if not issues:
lines.extend(["", "No reconstruction issues were recorded.", ""])
return "\n".join(lines)
lines.extend(["", "## Issues", "", "| Sample | Category | Subsystem | Feature | Detail |", "| --- | --- | --- | --- | --- |"])
for issue in issues:
feature = issue.get("feature_type") or issue.get("feature_id") or "-"
detail = issue["detail"].replace("|", "\\|").replace("\n", " ")
lines.append(f"| {issue['sample_id']} | {issue['category']} | {issue['subsystem']} | {feature} | {detail} |")
lines.extend(["", "## Engine Work Queue", ""])
grouped: dict[str, set[str]] = defaultdict(set)
for issue in issues:
if issue["category"] in {"engine_capability_gap", "engine_execution_failure"}:
grouped[issue["category"]].add(issue["detail"])
if not grouped:
lines.append("No engine-specific blockers were observed in this run.")
else:
for category, details in sorted(grouped.items()):
lines.append(f"### {category}")
lines.append("")
lines.extend(f"- {detail}" for detail in sorted(details))
lines.append("")
return "\n".join(lines)
def write_issue_register(converted_root: Path, validation_records: list[dict[str, Any]]) -> dict[str, Any]:
"""Persist both machine-readable and Markdown summaries under `data/`."""
issues: list[dict[str, str]] = []
for validation in validation_records:
sample_id = str(validation["sample_id"])
conversion_path = converted_root / sample_id / "conversion.json"
if conversion_path.exists():
issues.extend(_conversion_issues(sample_id, read_json(conversion_path)))
issues.extend(_validation_issues(sample_id, validation))
issues.sort(key=lambda issue: (issue["category"], issue["sample_id"], issue.get("feature_type", ""), issue["detail"]))
summary = dict(sorted(Counter(issue["category"] for issue in issues).items()))
register = {"schema": "onshape_to_cdsl.reconstruction_issues.v1", "sample_count": len(validation_records), "issue_count": len(issues), "summary": summary, "issues": issues}
data_root = converted_root.parent
write_json(data_root / "reconstruction_issues.json", register)
(data_root / "reconstruction_issues.md").write_text(_markdown(issues, summary), encoding="utf-8")
return register
@@ -0,0 +1,42 @@
"""Small, atomic JSON helpers used by every resumable pipeline stage."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
from typing import Any
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def write_json(path: Path, value: Any) -> None:
"""Atomically write JSON so interrupted stages never leave a valid-looking file."""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(temporary, path)
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def output_is_current(path: Path, expected_sha256: str | None = None) -> bool:
return path.is_file() and (expected_sha256 is None or sha256_file(path) == expected_sha256)
def jsonl_write(path: Path, items: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
with temporary.open("w", encoding="utf-8") as stream:
for item in items:
stream.write(json.dumps(item, ensure_ascii=True, sort_keys=True) + "\n")
os.replace(temporary, path)
@@ -0,0 +1,31 @@
"""Combine non-overlapping scan batches into one deterministic selection input."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .manifests import read_json, sha256_file, write_json
def merge_scans(paths: list[Path], out: Path) -> dict[str, Any]:
if not paths:
raise ValueError("at least one scan manifest is required")
records: list[dict[str, Any]] = []
source_hashes: list[dict[str, str]] = []
seen: set[str] = set()
for path in paths:
scan = read_json(path)
for record in scan.get("records") or []:
sample_id = str(record.get("sample_id") or "")
if not sample_id:
raise ValueError(f"{path}: scan record has no sample_id")
if sample_id in seen:
raise ValueError(f"{path}: duplicate sample_id {sample_id}")
seen.add(sample_id)
records.append(record)
source_hashes.append({"path": str(path), "sha256": sha256_file(path)})
records.sort(key=lambda item: str(item["sample_id"]))
manifest = {"schema": "onshape_to_cdsl.merged_scan.v1", "source_scans": source_hashes, "sample_count": len(records), "records": records}
write_json(out, manifest)
return manifest
@@ -0,0 +1,72 @@
"""Minimal authenticated Onshape v17 client. Credentials never enter manifests."""
from __future__ import annotations
import base64
import getpass
import json
import os
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
from .source_urls import PartStudioRef
API_VERSION = "v17"
JSON_ACCEPT = "application/json;charset=UTF-8; qs=0.09"
def authorization_from_environment() -> str:
access_key = os.environ.get("ONSHAPE_ACCESS_KEY") or getpass.getpass("Onshape access key: ")
secret_key = os.environ.get("ONSHAPE_SECRET_KEY") or getpass.getpass("Onshape secret key: ")
if not access_key or not secret_key:
raise ValueError("both ONSHAPE_ACCESS_KEY and ONSHAPE_SECRET_KEY are required")
return "Basic " + base64.b64encode(f"{access_key}:{secret_key}".encode("utf-8")).decode("ascii")
class OnshapeClient:
def __init__(self, ref: PartStudioRef, authorization: str, timeout: float = 60.0) -> None:
self.ref, self.authorization, self.timeout = ref, authorization, timeout
try:
import certifi
self.context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
self.context = ssl.create_default_context()
def url(self, path: str, query: dict[str, Any] | None = None) -> str:
encoded = urllib.parse.urlencode(query or {}, doseq=True)
return f"https://{self.ref.stack}/api/{API_VERSION}{path}" + (f"?{encoded}" if encoded else "")
def request(self, method: str, path: str, *, query: dict[str, Any] | None = None, body: dict[str, Any] | None = None, accept: str = JSON_ACCEPT) -> tuple[bytes, str]:
request = urllib.request.Request(
self.url(path, query), data=json.dumps(body).encode("utf-8") if body is not None else None,
method=method, headers={"Accept": accept, "Authorization": self.authorization, "Content-Type": JSON_ACCEPT, "User-Agent": "onshape-to-cdsl/0.1"},
)
# Public ABC URLs can be scanned in large batches. Respect Onshape's
# rate limit rather than recording a transient 429 as a dead document.
retry_limit = max(0, int(os.environ.get("ONSHAPE_RATE_LIMIT_RETRIES", "5")))
for attempt in range(retry_limit + 1):
try:
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
return response.read(), response.geturl()
except urllib.error.HTTPError as exc:
if exc.code != 429 or attempt == retry_limit:
raise
retry_after = exc.headers.get("Retry-After")
try:
pause = float(retry_after) if retry_after is not None else min(60.0, 2.0 ** attempt)
except ValueError:
pause = min(60.0, 2.0 ** attempt)
time.sleep(max(1.0, pause))
raise RuntimeError("unreachable Onshape retry loop")
def get_json(self, path: str, query: dict[str, Any] | None = None) -> Any:
return json.loads(self.request("GET", path, query=query)[0].decode("utf-8"))
def part_studio_path(ref: PartStudioRef, suffix: str) -> str:
return f"/partstudios/d/{ref.did}/{ref.wvm}/{ref.wvmid}/e/{ref.eid}{suffix}"
@@ -0,0 +1,45 @@
"""Explicit feature support contract. Unknown does not mean executable."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class FeatureRule:
status: str
atomic_id: str | None = None
reason: str = ""
RULES: dict[str, FeatureRule] = {
"newSketch": FeatureRule("supported", reason="solved sketch is captured separately"),
"extrude": FeatureRule("supported", reason="only BLIND without draft is executable in v1"),
"revolve": FeatureRule("deferred", "revolve_add", "requires robust source axis/region resolution"),
"hole": FeatureRule("deferred", "hole_blind", "requires host face topology binding"),
"fillet": FeatureRule("deferred", "fillet", "requires stable source edge selector"),
"chamfer": FeatureRule("deferred", "chamfer", "requires stable source edge selector"),
"linearPattern": FeatureRule("deferred", "pattern_linear", "requires source feature references"),
"circularPattern": FeatureRule("deferred", reason="circular pattern is not implemented by the engine"),
"mirror": FeatureRule("deferred", "pattern_mirror", "requires source plane selector"),
"draft": FeatureRule("deferred", reason="draft executor is not implemented by the engine"),
"loft": FeatureRule("deferred", reason="loft executor is not implemented by the engine"),
"sweep": FeatureRule("deferred", reason="sweep executor is not implemented by the engine"),
"shell": FeatureRule("deferred", reason="shell executor is not implemented by the engine"),
"thicken": FeatureRule("deferred", reason="thicken executor is not implemented by the engine"),
"split": FeatureRule("deferred", reason="split executor is not implemented by the engine"),
"moveFace": FeatureRule("deferred", reason="direct face editing is not generically replayable"),
"replaceFace": FeatureRule("deferred", reason="direct face editing is not generically replayable"),
"deleteFace": FeatureRule("deferred", reason="direct face editing is not generically replayable"),
"derive": FeatureRule("not_admissible", reason="external document dependency"),
"import": FeatureRule("not_admissible", reason="imported geometry has no local parametric source"),
}
def rule_for(feature_type: object) -> FeatureRule:
name = str(feature_type or "unknown")
if name in RULES:
return RULES[name]
if "custom" in name.lower() or "featurescript" in name.lower() or "derive" in name.lower() or "import" in name.lower():
return FeatureRule("not_admissible", reason="custom FeatureScript, imported, or external feature")
return FeatureRule("deferred", reason=f"no registered local executor for Onshape feature {name}")
@@ -0,0 +1,69 @@
"""Feature-only scan used for selection; it deliberately does not export STEP."""
from __future__ import annotations
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from .manifests import sha256_file, write_json
from .onshape_api import OnshapeClient, authorization_from_environment, part_studio_path
from .registry import rule_for
from .source_urls import read_url_file
def _feature_summary(payload: Any) -> dict[str, Any]:
features = payload.get("features", []) if isinstance(payload, dict) else []
types: Counter[str] = Counter()
states: Counter[str] = Counter()
for feature in features:
if not isinstance(feature, dict):
continue
feature_type = str(feature.get("featureType") or "unknown")
types[feature_type] += 1
states[rule_for(feature_type).status] += 1
return {"feature_count": sum(types.values()), "feature_types": dict(sorted(types.items())), "admissibility": dict(sorted(states.items()))}
def _curve_types(payload: Any) -> Counter[str]:
curves: Counter[str] = Counter()
features = payload.get("features", []) if isinstance(payload, dict) else []
for feature in features:
if not isinstance(feature, dict) or feature.get("featureType") != "newSketch":
continue
for entity in feature.get("entities") or []:
geometry = entity.get("geometry") if isinstance(entity, dict) else None
if isinstance(geometry, dict):
curves[str(geometry.get("btType") or "unknown")] += 1
return curves
def scan(url_file: Path, out: Path, *, timeout: float = 60.0, limit: int | None = None, offset: int = 0, workers: int = 1) -> dict[str, Any]:
authorization = authorization_from_environment()
if offset < 0:
raise ValueError("scan offset must be non-negative")
if workers < 1 or workers > 32:
raise ValueError("scan workers must be between 1 and 32")
refs = read_url_file(url_file)
if limit is not None:
if limit < 1:
raise ValueError("scan limit must be positive")
refs = refs[offset:offset + limit]
else:
refs = refs[offset:]
def inspect(ref: Any) -> dict[str, Any]:
entry: dict[str, Any] = {"sample_id": ref.sample_id, "source": ref.as_dict()}
try:
payload = OnshapeClient(ref, authorization, timeout).get_json(part_studio_path(ref, "/features"), {"rollbackBarIndex": -1, "includeGeometryIds": "true", "noSketchGeometry": "false"})
summary = _feature_summary(payload)
summary["curve_types"] = dict(sorted(_curve_types(payload).items()))
entry.update({"status": "scanned", **summary})
except Exception as exc: # A failed public document must remain auditable too.
entry.update({"status": "failed", "failure_reason": f"{type(exc).__name__}: {exc}"})
return entry
with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="onshape-scan") as executor:
records = list(executor.map(inspect, refs))
manifest = {"schema": "onshape_to_cdsl.scan.v1", "url_file": str(url_file), "url_file_sha256": sha256_file(url_file), "source_record_count": len(read_url_file(url_file)), "scan_offset": offset, "scan_limit": limit, "scan_workers": workers, "sample_count": len(records), "records": records}
write_json(out, manifest)
return manifest
@@ -0,0 +1,40 @@
"""Deterministic stratified candidate selection."""
from __future__ import annotations
import random
from collections import defaultdict
from pathlib import Path
from typing import Any
from .manifests import read_json, sha256_file, write_json
def _stratum(record: dict[str, Any]) -> str:
feature_types = record.get("feature_types") or {}
curves = record.get("curve_types") or {}
feature_band = "1-3" if record.get("feature_count", 0) <= 3 else "4-8" if record.get("feature_count", 0) <= 8 else "9+"
return "|".join((",".join(sorted(feature_types)) or "none", ",".join(sorted(curves)) or "none", feature_band))
def select(scan_manifest: Path, out: Path, *, count: int, seed: int) -> dict[str, Any]:
scan = read_json(scan_manifest)
eligible = [record for record in scan.get("records", []) if record.get("status") == "scanned"]
buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
for record in eligible:
buckets[_stratum(record)].append(record)
rng = random.Random(seed)
for values in buckets.values():
rng.shuffle(values)
chosen: list[dict[str, Any]] = []
# Round robin preserves uncommon strata while keeping a fixed seed order.
keys = sorted(buckets)
while len(chosen) < min(count, len(eligible)) and any(buckets.values()):
for key in keys:
if buckets[key] and len(chosen) < count:
item = dict(buckets[key].pop())
item["stratum"] = key
chosen.append(item)
manifest = {"schema": "onshape_to_cdsl.selection.v1", "scan_manifest": str(scan_manifest), "scan_manifest_sha256": sha256_file(scan_manifest), "seed": seed, "requested_count": count, "selected_count": len(chosen), "records": chosen}
write_json(out, manifest)
return manifest
@@ -0,0 +1,120 @@
"""Solved Onshape sketch geometry to self-contained CDSL analytic contours."""
from __future__ import annotations
import math
from typing import Any
MM_PER_M = 1000.0
def _vector(value: Any) -> list[float]:
if not isinstance(value, dict):
raise ValueError("missing Onshape 3D point")
return [float(value[key]) * MM_PER_M for key in ("x", "y", "z")]
def _dot(left: list[float], right: list[float]) -> float:
return sum(a * b for a, b in zip(left, right))
def _subtract(left: list[float], right: list[float]) -> list[float]:
return [a - b for a, b in zip(left, right)]
def _cross(left: list[float], right: list[float]) -> list[float]:
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]]
def _unit(value: list[float]) -> list[float]:
length = math.sqrt(_dot(value, value))
if length < 1e-10:
raise ValueError("zero sketch coordinate direction")
return [component / length for component in value]
def workplane_from_matrix(matrix: Any) -> dict[str, list[float]]:
"""Decode Onshape's row-major 4x4 sketch transform into millimetres."""
if not isinstance(matrix, list) or len(matrix) != 16:
raise ValueError("sketchMatrix must contain 16 values")
values = [float(value) for value in matrix]
x_dir = _unit([values[0], values[1], values[2]])
y_dir = _unit([values[4], values[5], values[6]])
normal = _unit(_cross(x_dir, y_dir))
# API sketchMatrix serializes a row-major transform; translation is 3,7,11.
return {"origin_mm": [values[3] * MM_PER_M, values[7] * MM_PER_M, values[11] * MM_PER_M], "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
def _local(point: list[float], plane: dict[str, list[float]]) -> list[float]:
displacement = _subtract(point, plane["origin_mm"])
return [_dot(displacement, plane["x_dir"]), _dot(displacement, plane["y_dir"])]
def _endpoint_key(point: list[float], tolerance_mm: float = 1e-5) -> tuple[int, int]:
return (round(point[0] / tolerance_mm), round(point[1] / tolerance_mm))
def _ordered_contours(segments: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
"""Join non-circular edges by endpoints; circles are independent closed contours."""
circles = [[segment] for segment in segments if segment["type"] == "circle"]
edges = [segment for segment in segments if segment["type"] != "circle"]
unused = set(range(len(edges)))
contours: list[list[dict[str, Any]]] = []
while unused:
current_index = unused.pop()
contour = [edges[current_index]]
first = _endpoint_key(contour[0]["start_mm"])
end = _endpoint_key(contour[0]["end_mm"])
while end != first:
match = next((index for index in unused if _endpoint_key(edges[index]["start_mm"]) == end or _endpoint_key(edges[index]["end_mm"]) == end), None)
if match is None:
raise ValueError("sketch has an open or disconnected profile contour")
unused.remove(match)
edge = dict(edges[match])
if _endpoint_key(edge["end_mm"]) == end:
edge["start_mm"], edge["end_mm"] = edge["end_mm"], edge["start_mm"]
if edge["type"] == "arc":
edge["clockwise"] = not bool(edge.get("clockwise", False))
contour.append(edge)
end = _endpoint_key(edge["end_mm"])
contours.append(contour)
return contours + circles
def sketch_to_cdsl(sketch: dict[str, Any]) -> dict[str, Any]:
plane = workplane_from_matrix(sketch.get("sketchMatrix"))
segments: list[dict[str, Any]] = []
unsupported: list[str] = []
for entity in sketch.get("entities") or []:
if not isinstance(entity, dict) or entity.get("isConstruction"):
continue
entity_type = entity.get("sketchEntityType")
geometry = entity.get("geometry") or {}
try:
if entity_type == "skLineSegment":
segments.append({"type": "line", "start_mm": _local(_vector(entity["startPosition3d"]), plane), "end_mm": _local(_vector(entity["endPosition3d"]), plane)})
elif entity_type == "skArc":
segments.append({"type": "arc", "start_mm": _local(_vector(entity["startPosition3d"]), plane), "end_mm": _local(_vector(entity["endPosition3d"]), plane), "center_mm": _local(_vector(geometry["center3d"]), plane), "radius_mm": float(geometry["radius"]) * MM_PER_M, "clockwise": bool(geometry.get("clockWise", False))})
elif entity_type == "skCircle":
segments.append({"type": "circle", "center_mm": _local(_vector(geometry["center3d"]), plane), "radius_mm": float(geometry["radius"]) * MM_PER_M})
elif entity_type != "skPoint":
unsupported.append(str(entity_type))
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"{entity.get('sketchEntityId', '?')}: {exc}") from exc
if unsupported:
raise ValueError("unsupported solved sketch entities: " + ", ".join(sorted(set(unsupported))))
if not segments:
raise ValueError("sketch has no non-construction executable geometry")
contours = _ordered_contours(segments)
def profile_segment(segment: dict[str, Any]) -> dict[str, Any]:
output = {key: value for key, value in segment.items() if key not in {"start_mm", "end_mm", "center_mm"}}
if "start_mm" in segment:
output["start"] = segment["start_mm"]
output["end"] = segment["end_mm"]
if "center_mm" in segment:
output["center"] = segment["center_mm"]
return output
return {"id": str(sketch["featureId"]), "name": str(sketch.get("name") or sketch["featureId"]), "workplane": plane, "profile": {"type": "analytic_contours", "contours": [{"role": "unknown", "closed": True, "segments": [profile_segment(segment) for segment in contour]} for contour in contours]}}
@@ -0,0 +1,66 @@
"""Parse official ABC URL mapping files without depending on PyYAML."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import re
from pathlib import Path
ONSHAPE_URL_RE = re.compile(
r"^https://(?P<stack>[^/]+)/documents/(?P<did>[^/]+)/(?P<wvm>w|v|m)/(?P<wvmid>[^/]+)/e/(?P<eid>[^/?#]+)"
)
URL_RE = re.compile(r"https://[^'\"\s]+/documents/[^'\"\s]+")
# ABC's source files are Python/YAML-style mapping literals: the first record
# starts with `{`, all following records are indented, and the final record
# ends in `}`. Accept the optional opening delimiter without parsing arbitrary
# YAML or evaluating source text.
LEADING_ID_RE = re.compile(r"^\s*(?:\{\s*)?(?:-\s*)?['\"]?(?P<id>[A-Za-z0-9_-]+)['\"]?\s*(?::|\s)")
@dataclass(frozen=True)
class PartStudioRef:
sample_id: str
source_url: str
stack: str
did: str
wvm: str
wvmid: str
eid: str
def as_dict(self) -> dict[str, str]:
return asdict(self)
def parse_part_studio_url(sample_id: str, url: str) -> PartStudioRef:
source_url = url.strip().rstrip("',\"]")
match = ONSHAPE_URL_RE.match(source_url)
if match is None:
raise ValueError(f"{sample_id}: not an Onshape Part Studio URL: {url}")
return PartStudioRef(sample_id=str(sample_id), source_url=source_url, **match.groupdict())
def read_url_file(path: Path) -> list[PartStudioRef]:
"""Read `id URL`, `id: URL`, or ABC objects YAML records.
ABC mappings can contain unrelated fields. A URL line is accepted only
when its object ID appears on the same line, which prevents silently
associating a URL with an adjacent record.
"""
refs: list[PartStudioRef] = []
seen: set[str] = set()
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
url = URL_RE.search(line)
if not url:
continue
identifier = LEADING_ID_RE.match(line)
if not identifier:
raise ValueError(f"{path}:{number}: Onshape URL requires a leading sample ID")
sample_id = identifier.group("id")
if sample_id in seen:
raise ValueError(f"{path}:{number}: duplicate sample ID {sample_id!r}")
seen.add(sample_id)
refs.append(parse_part_studio_url(sample_id, url.group(0)))
if not refs:
raise ValueError(f"{path}: no Onshape Part Studio URLs found")
return refs
@@ -0,0 +1,18 @@
"""Conservative parser for Onshape length expressions used by standard features."""
from __future__ import annotations
import re
_LENGTH_RE = re.compile(r"^\s*(?P<value>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?:\*\s*)?(?P<unit>mm|millimeter(?:s)?|cm|m|meter(?:s)?|in|inch|inches|ft|foot|feet)\s*$", re.I)
_SCALE = {"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "cm": 10.0, "m": 1000.0, "meter": 1000.0, "meters": 1000.0, "in": 25.4, "inch": 25.4, "inches": 25.4, "ft": 304.8, "foot": 304.8, "feet": 304.8}
def length_mm(expression: object) -> float:
if not isinstance(expression, str):
raise ValueError("missing length expression")
match = _LENGTH_RE.match(expression)
if not match:
raise ValueError(f"unsupported length expression {expression!r}")
return float(match.group("value")) * _SCALE[match.group("unit").lower()]
@@ -0,0 +1,59 @@
"""CDSL-only rebuild, strict comparison, and accepted/rejected dataset routing."""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from .compare import strict_compare
from .issues import write_issue_register
from .manifests import read_json, sha256_file, write_json
def validate_one(converted_dir: Path, dataset_root: Path, rejected_root: Path) -> dict[str, Any]:
conversion = read_json(converted_dir / "conversion.json")
sample_id = str(conversion["sample_id"])
output = converted_dir / "rebuild.step"
report: dict[str, Any] = {"schema": "onshape_to_cdsl.validation.v1", "sample_id": sample_id, "conversion_status": conversion.get("status")}
if conversion.get("status") != "converted":
report.update({"status": "rejected", "failure_reason": "conversion_not_executable"})
else:
try:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = read_json(converted_dir / "candidate.cdsl.json")
report["rebuild"] = rebuild_cdsl(cdsl, output, strict=True)
report["rebuild"]["engine"] = "cdsl_session_runtime"
gold = Path(str(conversion["raw_dir"])) / "model.step"
report["comparison"] = strict_compare(gold, output)
report["status"] = "accepted" if report["comparison"]["passed"] else "rejected"
if report["status"] == "rejected":
report["failure_reason"] = ",".join(report["comparison"]["failure_reasons"])
except Exception as exc:
report.update({"status": "rejected", "failure_reason": f"{type(exc).__name__}: {exc}"})
if report["status"] == "accepted":
dataset_root.mkdir(parents=True, exist_ok=True)
destination = dataset_root / f"{sample_id}.cdsl.json"
shutil.copy2(converted_dir / "candidate.cdsl.json", destination)
report["dataset_cdsl"] = str(destination)
report["dataset_cdsl_sha256"] = sha256_file(destination)
else:
destination = rejected_root / sample_id
destination.mkdir(parents=True, exist_ok=True)
for name in ("candidate.cdsl.json", "rebuild.step", "conversion.json"):
source = converted_dir / name
if source.exists():
shutil.copy2(source, destination / name)
report["rejection_dir"] = str(destination)
write_json(converted_dir / "validation.json", report)
if report["status"] == "rejected":
shutil.copy2(converted_dir / "validation.json", Path(report["rejection_dir"]) / "validation.json")
return report
def validate(converted_root: Path, dataset_root: Path, rejected_root: Path) -> dict[str, Any]:
records = [validate_one(path, dataset_root, rejected_root) for path in sorted(converted_root.iterdir()) if path.is_dir() and (path / "conversion.json").exists()]
issues = write_issue_register(converted_root, records)
manifest = {"schema": "onshape_to_cdsl.validation_summary.v1", "converted_root": str(converted_root), "sample_count": len(records), "accepted_count": sum(item["status"] == "accepted" for item in records), "rejected_count": sum(item["status"] == "rejected" for item in records), "reconstruction_issues": {"path": str(converted_root.parent / "reconstruction_issues.md"), "issue_count": issues["issue_count"], "summary": issues["summary"]}, "records": records}
write_json(converted_root / "validation-manifest.json", manifest)
return manifest
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import json
from pathlib import Path
import shutil
import tempfile
import unittest
from onshape_to_cdsl.compare import strict_compare
from onshape_to_cdsl.convert import convert_one
from onshape_to_cdsl.issues import write_issue_register
from onshape_to_cdsl.merge_scans import merge_scans
from onshape_to_cdsl.select import select
from onshape_to_cdsl.sketches import sketch_to_cdsl, workplane_from_matrix
from onshape_to_cdsl.source_urls import read_url_file
from onshape_to_cdsl.units import length_mm
class PipelineTests(unittest.TestCase):
def test_url_file_and_units(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
mapping = Path(temporary) / "objects.yml"
mapping.write_text("{'00000352': 'https://cad.onshape.com/documents/doc/w/work/e/element'}\n", encoding="utf-8")
reference = read_url_file(mapping)[0]
self.assertEqual(reference.sample_id, "00000352")
self.assertEqual(reference.did, "doc")
self.assertAlmostEqual(length_mm(".063 in"), 1.6002)
self.assertAlmostEqual(length_mm("1.2*cm"), 12.0)
with self.assertRaises(ValueError):
length_mm("width")
def test_solved_rectangle_becomes_closed_analytic_contour(self) -> None:
matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, .002, 0, 0, 0, 1]
self.assertEqual(workplane_from_matrix(matrix)["origin_mm"], [0.0, 0.0, 2.0])
point = lambda x, y: {"x": x / 1000, "y": y / 1000, "z": .002}
entities = []
for start, end in [((0, 0), (10, 0)), ((10, 0), (10, 5)), ((10, 5), (0, 5)), ((0, 5), (0, 0))]:
entities.append({"sketchEntityType": "skLineSegment", "isConstruction": False, "startPosition3d": point(*start), "endPosition3d": point(*end)})
cdsl = sketch_to_cdsl({"featureId": "sketch_one", "sketchMatrix": matrix, "entities": entities})
contour = cdsl["profile"]["contours"][0]
self.assertTrue(contour["closed"])
self.assertEqual(len(contour["segments"]), 4)
self.assertEqual(contour["segments"][0]["type"], "line")
def test_conversion_rejects_unsupported_history(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) / "raw" / "sample_01"
root.mkdir(parents=True)
(root / "features.json").write_text(json.dumps({"features": [{"featureId": "x", "featureType": "loft", "parameters": []}]}), encoding="utf-8")
(root / "sketches.json").write_text(json.dumps({"sketches": []}), encoding="utf-8")
result = convert_one(root, Path(temporary) / "converted")
self.assertEqual(result["status"], "rejected")
self.assertEqual(result["diagnostics"][0]["status"], "deferred")
def test_issue_register_summarizes_engine_capability_gaps(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
converted = Path(temporary) / "converted"
sample = converted / "sample_01"
sample.mkdir(parents=True)
(sample / "conversion.json").write_text(json.dumps({"sample_id": "sample_01", "status": "rejected", "diagnostics": [{"feature_id": "loft_1", "feature_type": "loft", "status": "deferred", "reason": "loft executor is not implemented by the engine"}]}), encoding="utf-8")
register = write_issue_register(converted, [{"sample_id": "sample_01", "status": "rejected", "failure_reason": "conversion_not_executable"}])
self.assertEqual(register["summary"], {"engine_capability_gap": 1})
document = (Path(temporary) / "reconstruction_issues.md").read_text(encoding="utf-8")
self.assertIn("Engine Work Queue", document)
self.assertIn("loft executor", document)
def test_deterministic_stratified_selection(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
scan = Path(temporary) / "scan.json"
records = [{"sample_id": str(index), "status": "scanned", "feature_count": index, "feature_types": {"extrude": 1}, "curve_types": {"line": 4}} for index in range(8)]
scan.write_text(json.dumps({"records": records}), encoding="utf-8")
one = select(scan, Path(temporary) / "one.json", count=4, seed=7)
two = select(scan, Path(temporary) / "two.json", count=4, seed=7)
self.assertEqual([item["sample_id"] for item in one["records"]], [item["sample_id"] for item in two["records"]])
def test_merge_scan_batches_rejects_duplicate_ids(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
first, second = root / "first.json", root / "second.json"
first.write_text(json.dumps({"records": [{"sample_id": "0001", "status": "scanned"}]}), encoding="utf-8")
second.write_text(json.dumps({"records": [{"sample_id": "0002", "status": "failed"}]}), encoding="utf-8")
merged = merge_scans([first, second], root / "merged.json")
self.assertEqual([item["sample_id"] for item in merged["records"]], ["0001", "0002"])
second.write_text(json.dumps({"records": [{"sample_id": "0001", "status": "failed"}]}), encoding="utf-8")
with self.assertRaises(ValueError):
merge_scans([first, second], root / "bad.json")
def test_strict_comparator_accepts_identical_and_rejects_translation(self) -> None:
from build123d import Box, export_step
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, same, shifted = root / "source.step", root / "same.step", root / "shifted.step"
box = Box(10, 10, 10)
export_step(box, source)
export_step(box, same)
moved = Box(10, 10, 10).translate((0.02, 0, 0))
export_step(moved, shifted)
self.assertTrue(strict_compare(source, same)["passed"])
rejection = strict_compare(source, shifted)
self.assertFalse(rejection["passed"])
self.assertIn("bbox_exceeds_0.01mm", rejection["failure_reasons"])
def test_00000352_offline_end_to_end_when_fixture_is_available(self) -> None:
fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352"
if not (fixture / "model.step").exists():
self.skipTest("optional local 00000352 raw fixture is not installed")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
raw = root / "raw" / "00000352"
raw.parent.mkdir()
shutil.copytree(fixture, raw)
result = convert_one(raw, root / "converted")
self.assertEqual(result["status"], "converted")
from cdsl_engine.runtime import rebuild_cdsl
from cdsl_engine.semantic_validation import validate_semantic_cdsl
cdsl_path = root / "converted/00000352/candidate.cdsl.json"
cdsl = json.loads(cdsl_path.read_text(encoding="utf-8"))
validate_semantic_cdsl(cdsl)
rebuilt = root / "rebuild.step"
rebuild_cdsl(cdsl, rebuilt, strict=True)
comparison = strict_compare(raw / "model.step", rebuilt)
self.assertTrue(comparison["passed"], comparison["failure_reasons"])
if __name__ == "__main__":
unittest.main()