优化流程
This commit is contained in:
@@ -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"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user