优化流程
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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": "动作选择",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
output/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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>/`.
|
||||
@@ -0,0 +1,4 @@
|
||||
"""CADFS FeatureScript to CDSL conversion and validation pipeline."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,3 +2,5 @@
|
||||
|
||||
Architecture decisions, API contracts, engine usage, data formats, and
|
||||
development notes belong here.
|
||||
|
||||
- [CDSL format structure](cdsl-format.md)
|
||||
|
||||
@@ -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`;它记录导出状态,实际能否重建仍取决于当前引擎预检。
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
@@ -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(";") : "")
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user