添加cadfs_to_cdsl代码,优化cad生成
This commit is contained in:
@@ -45,3 +45,4 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
text-to-cad-requirements/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# CAD Agent 运行原则
|
||||
|
||||
## 首要目标
|
||||
|
||||
产品的首要目标是稳定、可执行的 CAD 生成流程。任何已经获得可执行检查点的任务,系统都必须发布当前最完整、最可用的 CAD 模型。严格满足全部需求是期望目标,但绝不能因此进入无限修复循环,也不能丢弃本来可用的模型。
|
||||
|
||||
必须将以下两类结果严格区分:
|
||||
|
||||
- **执行可靠性:** 工作流只接收有效协议数据,只调用引擎支持的操作,保留有效工作;只要存在可执行模型,就必须以最佳可用结果终止。
|
||||
- **需求符合度:** 最终模型可能满足全部、部分或不满足用户要求。未满足项必须作为警告和证据报告,不能成为销毁、隐藏可用模型的理由。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 服务端负责校验输出格式、schema、有限数值、操作可用性、selector/reference 有效性、Runtime 安全、持久化和状态转换。
|
||||
- 服务端不得判断 LLM 是否“正确理解”了用户的设计语义。不得引入基于语义分歧而阻止建模的 reviewer 循环。
|
||||
- 可以使用工程默认值补全常见但描述不足的零件,但未指定的值不得变成虚构的、严格的确定性验收尺寸。例如,仅因为 verifier 需要数值,不能把未指定的孔径编译成硬性的 `1 mm` claim。
|
||||
- 不要求 LLM 输出内部运行时 ID,例如 task、revision、action、candidate、requirement、claim、evidence、source ID。所有这些值由服务端从当前状态生成和绑定。
|
||||
- LLM 的规划、坐标、宿主面、操作选择或几何策略错误,通常属于模型质量问题,不是程序失败。必须保留诊断,并按下述恢复规则继续。
|
||||
- schema 无效、引擎不支持的操作、非法状态转换、provider 协议不兼容、持久化失败或 Runtime 崩溃,属于程序、协议或服务问题,必须准确报告。
|
||||
|
||||
|
||||
## 结果与用户沟通
|
||||
|
||||
- 始终保留并暴露最新可执行 STEP、GLB 和渲染工件。
|
||||
- 完成结果必须区分:已满足目标、未解决目标、因依赖跳过的操作、Runtime/协议故障和 LLM 规划局限。
|
||||
- 不得把未满足需求声称为已通过;同样不得因仍有未满足需求而隐藏可用模型。
|
||||
- 需求文档和完成文档是面向用户的工程工件。需求文档可以用已接受的 CAD 默认规则完善描述不足的提示词;完成结果必须描述生成模型实际达成了什么。
|
||||
|
||||
## 改动评审指引
|
||||
|
||||
修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。
|
||||
@@ -124,7 +124,7 @@ class ProfileCadRuntime:
|
||||
result["ref_" + sha256(f"{document_hash}|{feature_id}".encode("utf-8")).hexdigest()[:16]] = feature_id
|
||||
return result
|
||||
|
||||
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 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, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
# ActionCommandHandler validates the exposed schema first, but this
|
||||
# adapter is also used during crash recovery. Keep the runtime boundary
|
||||
# self-contained so a corrupted/replayed staged payload cannot produce
|
||||
@@ -168,7 +168,24 @@ class ProfileCadRuntime:
|
||||
if not isinstance(supplied, list) or not all(token in reference_tokens for token in supplied):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference token is absent or stale")
|
||||
materialized["feature"]["params"][slot] = [reference_tokens[token] for token in supplied]
|
||||
self._semantic_preflight(materialized, contract, selector_tokens, base_cdsl, require_through=require_through)
|
||||
through_normalizations = self._normalize_required_through_cut_depth(
|
||||
materialized,
|
||||
contract,
|
||||
selector_tokens,
|
||||
require_through=require_through,
|
||||
)
|
||||
cut_support_normal = self._semantic_preflight(
|
||||
materialized,
|
||||
contract,
|
||||
selector_tokens,
|
||||
base_cdsl,
|
||||
require_through=require_through,
|
||||
)
|
||||
direction_normalizations = self._normalize_extrude_cut_direction(
|
||||
materialized,
|
||||
contract,
|
||||
cut_support_normal,
|
||||
)
|
||||
document = deepcopy(base_cdsl) if isinstance(base_cdsl, dict) else {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [],
|
||||
}
|
||||
@@ -177,9 +194,17 @@ class ProfileCadRuntime:
|
||||
features = document.setdefault("features", [])
|
||||
if not isinstance(sketches, list) or not isinstance(features, list):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: base CDSL collections are invalid")
|
||||
existing_feature_ids = {
|
||||
str(item.get("id") or "")
|
||||
for item in features
|
||||
if isinstance(item, dict) and str(item.get("id") or "")
|
||||
}
|
||||
direct_dependencies = tuple(str(value) for value in depends_on_feature_ids)
|
||||
if len(direct_dependencies) != len(set(direct_dependencies)) or any(value not in existing_feature_ids for value in direct_dependencies):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: plan dependency feature is absent from the active checkpoint")
|
||||
index = len(features) + 1
|
||||
feature = materialized["feature"]
|
||||
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": [str(features[-1].get("id"))] if features else []}
|
||||
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": list(direct_dependencies)}
|
||||
if contract["fragment_shape"]["sketch"] == "required":
|
||||
sketch_id = f"sketch_{len(sketches) + 1:03d}"
|
||||
sketch = materialized["sketch"]
|
||||
@@ -225,35 +250,69 @@ class ProfileCadRuntime:
|
||||
"RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: "
|
||||
+ message
|
||||
) from error
|
||||
return document, {"schema_version": "cad.v3.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else []}
|
||||
return document, {"schema_version": "cad.v3.2.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "depends_on_feature_ids": list(direct_dependencies), "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else [], "server_normalizations": [*through_normalizations, *direction_normalizations]}
|
||||
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
"""Build the exact geometry checkpoint required by every DAG node.
|
||||
|
||||
Rendering is deliberately absent here: a renderer outage must never
|
||||
make a geometrically valid atomic feature fail its local acceptance.
|
||||
"""
|
||||
root = Path(output_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
cdsl_copy = deepcopy(cdsl)
|
||||
cdsl_copy["part_id"] = task_id
|
||||
step_path = root / "model.step"
|
||||
glb_path = root / "model.glb"
|
||||
self._write_json(root / "model.cdsl.json", cdsl_copy)
|
||||
try:
|
||||
self._validate_finite_tree(cdsl_copy)
|
||||
validate_cdsl(cdsl_copy, self.engine)
|
||||
except RuntimeAdapterError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: persisted CDSL failed rebuild preflight: {error}") from error
|
||||
try:
|
||||
engine_result = self.engine.run_cdsl_only(cdsl_copy, step_path)
|
||||
if str(engine_result.get("engine") or "") != "cdsl_only":
|
||||
raise RuntimeAdapterError("engine did not execute CDSL-only rebuild")
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
health = self._health(engine_result, step_path, glb_path)
|
||||
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id, preview=preview)
|
||||
health = self._health(engine_result, step_path)
|
||||
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id)
|
||||
self._write_json(root / "model.topology.json", topology)
|
||||
manifest = render_checkpoint(self.settings, step_path=step_path, output_dir=root / "renders")
|
||||
report = {"engine_result": engine_result, "preview": preview, "health": health, "render_manifest": manifest}
|
||||
report = {"engine_result": engine_result, "preview": {}, "health": health, "render_manifest": {}}
|
||||
self._write_json(root / "rebuild-report.json", report)
|
||||
return {"health": health, "topology": topology, "report": report, "render_manifest": manifest, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json", "render_manifest": "renders/render-manifest.json"}}
|
||||
return {"health": health, "topology": topology, "report": report, "render_manifest": {}, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "topology": "model.topology.json", "report": "rebuild-report.json"}}
|
||||
except OSError:
|
||||
raise
|
||||
except RuntimeAdapterError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
|
||||
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]:
|
||||
"""Create an optional GLB preview for a completed checkpoint."""
|
||||
root = Path(output_dir)
|
||||
step_path = root / "model.step"
|
||||
glb_path = root / "model.glb"
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
report_path = root / "rebuild-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
|
||||
report["preview"] = preview
|
||||
self._write_json(report_path, report)
|
||||
return {"preview": preview, "path": "model.glb"}
|
||||
|
||||
def render_review_bundle(self, output_dir: str) -> dict[str, Any]:
|
||||
root = Path(output_dir)
|
||||
manifest = render_checkpoint(self.settings, step_path=root / "model.step", output_dir=root / "renders")
|
||||
report_path = root / "rebuild-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
|
||||
report["render_manifest"] = manifest
|
||||
self._write_json(report_path, report)
|
||||
return manifest
|
||||
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
# Legacy v3.1 compatibility path. New DAG nodes use build_checkpoint.
|
||||
built = self.build_checkpoint(cdsl, output_dir, task_id, revision_id)
|
||||
root = Path(output_dir)
|
||||
try:
|
||||
preview = self.create_preview(output_dir)
|
||||
manifest = self.render_review_bundle(output_dir)
|
||||
report = json.loads((root / "rebuild-report.json").read_text(encoding="utf-8"))
|
||||
return {**built, "report": report, "preview": preview["preview"], "render_manifest": manifest, "paths": {**built["paths"], "glb": "model.glb", "render_manifest": "renders/render-manifest.json"}}
|
||||
except OSError:
|
||||
# Artifact writes are a recoverable infrastructure outage. Let the
|
||||
# application handler park the same candidate stage for replay.
|
||||
@@ -330,7 +389,7 @@ class ProfileCadRuntime:
|
||||
}
|
||||
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:
|
||||
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) -> list[float] | None:
|
||||
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
|
||||
policy = contract["selector_policy"]
|
||||
@@ -340,6 +399,83 @@ class ProfileCadRuntime:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selector token is absent, stale, or has the wrong kind")
|
||||
for name in contract["semantic_preflight"]:
|
||||
self._semantic_preflight_handlers[name](fragment, selector_tokens, base_cdsl, require_through)
|
||||
if contract.get("atomic_id") == "extrude_cut_blind":
|
||||
return self._preflight_extrude_cut_contacts_material(fragment, selector_tokens)
|
||||
return None
|
||||
|
||||
def _normalize_extrude_cut_direction(
|
||||
self,
|
||||
fragment: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
support_normal: list[float] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Aim a surface-attached cut into the measured material half-space.
|
||||
|
||||
A sketch extrusion has no host selector. Once preflight proves that its
|
||||
profile lies on an oriented boundary face, the only executable blind
|
||||
cut direction is the material side of that face. This is a coordinate
|
||||
normalization, not a planning decision, and is recorded in the audit.
|
||||
"""
|
||||
if contract.get("atomic_id") != "extrude_cut_blind" or not self._valid_vector3(support_normal, require_nonzero=True):
|
||||
return []
|
||||
sketch = fragment.get("sketch") if isinstance(fragment, dict) else None
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
params = fragment.get("feature", {}).get("params") if isinstance(fragment.get("feature"), dict) else None
|
||||
if not self._valid_vector3(normal, require_nonzero=True) or not isinstance(params, dict):
|
||||
return []
|
||||
unit_normal = [float(component) / self._norm(normal) for component in normal]
|
||||
unit_support = [float(component) / self._norm(support_normal) for component in support_normal]
|
||||
alignment = self._dot(unit_normal, unit_support)
|
||||
if abs(abs(alignment) - 1.0) > 1e-6:
|
||||
return []
|
||||
materialized_reverse = alignment > 0
|
||||
submitted_reverse = bool(params.get("reverse", False))
|
||||
params["reverse"] = materialized_reverse
|
||||
return [{
|
||||
"path": "feature.params.reverse",
|
||||
"submitted": submitted_reverse,
|
||||
"materialized": materialized_reverse,
|
||||
"reason": "surface-attached cut must travel into the measured material half-space",
|
||||
"support_normal": unit_support,
|
||||
}]
|
||||
|
||||
def _normalize_required_through_cut_depth(
|
||||
self,
|
||||
fragment: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
selector_tokens: dict[str, dict[str, Any]],
|
||||
*,
|
||||
require_through: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add the minimum deterministic exit allowance for a through cut.
|
||||
|
||||
The author owns nominal feature geometry. For a through requirement,
|
||||
however, the runtime owns the executable end condition: this engine
|
||||
needs a strictly greater cut distance than the measured host span.
|
||||
Recording the adjustment makes the operational allowance visible
|
||||
without treating it as a user-specified blind-cut depth.
|
||||
"""
|
||||
if not require_through or contract.get("atomic_id") != "extrude_cut_blind":
|
||||
return []
|
||||
params = fragment.get("feature", {}).get("params", {})
|
||||
sketch = fragment.get("sketch") if isinstance(fragment.get("sketch"), dict) else {}
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else {}
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
thickness = self._span_from_bbox(self._active_body_bbox(selector_tokens), normal)
|
||||
distance = params.get("distance_mm") if isinstance(params, dict) else None
|
||||
if not isinstance(distance, (int, float)) or thickness is None:
|
||||
return []
|
||||
required_distance = float(thickness) + 0.01
|
||||
if float(distance) > float(thickness) + 1e-6:
|
||||
return []
|
||||
params["distance_mm"] = required_distance
|
||||
return [{
|
||||
"path": "/feature/params/distance_mm",
|
||||
"submitted_mm": float(distance),
|
||||
"materialized_mm": required_distance,
|
||||
"reason": "required through-cut exit allowance",
|
||||
}]
|
||||
|
||||
def _preflight_sketch_workplane(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
sketch = fragment.get("sketch")
|
||||
@@ -362,6 +498,123 @@ class ProfileCadRuntime:
|
||||
elif profile.get("type") == "analytic_contours":
|
||||
self._preflight_analytic_contours(profile)
|
||||
|
||||
def _preflight_extrude_cut_contacts_material(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]]) -> list[float] | None:
|
||||
"""Reject an extrude cut whose start profile floats above the solid.
|
||||
|
||||
Sketch cuts do not carry a host-face selector, so a model can place a
|
||||
correct 2-D profile on the top of an unrelated boss. The engine then
|
||||
rebuilds successfully but leaves the body unchanged. Detect the common
|
||||
no-contact form using the current planar topology and return a useful
|
||||
coordinate diagnosis before allocating a stage or running the kernel.
|
||||
"""
|
||||
sketch = fragment.get("sketch") if isinstance(fragment, dict) else None
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||
profile = sketch.get("profile") if isinstance(sketch, dict) else None
|
||||
origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None
|
||||
normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
x_dir = workplane.get("x_dir") if isinstance(workplane, dict) else None
|
||||
if not self._valid_vector3(origin) or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(x_dir, require_nonzero=True) or not isinstance(profile, dict):
|
||||
return None
|
||||
unit_normal = [float(component) / self._norm(normal) for component in normal]
|
||||
x_projection = self._dot(x_dir, unit_normal)
|
||||
raw_x = [float(x_dir[index]) - x_projection * unit_normal[index] for index in range(3)]
|
||||
if self._norm(raw_x) <= 1e-9:
|
||||
return None
|
||||
unit_x = [value / self._norm(raw_x) for value in raw_x]
|
||||
unit_y = self._cross(unit_normal, unit_x)
|
||||
local_points = self._profile_probe_points(profile)
|
||||
if not local_points:
|
||||
return None
|
||||
world_points = [
|
||||
[
|
||||
float(origin[index]) + local[0] * unit_x[index] + local[1] * unit_y[index]
|
||||
for index in range(3)
|
||||
]
|
||||
for local in local_points
|
||||
]
|
||||
tolerance = 1e-5
|
||||
matching_faces: list[dict[str, Any]] = []
|
||||
available_heights: list[float] = []
|
||||
for value in selectors.values():
|
||||
geometry = value.get("geometry") if isinstance(value, dict) else None
|
||||
face_normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||
loops = geometry.get("boundary_loops_mm") if isinstance(geometry, dict) else None
|
||||
if (
|
||||
not isinstance(geometry, dict)
|
||||
or geometry.get("surface_type") != "plane"
|
||||
or not self._valid_vector3(face_normal, require_nonzero=True)
|
||||
or not self._valid_vector3(center)
|
||||
or not isinstance(loops, list)
|
||||
or not loops
|
||||
):
|
||||
continue
|
||||
unit_face_normal = [float(component) / self._norm(face_normal) for component in face_normal]
|
||||
if abs(abs(self._dot(unit_normal, unit_face_normal)) - 1.0) > 1e-6:
|
||||
continue
|
||||
available_heights.append(self._dot([float(center[index]) for index in range(3)], unit_normal))
|
||||
if abs(self._dot([float(origin[index]) - float(center[index]) for index in range(3)], unit_normal)) <= tolerance:
|
||||
matching_faces.append(geometry)
|
||||
if not matching_faces:
|
||||
return None
|
||||
for face in matching_faces:
|
||||
if any(
|
||||
self._point_in_planar_face(point, face["boundary_loops_mm"], unit_normal, tolerance)
|
||||
for point in world_points
|
||||
):
|
||||
face_normal = face.get("normal")
|
||||
return [float(component) for component in face_normal] if self._valid_vector3(face_normal, require_nonzero=True) else None
|
||||
plane_coordinate = self._dot([float(value) for value in origin], unit_normal)
|
||||
heights = ", ".join(f"{value:g}" for value in sorted(set(round(value, 6) for value in available_heights))[:8])
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: extrude-cut profile does not contact material on its start plane "
|
||||
f"(plane coordinate {plane_coordinate:g}; available parallel planar faces: [{heights}]); "
|
||||
"place the sketch on the material face containing the intended cut profile"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _profile_probe_points(profile: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
"""Return inexpensive local points sufficient for contact preflight."""
|
||||
points: list[tuple[float, float]] = []
|
||||
|
||||
def append(value: Any) -> None:
|
||||
point = ProfileCadRuntime._point2(value)
|
||||
if point is not None:
|
||||
points.append(point)
|
||||
|
||||
profile_type = profile.get("type")
|
||||
if profile_type == "circle":
|
||||
append(profile.get("center"))
|
||||
elif profile_type == "rectangle":
|
||||
append(profile.get("center"))
|
||||
elif profile_type == "polygon":
|
||||
vertices = profile.get("vertices")
|
||||
if isinstance(vertices, list):
|
||||
for vertex in vertices:
|
||||
append(vertex)
|
||||
elif profile_type == "analytic_contours":
|
||||
contours = profile.get("contours")
|
||||
if isinstance(contours, list):
|
||||
for contour in contours:
|
||||
segments = contour.get("segments") if isinstance(contour, dict) else None
|
||||
if not isinstance(segments, list):
|
||||
continue
|
||||
contour_points: list[tuple[float, float]] = []
|
||||
for segment in segments:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
for key in ("start", "end", "center"):
|
||||
point = ProfileCadRuntime._point2(segment.get(key))
|
||||
if point is not None:
|
||||
points.append(point)
|
||||
contour_points.append(point)
|
||||
if contour_points:
|
||||
points.append((
|
||||
sum(point[0] for point in contour_points) / len(contour_points),
|
||||
sum(point[1] for point in contour_points) / len(contour_points),
|
||||
))
|
||||
return points
|
||||
|
||||
def _preflight_host_face_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "face":
|
||||
@@ -439,7 +692,79 @@ class ProfileCadRuntime:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host-face bounds")
|
||||
boundary_loops = geometry.get("boundary_loops_mm")
|
||||
if isinstance(boundary_loops, list) and boundary_loops and not self._point_in_planar_face(point, boundary_loops, unit_normal, tolerance):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host face's effective boundary")
|
||||
if not self._counterbore_reuses_existing_pilot(fragment, selectors, point, unit_normal, tolerance):
|
||||
host_z = float(center[2]) if isinstance(center, list) and len(center) == 3 else float("nan")
|
||||
coordinate = ", ".join(f"{float(value):g}" for value in point)
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: hole position "
|
||||
f"[{coordinate}] lies outside the selected host face material boundary (host center z={host_z:g}); "
|
||||
"select a planar host face that contains every requested hole center"
|
||||
)
|
||||
|
||||
def _counterbore_reuses_existing_pilot(
|
||||
self,
|
||||
fragment: dict[str, Any],
|
||||
selectors: dict[str, dict[str, Any]],
|
||||
point: list[float],
|
||||
host_normal: list[float],
|
||||
tolerance: float,
|
||||
) -> bool:
|
||||
"""Allow a counterbore to start from an existing coaxial pilot bore.
|
||||
|
||||
A top face becomes annular after a through bore, so the pilot centre
|
||||
is deliberately outside its material boundary. Counterboring that
|
||||
pilot is nevertheless a standard valid operation. The exception is
|
||||
deliberately narrow: it only applies to a matching inner cylindrical
|
||||
bore whose axis is normal to the selected host plane and whose open
|
||||
end contains the requested start point.
|
||||
"""
|
||||
feature = fragment.get("feature") if isinstance(fragment, dict) else None
|
||||
params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {}
|
||||
pilot_diameter = params.get("diameter_mm")
|
||||
counterbore_diameter = params.get("counterbore_diameter_mm")
|
||||
if (
|
||||
not isinstance(feature, dict)
|
||||
or feature.get("atomic_id") != "hole_counterbore"
|
||||
or not isinstance(pilot_diameter, (int, float))
|
||||
or not isinstance(counterbore_diameter, (int, float))
|
||||
or float(pilot_diameter) <= 0
|
||||
or float(counterbore_diameter) <= float(pilot_diameter)
|
||||
):
|
||||
return False
|
||||
diameter_tolerance = max(tolerance, abs(float(pilot_diameter)) * 1e-6)
|
||||
for value in selectors.values():
|
||||
geometry = value.get("geometry") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(geometry, dict)
|
||||
or geometry.get("surface_type") != "cylinder"
|
||||
or geometry.get("cylinder_role") != "inner"
|
||||
or not bool(geometry.get("through"))
|
||||
):
|
||||
continue
|
||||
radius = geometry.get("radius_mm")
|
||||
axis_origin = geometry.get("axis_origin_mm")
|
||||
axis_direction = geometry.get("axis_direction")
|
||||
bbox = geometry.get("bbox_mm")
|
||||
if (
|
||||
not isinstance(radius, (int, float))
|
||||
or abs(2 * float(radius) - float(pilot_diameter)) > diameter_tolerance
|
||||
or not self._valid_vector3(axis_origin)
|
||||
or not self._valid_vector3(axis_direction, require_nonzero=True)
|
||||
or not self._valid_bbox(bbox)
|
||||
):
|
||||
continue
|
||||
unit_axis = [float(component) / self._norm(axis_direction) for component in axis_direction]
|
||||
if abs(abs(self._dot(unit_axis, host_normal)) - 1.0) > 1e-6:
|
||||
continue
|
||||
offset = [float(point[index]) - float(axis_origin[index]) for index in range(3)]
|
||||
axial = self._dot(offset, unit_axis)
|
||||
radial = [offset[index] - axial * unit_axis[index] for index in range(3)]
|
||||
if self._norm(radial) > tolerance:
|
||||
continue
|
||||
if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
def _preflight_analytic_contours(self, profile: dict[str, Any]) -> None:
|
||||
contours = profile.get("contours")
|
||||
@@ -750,9 +1075,9 @@ class ProfileCadRuntime:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path) -> dict[str, Any]:
|
||||
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path | None = None) -> dict[str, Any]:
|
||||
bbox = engine_result.get("bbox_mm") if isinstance(engine_result.get("bbox_mm"), dict) else {}
|
||||
minimum, maximum = bbox.get("min"), bbox.get("max")
|
||||
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or not glb_path.is_file():
|
||||
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or (glb_path is not None and not glb_path.is_file()):
|
||||
raise RuntimeAdapterError("rebuild did not produce complete deterministic artifacts")
|
||||
return {"bbox_mm": {"min": [float(item) for item in minimum], "max": [float(item) for item in maximum], "dimensions": [float(maximum[index]) - float(minimum[index]) for index in range(3)]}, "volume_mm3": float(engine_result["volume_mm3"]), "solid_count": int(engine_result["solid_count"]), "feature_count": len(engine_result.get("feature_results") or [])}
|
||||
|
||||
@@ -35,12 +35,12 @@ class SqliteTaskRepository:
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
# Protocol 3.1 intentionally has no migration path from the
|
||||
# Protocol 3.2 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 ""):
|
||||
if existing is not None and "'3.2'" not in str(existing[0] or ""):
|
||||
self.protocol_reset = True
|
||||
connection.executescript("""
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
@@ -55,7 +55,7 @@ class SqliteTaskRepository:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.1'),
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
|
||||
request TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
@@ -70,6 +70,9 @@ class SqliteTaskRepository:
|
||||
requirements_document_path TEXT NOT NULL DEFAULT '',
|
||||
completion_target_path TEXT NOT NULL DEFAULT '',
|
||||
modeling_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_hash TEXT NOT NULL DEFAULT '',
|
||||
feature_stage_id 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,
|
||||
@@ -124,7 +127,7 @@ 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.1', ?, ?, 0)",
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
|
||||
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
|
||||
)
|
||||
state = self.get_state(task_id)
|
||||
@@ -145,10 +148,10 @@ class SqliteTaskRepository:
|
||||
revisions = [
|
||||
{
|
||||
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
}
|
||||
for item in events
|
||||
if item.get("event") == "accepted" and isinstance(item.get("revision_id"), str)
|
||||
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
|
||||
]
|
||||
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
|
||||
verification_warnings = [
|
||||
@@ -167,7 +170,7 @@ class SqliteTaskRepository:
|
||||
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.1",
|
||||
"schema_version": "3.2",
|
||||
"task_id": state.task_id,
|
||||
"phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase),
|
||||
@@ -184,6 +187,11 @@ class SqliteTaskRepository:
|
||||
"requirements_document_path": state.requirements_document_path,
|
||||
"completion_target_path": state.completion_target_path,
|
||||
"modeling_plan_path": state.modeling_plan_path,
|
||||
"feature_plan_path": state.feature_plan_path,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
|
||||
"pending_feature": self._pending_payload(state.pending_feature),
|
||||
"feature_nodes": self._feature_node_projection(events),
|
||||
"clarification_path": state.clarification_path,
|
||||
"requirements_contract_path": state.requirements_contract_path,
|
||||
"verification_status": (
|
||||
@@ -204,6 +212,25 @@ class SqliteTaskRepository:
|
||||
"revisions": revisions,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if not node_id:
|
||||
continue
|
||||
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
|
||||
if event.get("event") == "feature_node_scheduled":
|
||||
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
|
||||
elif event.get("event") == "feature_node_failed":
|
||||
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
|
||||
elif event.get("event") == "feature_node_verified":
|
||||
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
|
||||
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
|
||||
item["status"] = "invalidated"
|
||||
return list(nodes.values())
|
||||
|
||||
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
|
||||
@@ -243,7 +270,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 = ?,
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND state_version = ?""",
|
||||
(
|
||||
state.phase.value, state.version, state.active_revision,
|
||||
@@ -252,6 +279,7 @@ class SqliteTaskRepository:
|
||||
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.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
|
||||
state.clarification_path, state.requirements_contract_path,
|
||||
state.task_id, previous_version,
|
||||
),
|
||||
@@ -393,6 +421,8 @@ class SqliteTaskRepository:
|
||||
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
|
||||
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
|
||||
) if isinstance(raw_pending, dict) else None
|
||||
return TaskState(
|
||||
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||
@@ -405,6 +435,9 @@ class SqliteTaskRepository:
|
||||
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 ""),
|
||||
feature_plan_path=str(row["feature_plan_path"] or ""),
|
||||
feature_plan_hash=str(row["feature_plan_hash"] or ""),
|
||||
feature_stage_id=str(row["feature_stage_id"] or ""),
|
||||
clarification_path=str(row["clarification_path"] or ""),
|
||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||
)
|
||||
@@ -413,7 +446,7 @@ class SqliteTaskRepository:
|
||||
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||
if pending is None:
|
||||
return None
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key}
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
|
||||
|
||||
@staticmethod
|
||||
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
CandidateReview,
|
||||
FinalReview,
|
||||
@@ -55,6 +56,236 @@ class ActionCommandHandler:
|
||||
available.append(atomic_id)
|
||||
return tuple(available)
|
||||
|
||||
def schedule_next_feature(self, task_id: str) -> Accepted | Rejected:
|
||||
"""Select the server-owned next ready node by fixed plan priority."""
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.SCHEDULING_FEATURE:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Feature scheduling is not expected in the current workflow state."))
|
||||
plan = self._feature_plan(task_id, state)
|
||||
if plan is None:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan is unavailable.", retryable=True))
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
if scheduler.all_done():
|
||||
claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision))
|
||||
deterministic_failures = [
|
||||
item for item in claim_results
|
||||
if item.get("deterministic") and item.get("status") != "pass"
|
||||
]
|
||||
if deterministic_failures:
|
||||
next_state = transition(
|
||||
state,
|
||||
"feature_replan",
|
||||
error=ErrorCode.CLAIM_VERIFICATION_FAILED,
|
||||
)
|
||||
result = {
|
||||
"status": "replan_required",
|
||||
"phase": next_state.phase.value,
|
||||
"claim_results": claim_results,
|
||||
}
|
||||
if not self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "feature_plan_completion_failed",
|
||||
"plan_hash": state.feature_plan_hash,
|
||||
"revision_id": state.active_revision,
|
||||
"claim_results": claim_results,
|
||||
"failed_claim_ids": [str(item.get("claim_id") or "") for item in deterministic_failures],
|
||||
"message": "All feature nodes completed, but final deterministic validation failed.",
|
||||
}]):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
next_state = transition(state, "final_requested")
|
||||
result = {"status": "final_validation", "phase": next_state.phase.value, "claim_results": claim_results}
|
||||
if not self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "feature_plan_complete",
|
||||
"plan_hash": state.feature_plan_hash,
|
||||
"revision_id": state.active_revision,
|
||||
"claim_results": claim_results,
|
||||
}], invocation_id=None, invocation_result=None):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
node = scheduler.next_ready()
|
||||
if node is None:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan has no runnable node; revise its unresolved subgraph."))
|
||||
contract = self.runtime.operation_contract(node.atomic_id)
|
||||
requirement_ids = tuple(sorted(self._requirement_ids_for_claims(task_id, tuple(node.claim_ids))))
|
||||
action_id = "feature_" + sha256(f"{state.feature_plan_hash}|{node.node_id}|{state.active_revision}".encode("utf-8")).hexdigest()[:16]
|
||||
pending = PendingAction(
|
||||
action_id=action_id,
|
||||
working_head=state.working_head,
|
||||
intent=node.intent,
|
||||
requirement_ids=requirement_ids,
|
||||
atomic_id=node.atomic_id,
|
||||
expected_change=node.expected_change,
|
||||
contract_hash=contract["contract_hash"],
|
||||
idempotency_key=sha256(f"{task_id}|{action_id}|{state.working_head}".encode("utf-8")).hexdigest(),
|
||||
node_id=node.node_id,
|
||||
plan_hash=state.feature_plan_hash,
|
||||
claim_ids=tuple(node.claim_ids),
|
||||
depends_on_node_ids=tuple(node.depends_on),
|
||||
)
|
||||
next_state = transition(state, "feature_scheduled", pending_action=pending)
|
||||
event = {
|
||||
"event": "feature_node_scheduled", "node_id": node.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "action_id": action_id, "atomic_id": node.atomic_id,
|
||||
"depends_on": node.depends_on, "claim_ids": node.claim_ids, "priority": node.priority,
|
||||
}
|
||||
if not self.repository.compare_and_swap(next_state, events=[event]):
|
||||
return Rejected(self._stale())
|
||||
return Accepted({"status": "scheduled", "node_id": node.node_id, "atomic_id": node.atomic_id, "phase": next_state.phase.value})
|
||||
|
||||
def submit_feature_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
action = state.pending_feature if state else None
|
||||
if state is None or state.phase != TaskPhase.FEATURE_PENDING or action is None:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature fragment requires one scheduled feature node."))
|
||||
plan = self._feature_plan(task_id, state)
|
||||
if plan is None or action.plan_hash != state.feature_plan_hash:
|
||||
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature belongs to an obsolete plan."))
|
||||
node = next((item for item in plan.nodes if item.node_id == action.node_id), None)
|
||||
if node is None or node.atomic_id != action.atomic_id or tuple(node.claim_ids) != action.claim_ids:
|
||||
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The scheduled feature no longer matches the active plan."))
|
||||
contract = self.runtime.operation_contract(action.atomic_id)
|
||||
if contract["contract_hash"] != action.contract_hash:
|
||||
return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The operation contract changed while the feature was scheduled."))
|
||||
base = self.artifacts.read_active_cdsl(task_id, state.active_revision)
|
||||
selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
|
||||
references = self.runtime.reference_tokens(base)
|
||||
selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {}
|
||||
selector_kind = str(selector_policy.get("token_kind") or "")
|
||||
allowed_selectors = [
|
||||
token for token, value in selectors.items()
|
||||
if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required"
|
||||
and isinstance(value, dict) and value.get("kind") == selector_kind
|
||||
]
|
||||
errors = validate_fragment(contract, fragment, selector_tokens=allowed_selectors, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))
|
||||
if errors:
|
||||
return self._feature_failure(task_id, state, action, ErrorCode.AUTHOR_FORMAT_INVALID, "fragment_preflight", "CDSL fragment violates the scheduled operation schema.", invocation_id=invocation_id, field_errors=tuple(errors))
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
feature_ids = scheduler.feature_ids()
|
||||
direct_feature_ids = tuple(feature_ids.get(dependency, "") for dependency in action.depends_on_node_ids)
|
||||
if any(not value for value in direct_feature_ids):
|
||||
return self._feature_failure(task_id, state, action, ErrorCode.RUNTIME_PRECONDITION_FAILED, "dependency", "A direct dependency has no verified runtime feature.", invocation_id=invocation_id)
|
||||
fragment_hash = canonical_hash(fragment)
|
||||
key = self._key(task_id, "feature_fragment", state.working_head, {"node_id": action.node_id, "fragment": fragment})
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, key)
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return Accepted(invocation.result)
|
||||
try:
|
||||
cdsl, audit = self.runtime.materialize_fragment(
|
||||
base, fragment, contract, selectors, references,
|
||||
require_through=self._claims_require_through(task_id, action.claim_ids),
|
||||
depends_on_feature_ids=direct_feature_ids,
|
||||
)
|
||||
except Exception as error:
|
||||
return self._feature_failure(task_id, state, action, self._runtime_error(error).code, "fragment_preflight", str(error), invocation_id=invocation_id, invocation=invocation)
|
||||
stage_id = "feature_stage_" + sha256(key.encode("utf-8")).hexdigest()[:16]
|
||||
try:
|
||||
stage = self.artifacts.start_candidate_stage(task_id, key, {
|
||||
"schema_version": "cad.v3.2.feature-input.v1", "stage_id": stage_id,
|
||||
"node_id": action.node_id, "node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
|
||||
"action_id": action.action_id, "fragment": fragment, "fragment_audit": audit,
|
||||
})
|
||||
except OSError as error:
|
||||
return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event="feature_stage_storage_failure", message=str(error), details={"node_id": action.node_id})
|
||||
building = transition(state, "feature_started", feature_stage_id=stage.stage_id)
|
||||
if not self.repository.compare_and_swap(building, events=[{
|
||||
"event": "feature_node_building", "node_id": action.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "stage_id": stage.stage_id, "fragment_hash": fragment_hash,
|
||||
}]):
|
||||
return Rejected(self._stale())
|
||||
try:
|
||||
rebuilt = self.runtime.build_checkpoint(cdsl, stage.output_dir, task_id, stage_id)
|
||||
try:
|
||||
preview = self.runtime.create_preview(stage.output_dir)
|
||||
rebuilt["paths"]["glb"] = preview["path"]
|
||||
except Exception as preview_error:
|
||||
preview = {"preview_unavailable": str(preview_error)[:500]}
|
||||
claim_results = self._evaluate_claims(task_id, rebuilt, claim_ids=set(action.claim_ids))
|
||||
operation_results = self._operation_candidate_results(
|
||||
action, contract, cdsl, rebuilt, parent_facts=self._facts(task_id, state.active_revision),
|
||||
require_through=self._claims_require_through(task_id, action.claim_ids),
|
||||
)
|
||||
global_failures = [item for item in self._evaluate_claims(task_id, rebuilt) if item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} and item.get("status") != "pass"]
|
||||
blockers = [
|
||||
# A claim is assigned to exactly one runtime-atomic node in
|
||||
# the Feature Plan. Unlike unassigned future-work claims,
|
||||
# an assigned claim may not remain pending when that node is
|
||||
# published: doing so would turn a missing feature into a
|
||||
# permanent, apparently successful checkpoint.
|
||||
*[item for item in claim_results if item.get("status") != "pass"],
|
||||
*[item for item in operation_results if item.get("status") != "pass"],
|
||||
*global_failures,
|
||||
]
|
||||
if blockers:
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", {
|
||||
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
|
||||
"claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers,
|
||||
})
|
||||
return self._feature_failure(task_id, building, action, ErrorCode.CLAIM_VERIFICATION_FAILED, "node_validation", "The feature did not satisfy its local deterministic acceptance.", invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id, "blockers": blockers})
|
||||
verification = {
|
||||
"schema_version": "cad.v3.2.node-verification.v1", "node_id": action.node_id,
|
||||
"node_hash": node_hash(node), "plan_hash": state.feature_plan_hash,
|
||||
"feature_id": audit["assigned_feature_ids"][0], "claim_results": claim_results,
|
||||
"operation_verifier_results": operation_results, "health": rebuilt["health"], "preview": preview,
|
||||
}
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "node-verification.json", verification)
|
||||
revision_id = self._next_revision(task_id)
|
||||
paths = self.artifacts.publish_candidate(task_id, stage.stage_id, revision_id)
|
||||
next_state = transition(building, "feature_verified", active_revision=revision_id)
|
||||
result = {"status": "verified", "node_id": action.node_id, "revision_id": revision_id, "paths": paths, "claim_results": claim_results}
|
||||
event = {
|
||||
"event": "feature_node_verified", "node_id": action.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "feature_id": audit["assigned_feature_ids"][0],
|
||||
"atomic_id": action.atomic_id,
|
||||
"revision_id": revision_id, "parent_revision": state.active_revision, "stage_id": stage.stage_id,
|
||||
"claim_results": claim_results, "preview_unavailable": preview.get("preview_unavailable", ""),
|
||||
}
|
||||
if not self._commit_invocation(next_state, [event], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
except OSError as error:
|
||||
return self._park_retry(building, ErrorCode.STORAGE_FAILURE, event="feature_build_storage_failure", message=str(error), details={"node_id": action.node_id, "stage_id": stage.stage_id})
|
||||
except Exception as error:
|
||||
return self._feature_failure(task_id, building, action, self._runtime_error(error).code, "engine_build", str(error), invocation_id=invocation_id, invocation=invocation, details={"stage_id": stage.stage_id})
|
||||
|
||||
def recover_feature_build(self, task_id: str) -> Accepted | Rejected:
|
||||
"""Make a crashed build retryable without losing its scheduled node."""
|
||||
state = self.repository.get_state(task_id)
|
||||
action = state.pending_feature if state else None
|
||||
if state is None or state.phase != TaskPhase.FEATURE_BUILDING or action is None:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable feature build."))
|
||||
next_state = transition(state, "feature_retry")
|
||||
if not self.repository.compare_and_swap(next_state, events=[{
|
||||
"event": "feature_build_recovered", "node_id": action.node_id, "plan_hash": action.plan_hash,
|
||||
"stage_id": state.feature_stage_id, "message": "Interrupted node build returned to the same scheduled feature.",
|
||||
}]):
|
||||
return Rejected(self._stale())
|
||||
return Accepted({"status": "retry", "node_id": action.node_id, "phase": next_state.phase.value})
|
||||
|
||||
def _feature_failure(self, task_id: str, state: TaskState, action: PendingAction, code: ErrorCode, failure_class: str, message: str, *, invocation_id: str, field_errors: tuple[dict[str, Any], ...] = (), invocation: Any = None, details: dict[str, Any] | None = None) -> Rejected:
|
||||
plan = self._feature_plan(task_id, state)
|
||||
node = next((item for item in (plan.nodes if plan else []) if item.node_id == action.node_id), None)
|
||||
if node is None:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature node is unavailable.", retryable=True))
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
attempt = scheduler.failure_count(action.node_id, failure_class) + 1
|
||||
terminal = attempt >= 2
|
||||
event = {
|
||||
"event": "feature_node_failed", "node_id": action.node_id, "node_hash": node_hash(node),
|
||||
"plan_hash": state.feature_plan_hash, "failure_class": failure_class, "attempt": attempt,
|
||||
"terminal": terminal, "code": code.value, "normalized_error_code": code.value,
|
||||
"atomic_id": action.atomic_id, "checkpoint_revision": state.active_revision,
|
||||
"message": message[:1000], **(details or {}),
|
||||
}
|
||||
next_state = transition(state, "feature_replan" if terminal else "feature_retry", error=code)
|
||||
result = {"status": "replan_required" if terminal else "retry", "node_id": action.node_id, "attempt": attempt, "failure_class": failure_class}
|
||||
if invocation is not None:
|
||||
committed = self._commit_invocation(next_state, [event], invocation, result)
|
||||
else:
|
||||
committed = self.repository.compare_and_swap(next_state, events=[event])
|
||||
if not committed:
|
||||
return Rejected(self._stale())
|
||||
return Rejected(WorkflowError(code, message, field_errors=field_errors, details={**(details or {}), "node_id": action.node_id, "attempt": attempt, "replan_required": terminal}))
|
||||
|
||||
def propose_next_action(self, task_id: str, proposal: NextAction, *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None:
|
||||
@@ -388,6 +619,8 @@ class ActionCommandHandler:
|
||||
|
||||
def submit_cdsl_fragment(self, task_id: str, fragment: dict[str, Any], *, invocation_id: str) -> Accepted | Rejected:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is not None and state.phase == TaskPhase.FEATURE_PENDING:
|
||||
return self.submit_feature_fragment(task_id, fragment, invocation_id=invocation_id)
|
||||
action = state.pending_action if state else None
|
||||
if state is None or state.phase != TaskPhase.ACTION_PENDING or action is None:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A CDSL fragment requires one pending action."))
|
||||
@@ -922,11 +1155,18 @@ class ActionCommandHandler:
|
||||
"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)
|
||||
is_dag = bool(state.feature_plan_hash)
|
||||
next_state = transition(
|
||||
state,
|
||||
"feature_replan" if is_dag else "final_repair",
|
||||
repair_required=not is_dag,
|
||||
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, [{
|
||||
"event": "final_review_repair",
|
||||
"event": "final_visual_reviewed" if is_dag else "final_review_repair",
|
||||
"revision_id": state.active_revision,
|
||||
**({"plan_hash": state.feature_plan_hash} if is_dag else {}),
|
||||
"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],
|
||||
@@ -947,15 +1187,48 @@ class ActionCommandHandler:
|
||||
return Accepted(result)
|
||||
next_state = transition(state, "final_accepted")
|
||||
result = {"status": "completed", "revision_id": state.active_revision}
|
||||
if not self._commit_invocation(next_state, [{"event": "completed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
|
||||
if not self._commit_invocation(next_state, [{"event": "final_visual_reviewed", "revision_id": state.active_revision, "final_review_path": final_review_path, "completion_result_path": "completion-result.md", "claim_results": claim_results}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return Accepted(result)
|
||||
|
||||
def _evaluate_claims(self, task_id: str, facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def _evaluate_claims(self, task_id: str, facts: dict[str, Any], *, claim_ids: set[str] | None = None) -> list[dict[str, Any]]:
|
||||
contract = self._requirements_contract(task_id) or {}
|
||||
claims = [claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict) for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)]
|
||||
claims = [
|
||||
claim for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and (claim_ids is None or str(claim.get("claim_id") or "") in claim_ids)
|
||||
]
|
||||
return self.verifiers.evaluate(claims, facts)
|
||||
|
||||
def _feature_plan(self, task_id: str, state: TaskState | None = None) -> FeaturePlan | None:
|
||||
state = state or self.repository.get_state(task_id)
|
||||
if state is None or not state.feature_plan_path:
|
||||
return None
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
return FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _requirement_ids_for_claims(self, task_id: str, claim_ids: tuple[str, ...]) -> set[str]:
|
||||
wanted = set(claim_ids)
|
||||
contract = self._requirements_contract(task_id) or {}
|
||||
return {
|
||||
str(requirement.get("requirement_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
and any(str(claim.get("claim_id") or "") in wanted for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict))
|
||||
}
|
||||
|
||||
def _claims_require_through(self, task_id: str, claim_ids: tuple[str, ...]) -> bool:
|
||||
wanted = set(claim_ids)
|
||||
contract = self._requirements_contract(task_id) or {}
|
||||
return any(
|
||||
str(claim.get("claim_id") or "") in wanted and claim.get("claim_kind") == "through_cylindrical_bore"
|
||||
for requirement in contract.get("requirements") or () if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)
|
||||
)
|
||||
|
||||
def claim_summary(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
"""Return bounded current claim facts for author context assembly."""
|
||||
return [
|
||||
@@ -1059,7 +1332,16 @@ class ActionCommandHandler:
|
||||
continue
|
||||
expected: dict[str, Any]
|
||||
if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}:
|
||||
diameter = params.get("diameter_mm")
|
||||
# A counterbore can reuse an already-existing pilot bore. In
|
||||
# that case the material change is the larger cylindrical
|
||||
# recess, not an additional instance of the pilot diameter.
|
||||
# Measuring the pilot would count the parent bore as a new
|
||||
# feature and make a valid counterbore checkpoint fail.
|
||||
diameter = (
|
||||
params.get("counterbore_diameter_mm")
|
||||
if action.atomic_id == "hole_counterbore" and claim_kind == "cylindrical_bore"
|
||||
else params.get("diameter_mm")
|
||||
)
|
||||
positions = params.get("positions")
|
||||
if not isinstance(diameter, (int, float)):
|
||||
return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation has no measurable bore diameter"}}]
|
||||
@@ -1152,7 +1434,7 @@ class ActionCommandHandler:
|
||||
values = [
|
||||
int(str(event.get("revision_id") or "").removeprefix("rev_"))
|
||||
for event in self.repository.ledger_events(task_id)
|
||||
if event.get("event") == "accepted"
|
||||
if event.get("event") in {"accepted", "feature_node_verified"}
|
||||
and str(event.get("revision_id") or "").startswith("rev_")
|
||||
and str(event.get("revision_id") or "").removeprefix("rev_").isdigit()
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.cad_agent.application.llm_contracts import (
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||
@@ -30,6 +31,8 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
|
||||
if role == "reviewer":
|
||||
return [
|
||||
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||
# Compatibility conformance probe; regular DAG execution never
|
||||
# calls a per-node reviewer.
|
||||
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||
_tool("review_final", stateless_final_review_schema(1)),
|
||||
]
|
||||
@@ -40,8 +43,9 @@ def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict
|
||||
_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_feature_plan", FeaturePlan.model_json_schema()),
|
||||
# Compatibility probe only; production v3.2 workflow never exposes it.
|
||||
_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()),
|
||||
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||
@@ -57,7 +61,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.1.markdown-first", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
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
|
||||
@@ -18,6 +19,9 @@ from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
||||
_BORE_MARKERS = ("bore", "hole", "孔")
|
||||
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
@@ -27,10 +31,11 @@ class RequirementsCommandHandler:
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry) -> None:
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
self.registry = registry
|
||||
self.atomic_ids = atomic_ids or (lambda: ())
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
@@ -59,6 +64,81 @@ class RequirementsCommandHandler:
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
||||
"""Return the plan tool schema bound to the persisted planning state.
|
||||
|
||||
A model is allowed to choose node content, but it must not guess the
|
||||
immutable lineage identifiers of a plan revision. Binding those values
|
||||
as enums prevents a requirements-contract hash (or a stale plan hash)
|
||||
from being mistaken for ``parent_plan_hash``.
|
||||
"""
|
||||
schema = FeaturePlan.model_json_schema()
|
||||
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
||||
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
||||
node_properties = node.get("properties") if isinstance(node, dict) else None
|
||||
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
||||
if isinstance(atomic, dict):
|
||||
atomic["enum"] = list(self.atomic_ids())
|
||||
state = self.repository.get_state(task_id)
|
||||
previous: FeaturePlan | None = None
|
||||
if state is not None and state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
previous = None
|
||||
parent_hash = plan_hash(previous) if previous is not None else ""
|
||||
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
||||
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
||||
if isinstance(parent, dict):
|
||||
parent["enum"] = [parent_hash]
|
||||
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(replaced, dict):
|
||||
replaced.update({
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"minItems": len(replacements),
|
||||
"maxItems": len(replacements),
|
||||
"items": {"enum": replacements},
|
||||
})
|
||||
contract = self.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
state.requirements_contract_path if state is not None else "",
|
||||
) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
||||
if isinstance(node_claim_ids, dict):
|
||||
# Authors occasionally repeat a visual claim on the node that
|
||||
# creates the feature as well as in final_claim_ids. Accept that
|
||||
# harmless reference at the tool boundary; submit_feature_plan()
|
||||
# removes it before the immutable DAG is validated and written.
|
||||
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
||||
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(final_claim_ids, dict):
|
||||
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
||||
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
||||
return schema
|
||||
|
||||
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)
|
||||
|
||||
@@ -66,7 +146,157 @@ class RequirementsCommandHandler:
|
||||
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)
|
||||
# Compatibility shim for callers compiled against v3.1. The v3.2
|
||||
# coordinator never offers this method to an LLM.
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
|
||||
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Freeze a validated initial plan or full subgraph plan revision."""
|
||||
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 not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
||||
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
||||
plan = self._assign_unowned_global_health_claims(plan, contract)
|
||||
previous: FeaturePlan | None = None
|
||||
completed: dict[str, str] = {}
|
||||
if state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
||||
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
||||
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
||||
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
||||
digest = plan_hash(plan)
|
||||
path = f"plans/feature-plan-{digest}.json"
|
||||
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
||||
events: list[dict[str, Any]] = [{
|
||||
"event": event,
|
||||
"invocation_id": invocation_id,
|
||||
"plan_path": written,
|
||||
"plan_hash": digest,
|
||||
"parent_plan_hash": plan.parent_plan_hash,
|
||||
"replaces_node_ids": plan.replaces_node_ids,
|
||||
}]
|
||||
if previous is not None:
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
old_hash = plan_hash(previous)
|
||||
events.extend({
|
||||
"event": "feature_node_invalidated",
|
||||
"node_id": node_id,
|
||||
"node_hash": node_hash(old_nodes[node_id]),
|
||||
"plan_hash": old_hash,
|
||||
"replacement_plan_hash": digest,
|
||||
} for node_id in plan.replaces_node_ids)
|
||||
if not self._commit(next_state, events, invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Drop non-owning visual references from feature nodes.
|
||||
|
||||
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
||||
Visual claims are owned solely by ``final_claim_ids`` and have no
|
||||
node-local verifier. Retaining a repeated visual ID therefore adds
|
||||
no behavior and turns an otherwise valid plan into a schema retry.
|
||||
The contract validation below still requires every visual claim to be
|
||||
present exactly once in ``final_claim_ids``.
|
||||
"""
|
||||
visual_claim_ids = {
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
if not visual_claim_ids or not any(
|
||||
claim_id in visual_claim_ids
|
||||
for node in plan.nodes
|
||||
for claim_id in node.claim_ids
|
||||
):
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
for node in normalized.nodes:
|
||||
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Bind global solid-health claims to the unique root body feature.
|
||||
|
||||
``single_connected_body`` and ``solid_count_equals`` are checked as
|
||||
global health on every feature checkpoint. When a plan has exactly
|
||||
one root additive feature, their node owner is consequently
|
||||
determined without choosing any geometry strategy. This prevents an
|
||||
otherwise complete plan from failing merely because an author omitted
|
||||
the redundant ownership annotation.
|
||||
"""
|
||||
claims = {
|
||||
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
||||
unowned = [
|
||||
claim_id for claim_id, claim_kind in claims.items()
|
||||
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
||||
]
|
||||
roots = [
|
||||
node for node in plan.nodes
|
||||
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
||||
]
|
||||
if not unowned or len(roots) != 1:
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
root_id = roots[0].node_id
|
||||
for node in normalized.nodes:
|
||||
if node.node_id == root_id:
|
||||
node.claim_ids = [*node.claim_ids, *unowned]
|
||||
break
|
||||
return normalized
|
||||
|
||||
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
statuses = scheduler.statuses()
|
||||
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
||||
if not failed:
|
||||
return set()
|
||||
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
||||
for node in plan.nodes:
|
||||
for dependency in node.depends_on:
|
||||
children.setdefault(dependency, set()).add(node.node_id)
|
||||
result = set(failed)
|
||||
pending = list(failed)
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for child in children.get(current, set()):
|
||||
if statuses.get(child) != "done" and child not in result:
|
||||
result.add(child)
|
||||
pending.append(child)
|
||||
return result
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
@@ -79,7 +309,10 @@ class RequirementsCommandHandler:
|
||||
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)
|
||||
field_errors = [
|
||||
*self._claim_errors(normalized_output),
|
||||
*self._relationship_claim_errors(normalized_output, targets),
|
||||
]
|
||||
if field_errors:
|
||||
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")))
|
||||
@@ -101,7 +334,7 @@ class RequirementsCommandHandler:
|
||||
claim_position += 1
|
||||
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 = {"schema_version": "cad.requirements-contract.v3.2", "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)
|
||||
@@ -176,12 +409,57 @@ class RequirementsCommandHandler:
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
||||
"""Require explicit coverage for an unambiguous centered-bore target.
|
||||
|
||||
The compiler remains free to choose claims for open-ended CAD prose.
|
||||
A checklist item that explicitly says a bore is centered/concentric is
|
||||
different: dropping that relationship leaves a measurable user fact
|
||||
with no acceptance owner. The service only checks its presence here;
|
||||
the registry validates its numeric parameters independently.
|
||||
"""
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(
|
||||
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
||||
for claim in requirement.acceptance_claims
|
||||
):
|
||||
continue
|
||||
errors.append({
|
||||
"path": f"/requirements/{index}/acceptance_claims",
|
||||
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
||||
})
|
||||
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 self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
||||
warnings.append(
|
||||
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
||||
warnings.append(
|
||||
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
||||
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
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."
|
||||
@@ -202,8 +480,104 @@ class RequirementsCommandHandler:
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
self._derive_centered_bore_claims(normalized, targets)
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
||||
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
||||
|
||||
``rectangular_corner_through_bore_pattern`` measures four complete
|
||||
cylindrical bores at equal edge offsets. An obround slot has two arc
|
||||
ends and straight flanks; treating its stated length as an edge offset
|
||||
produces an unsatisfiable contract even when the CAD is correct.
|
||||
"""
|
||||
return (
|
||||
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
||||
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
||||
"""Attach a measurable concentricity claim when its inputs are frozen.
|
||||
|
||||
The requirements compiler receives a Markdown checklist, not runtime
|
||||
geometry IDs. Once it has already compiled an external cylindrical
|
||||
diameter and an explicitly centred bore diameter, their relationship
|
||||
is a service-owned mechanical consequence. Requiring an author to
|
||||
remember the internal verifier name makes a complete user request
|
||||
fail for a bookkeeping omission rather than a CAD decision.
|
||||
"""
|
||||
outer_diameters: list[float] = []
|
||||
for requirement in output.requirements:
|
||||
for claim in requirement.acceptance_claims:
|
||||
expected = claim.expected
|
||||
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
||||
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
||||
outer_diameters.append(float(diameter))
|
||||
if not outer_diameters:
|
||||
return
|
||||
outer_diameter = max(outer_diameters)
|
||||
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
||||
for target, requirement in zip(targets, output.requirements, strict=True):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
||||
continue
|
||||
bore_diameter = next((
|
||||
float(claim.expected["diameter_mm"])
|
||||
for claim in requirement.acceptance_claims
|
||||
if claim.claim_kind in bore_claim_kinds
|
||||
and isinstance(claim.expected, dict)
|
||||
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
||||
and float(claim.expected["diameter_mm"]) > 0
|
||||
), None)
|
||||
if bore_diameter is None:
|
||||
continue
|
||||
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {
|
||||
"bore_diameter_mm": bore_diameter,
|
||||
"outer_diameter_mm": outer_diameter,
|
||||
"tolerance_mm": 0.01,
|
||||
},
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
||||
"""Reject a bore-group verifier when the contract has no such group.
|
||||
|
||||
``coaxial_through_bore_group`` measures multiple inner bores of one
|
||||
diameter. It cannot prove a lone central bore is concentric with an
|
||||
external cylindrical wall. This is a mechanical consistency check:
|
||||
some through-bore claim must request at least the group count.
|
||||
"""
|
||||
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
||||
return False
|
||||
expected = getattr(claim, "expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return True
|
||||
diameter = expected.get("diameter_mm")
|
||||
count = expected.get("count")
|
||||
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
||||
return True
|
||||
for requirement in output.requirements:
|
||||
for candidate in requirement.acceptance_claims:
|
||||
candidate_expected = getattr(candidate, "expected", {})
|
||||
if (
|
||||
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
||||
and isinstance(candidate_expected, dict)
|
||||
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
||||
and isinstance(candidate_expected.get("count"), int)
|
||||
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
||||
and int(candidate_expected["count"]) >= count
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
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":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""LLM turn coordinator for protocol v3.
|
||||
|
||||
It selects only which schema is visible from the persisted state. It never
|
||||
derives a CAD operation from requirements; that choice is always an author
|
||||
tool call handled by ``ActionCommandHandler``.
|
||||
It selects only the structured schema visible from persisted state. Feature
|
||||
selection is server-owned: the scheduler chooses one atomic DAG node and the
|
||||
author can submit only that node's fragment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +29,7 @@ from app.cad_agent.application.llm_contracts import (
|
||||
)
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected, Waiting
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, plan_hash
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, retry_resume_event, transition
|
||||
@@ -36,6 +37,7 @@ from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, M
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
_FEATURE_REPLAN_FAILURE_LIMIT = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -248,6 +250,13 @@ class WorkflowCoordinator:
|
||||
if state.phase in {TaskPhase.FAILED, TaskPhase.WAITING_RETRY}:
|
||||
yield "task_terminal", self._projected_terminal(task_id, state)
|
||||
return
|
||||
if state.phase == TaskPhase.FEATURE_BUILDING:
|
||||
recovered = self.actions.recover_feature_build(task_id)
|
||||
yield "feature_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
||||
if isinstance(recovered, Rejected):
|
||||
yield self._service_failure(task_id, state, recovered.error)
|
||||
return
|
||||
continue
|
||||
if state.phase == TaskPhase.CANDIDATE_BUILDING:
|
||||
recovered = self.actions.recover_candidate_build(task_id)
|
||||
yield "candidate_result", {"taskId": task_id, "status": "success" if isinstance(recovered, Accepted) else "error", "result": self._result_payload(recovered), "recovered": True}
|
||||
@@ -434,6 +443,130 @@ class WorkflowCoordinator:
|
||||
yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
exhausted = self._feature_replan_exhausted(task_id, state)
|
||||
if exhausted is not None:
|
||||
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "feature_plan_no_progress_limit",
|
||||
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The same atomic feature exhausted its cross-plan replan budget.",
|
||||
"checkpoint_preserved": bool(state.active_revision),
|
||||
"revision_id": state.active_revision,
|
||||
**exhausted,
|
||||
}])
|
||||
yield "task_terminal", {
|
||||
"taskId": task_id,
|
||||
"lifecycle": "failed",
|
||||
"revisionId": state.active_revision,
|
||||
"code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The same atomic feature repeatedly failed after local replanning; the last executable checkpoint remains available.",
|
||||
}
|
||||
return
|
||||
plan_schema = self.requirements.feature_plan_schema(task_id)
|
||||
tools = [self._tool("write_feature_plan", plan_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, "write_feature_plan", result, format_errors, feedback)
|
||||
yield "feature_plan", {"taskId": task_id, "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, FeaturePlan)
|
||||
dynamic_error = canonical_validate_schema(raw, plan_schema) if not isinstance(validation, WorkflowError) else None
|
||||
if dynamic_error is not None:
|
||||
validation = dynamic_error
|
||||
if isinstance(validation, WorkflowError):
|
||||
terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback)
|
||||
yield "feature_plan", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
command = self.requirements.submit_feature_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="write_feature_plan")
|
||||
yield "feature_plan", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
yield "feature_plan_ready", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase == TaskPhase.SCHEDULING_FEATURE:
|
||||
command = self.actions.schedule_next_feature(task_id)
|
||||
if isinstance(command, Rejected):
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": command.error.code.value, "message": command.error.message}
|
||||
return
|
||||
yield "feature_scheduled", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
if state.phase == TaskPhase.FEATURE_PENDING:
|
||||
observed = action_observations.setdefault(state.working_head, set())
|
||||
tools = self._action_tools(task_id, state, observed)
|
||||
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 "feature_result", {"taskId": task_id, "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
|
||||
if name == "inspect_topology":
|
||||
validation = canonical_validate(raw, StatelessTopologyRequest)
|
||||
if isinstance(validation, WorkflowError):
|
||||
yield "feature_result", self._event(task_id, name, validation.payload(), "error", usage)
|
||||
continue
|
||||
payload = self._topology_payload(task_id, state, validation.kind, validation.limit)
|
||||
observed.add("topology")
|
||||
yield "tool_call", self._event(task_id, name, payload, "success", usage)
|
||||
feedback = [{"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}]
|
||||
continue
|
||||
fragment = canonical_json_object(raw)
|
||||
if isinstance(fragment, WorkflowError):
|
||||
yield "feature_result", self._event(task_id, name, fragment.payload(), "error", usage)
|
||||
feedback = [self._feedback(fragment)]
|
||||
continue
|
||||
command = self.actions.submit_feature_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id))
|
||||
if isinstance(command, Rejected):
|
||||
after = self.repository.get_state(task_id)
|
||||
yield "feature_result", self._event(task_id, name, command.error.payload(), "error", usage)
|
||||
if after is not None and after.version != state.version:
|
||||
feedback = [self._feedback(command.error)]
|
||||
continue
|
||||
terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback)
|
||||
if terminal:
|
||||
yield terminal
|
||||
return
|
||||
continue
|
||||
yield "feature_result", self._event(task_id, name, self._result_payload(command), "success", usage)
|
||||
feedback = []
|
||||
continue
|
||||
if state.phase == TaskPhase.AWAITING_ACTION:
|
||||
contract = self._requirements_contract(task_id, state) or {}
|
||||
requirement_ids = [str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict) and item.get("requirement_id")]
|
||||
@@ -688,6 +821,14 @@ class WorkflowCoordinator:
|
||||
yield "candidate_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
if state.phase == TaskPhase.FINAL_VALIDATION:
|
||||
facts = self.actions._facts(task_id, state.active_revision)
|
||||
if not (facts.get("report") or {}).get("render_manifest"):
|
||||
try:
|
||||
self.runtime.render_review_bundle(str(self.artifacts.artifact_path(task_id, f"revisions/{state.active_revision}")))
|
||||
except Exception as error:
|
||||
yield self._service_failure(task_id, state, WorkflowError(ErrorCode.RENDER_SERVICE_UNAVAILABLE, str(error)[:1000], retryable=True))
|
||||
return
|
||||
yield "final_render_ready", {"taskId": task_id, "revisionId": state.active_revision, "status": "success"}
|
||||
recovered = self.actions.recover_final_review(task_id)
|
||||
if recovered is not None:
|
||||
if isinstance(recovered, Accepted) and recovered.payload.get("status") == "completed":
|
||||
@@ -768,6 +909,15 @@ class WorkflowCoordinator:
|
||||
yield "final_review", {"taskId": task_id, "status": "success", "result": self._result_payload(command)}
|
||||
continue
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is not None and state.feature_plan_hash:
|
||||
failed = transition(state, "failed", error=ErrorCode.NO_PROGRESS_LIMIT)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "feature_plan_no_progress_limit", "code": ErrorCode.NO_PROGRESS_LIMIT.value,
|
||||
"message": "The feature DAG reached its bounded turn limit.",
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
|
||||
}])
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.NO_PROGRESS_LIMIT.value, "message": "The feature DAG reached its bounded turn limit; the last executable checkpoint remains available."}
|
||||
return
|
||||
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"}
|
||||
@@ -781,6 +931,14 @@ class WorkflowCoordinator:
|
||||
yield self._storage_failure(task_id, str(error))
|
||||
except Exception as error:
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is not None and state.feature_plan_hash 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": "feature_plan_internal_failure", "message": str(error)[:1000],
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
|
||||
}])
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision, "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]}
|
||||
return
|
||||
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"}
|
||||
@@ -814,6 +972,19 @@ class WorkflowCoordinator:
|
||||
return None
|
||||
details = budget.payload()
|
||||
details["next_actor"] = actor
|
||||
if state.feature_plan_hash:
|
||||
failed = transition(state, "failed", error=ErrorCode.CALL_BUDGET_EXHAUSTED)
|
||||
self.repository.compare_and_swap(failed, events=[{
|
||||
"event": "call_budget_exhausted", "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
|
||||
"message": "Configured model-call budget is exhausted before the feature DAG converged.",
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision, **details,
|
||||
}])
|
||||
return "task_terminal", {
|
||||
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
|
||||
"code": ErrorCode.CALL_BUDGET_EXHAUSTED.value,
|
||||
"message": "Configured model-call budget is exhausted before the feature DAG converged; the last executable checkpoint remains available.",
|
||||
"budget": details, "blockerType": "call_budget_exhausted", "userActionRequired": False,
|
||||
}
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
state,
|
||||
@@ -1235,7 +1406,7 @@ class WorkflowCoordinator:
|
||||
return []
|
||||
if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"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),
|
||||
@@ -1246,31 +1417,85 @@ class WorkflowCoordinator:
|
||||
}
|
||||
elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET:
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"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",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"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.",
|
||||
"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. An obround, slotted, or long-slot feature is not a rectangular_corner_through_bore_pattern: keep it visual unless a dedicated slot verifier is available. Every explicit centered, concentric, or coaxial bore must have concentric_bore_to_outer_cylinder coverage. coaxial_through_bore_group is only for two or more same-diameter inner bores. For one central bore concentric with an external cylinder, use concentric_bore_to_outer_cylinder with bore_diameter_mm and outer_diameter_mm.",
|
||||
}
|
||||
elif state.phase == TaskPhase.DRAFTING_MODELING_PLAN:
|
||||
elif state.phase in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
active_plan = self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None
|
||||
contract = self._requirements_contract(task_id, state) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
required_replacements: list[str] = []
|
||||
parent_plan_hash = ""
|
||||
if isinstance(active_plan, dict):
|
||||
try:
|
||||
parsed_plan = FeaturePlan.model_validate(active_plan)
|
||||
parent_plan_hash = plan_hash(parsed_plan)
|
||||
required_replacements = sorted(self.requirements._required_replacements(parsed_plan, task_id))
|
||||
except ValueError:
|
||||
pass
|
||||
content = {
|
||||
"protocol": "cad.v3.1.markdown-first",
|
||||
"protocol": "cad.v3.2.feature-dag",
|
||||
"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.",
|
||||
"compiled_contract": contract,
|
||||
"claim_ownership_binding": {
|
||||
"node_claim_ids_must_be_drawn_only_from": deterministic_claim_ids,
|
||||
"final_claim_ids_must_equal": visual_claim_ids,
|
||||
"visual_only_nodes_must_use_empty_claim_ids": True,
|
||||
},
|
||||
"supported_atomic_ids": list(self.runtime.supported_atomic_ids()),
|
||||
"active_feature_plan": active_plan,
|
||||
"plan_lineage_binding": {
|
||||
"parent_plan_hash": parent_plan_hash,
|
||||
"replaces_node_ids": required_replacements,
|
||||
},
|
||||
"feature_node_statuses": self._feature_node_statuses(task_id, active_plan),
|
||||
"replanning_evidence": self._replanning_evidence(task_id, state),
|
||||
"instruction": (
|
||||
"Write the complete feature-plan JSON. Each node is exactly one runtime atomic feature. "
|
||||
"Every deterministic frozen claim must belong to exactly one node; every visual claim must be in final_claim_ids. "
|
||||
"Never place a visual claim in any node claim_ids. Nodes for ribs, chamfers, slots, or other visual-only work are valid with claim_ids: []. "
|
||||
"Use dependency edges only for direct geometric prerequisites and unique fixed priorities. "
|
||||
"The tool schema binds parent_plan_hash and replaces_node_ids to the service values: copy those exact values, never use the requirements-contract hash. "
|
||||
"For a revision, preserve completed nodes byte-for-byte and use new IDs for replacements."
|
||||
),
|
||||
}
|
||||
else:
|
||||
contract = self._requirements_contract(task_id, state) or {}
|
||||
action = state.pending_feature
|
||||
feature_node_turn = state.phase == TaskPhase.FEATURE_PENDING and action is not None
|
||||
compact = [{
|
||||
"requirement_id": item.get("requirement_id"),
|
||||
"statement": item.get("statement"),
|
||||
"acceptance_claims": [
|
||||
{
|
||||
@@ -1281,14 +1506,20 @@ class WorkflowCoordinator:
|
||||
if isinstance(claim, dict)
|
||||
],
|
||||
} for item in contract.get("requirements") or () if isinstance(item, dict)]
|
||||
action = state.pending_action
|
||||
if feature_node_turn:
|
||||
owned_requirements = set(action.requirement_ids)
|
||||
compact = [
|
||||
item for item in compact
|
||||
if str(item.get("requirement_id") or "") in owned_requirements
|
||||
]
|
||||
operation = self.runtime.operation_contract(action.atomic_id) if action is not None else None
|
||||
selector_shape = str((operation or {}).get("fragment_shape", {}).get("selector_tokens") or "forbidden")
|
||||
instruction = "Choose only the next atomically verifiable action or complete when every requirement is proven. The service will not choose CAD operations for you."
|
||||
instruction = "The service has scheduled one atomic feature from the immutable DAG. Submit exactly that feature's CDSL fragment."
|
||||
if state.repair_required:
|
||||
instruction = "A prior candidate or final review requires repair. Use the current server evidence to record a geometry conclusion, then either choose a new action or, after a rollback conclusion, request an earlier checkpoint. The service will not choose CAD operations for you."
|
||||
operation_payload = self._operation_payload(task_id, state) if action is not None else None
|
||||
selector_summary = []
|
||||
sketch_workplane_candidates = []
|
||||
if action is not None and selector_shape == "required":
|
||||
tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision))
|
||||
allowed = set(self._selector_tokens_for_contract(operation or {}, tokens))
|
||||
@@ -1297,7 +1528,35 @@ 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.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}
|
||||
if (
|
||||
feature_node_turn
|
||||
and state.active_revision
|
||||
and str((operation or {}).get("fragment_shape", {}).get("sketch") or "forbidden") == "required"
|
||||
):
|
||||
sketch_workplane_candidates = self._sketch_workplane_candidates(
|
||||
self.artifacts.read_topology(task_id, state.active_revision)
|
||||
)
|
||||
if sketch_workplane_candidates:
|
||||
instruction += (
|
||||
" sketch_workplane_candidates are measured planar material faces. "
|
||||
"For a blind cut, select a plane whose material region covers the intended profile; "
|
||||
"do not choose a newer or higher face merely because it is the last feature."
|
||||
)
|
||||
current_claims = self.actions.claim_summary(task_id, state)
|
||||
if feature_node_turn:
|
||||
current_claims = [
|
||||
item for item in current_claims
|
||||
if str(item.get("claim_id") or "") in set(action.claim_ids)
|
||||
]
|
||||
content = {"protocol": "cad.v3.2.feature-dag", "coordinate_protocol": self._coordinate_protocol(state), "phase": state.phase.value, "requirements": compact, "verification_warnings": contract.get("verification_warnings") or [], "claim_coverage": [self._public_claim_result(item) for item in current_claims], "model_summary": self.actions.model_summary(task_id, state), "pending_feature": self._public_pending_context(state), "direct_upstream_facts": self._direct_upstream_facts(task_id, 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)), "sketch_workplane_candidates": sketch_workplane_candidates, "recent_failures": self._recent_failure_constraints(task_id, state), "instruction": instruction}
|
||||
if not feature_node_turn:
|
||||
content.update({
|
||||
"requirements_markdown": self._read_markdown(task_id, state.requirements_document_path),
|
||||
"completion_target_markdown": self._read_markdown(task_id, state.completion_target_path),
|
||||
"feature_plan": self.artifacts.read_json(task_id, state.feature_plan_path) if state.feature_plan_path else None,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"feature_node_statuses": self._feature_node_statuses(task_id, None),
|
||||
})
|
||||
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:]]
|
||||
|
||||
@@ -1322,6 +1581,160 @@ class WorkflowCoordinator:
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
def _feature_node_statuses(self, task_id: str, plan_payload: dict[str, Any] | None) -> dict[str, str]:
|
||||
try:
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
state = self.repository.get_state(task_id)
|
||||
plan = FeaturePlan.model_validate(plan_payload) if isinstance(plan_payload, dict) else FeaturePlan.model_validate(self.artifacts.read_json(task_id, state.feature_plan_path) if state and state.feature_plan_path else None)
|
||||
return FeatureScheduler(plan, self.repository.ledger_events(task_id)).statuses()
|
||||
except (ValueError, TypeError, OSError):
|
||||
return {}
|
||||
|
||||
def _direct_upstream_facts(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
"""Project only verified direct dependencies for a scheduled node.
|
||||
|
||||
The current model summary describes the working head. This additional
|
||||
projection lets the author distinguish the exact prerequisite nodes
|
||||
without exposing arbitrary historical topology or old failed stages.
|
||||
"""
|
||||
pending = state.pending_feature
|
||||
if pending is None or not pending.depends_on_node_ids:
|
||||
return []
|
||||
verified: dict[str, dict[str, Any]] = {}
|
||||
for event in self.repository.ledger_events(task_id):
|
||||
if event.get("event") == "feature_node_verified":
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id in pending.depends_on_node_ids:
|
||||
verified[node_id] = event
|
||||
result: list[dict[str, Any]] = []
|
||||
for node_id in pending.depends_on_node_ids:
|
||||
event = verified.get(node_id)
|
||||
if event is None:
|
||||
continue
|
||||
revision_id = str(event.get("revision_id") or "")
|
||||
verification = self.artifacts.read_json(task_id, f"revisions/{revision_id}/node-verification.json") if revision_id else None
|
||||
evidence = verification if isinstance(verification, dict) else {}
|
||||
result.append({
|
||||
"node_id": node_id,
|
||||
"revision_id": revision_id,
|
||||
"feature_id": str(event.get("feature_id") or evidence.get("feature_id") or ""),
|
||||
"claim_results": [
|
||||
{
|
||||
"claim_id": str(item.get("claim_id") or ""),
|
||||
"status": str(item.get("status") or ""),
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
|
||||
}
|
||||
for item in evidence.get("claim_results") or ()
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"operation_verifier_results": [
|
||||
{
|
||||
"claim_kind": str(item.get("claim_kind") or ""),
|
||||
"status": str(item.get("status") or ""),
|
||||
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
|
||||
}
|
||||
for item in evidence.get("operation_verifier_results") or ()
|
||||
if isinstance(item, dict)
|
||||
],
|
||||
"health": evidence.get("health") if isinstance(evidence.get("health"), dict) else {},
|
||||
})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _sketch_workplane_candidates(topology: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]:
|
||||
"""Expose compact, measured planes for sketch operations without selectors.
|
||||
|
||||
Sketch extrusions deliberately do not use B-rep selector tokens. The
|
||||
author still needs enough geometry to choose a material face instead of
|
||||
placing a profile on the most recently created boss.
|
||||
"""
|
||||
records = topology.get("records") if isinstance(topology, dict) else None
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
seen: set[tuple[float, ...]] = set()
|
||||
for record in records:
|
||||
geometry = record.get("geometry") if isinstance(record, dict) else None
|
||||
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||
normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
|
||||
if (
|
||||
not isinstance(geometry, dict)
|
||||
or geometry.get("surface_type") != "plane"
|
||||
or not isinstance(center, list)
|
||||
or not isinstance(normal, list)
|
||||
or len(center) != 3
|
||||
or len(normal) != 3
|
||||
or not isinstance(bbox, list)
|
||||
or len(bbox) != 6
|
||||
):
|
||||
continue
|
||||
try:
|
||||
point = [round(float(value), 4) for value in center]
|
||||
direction = [round(float(value), 4) for value in normal]
|
||||
bounds = [round(float(value), 4) for value in bbox]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
key = tuple(direction + point + bounds)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
footprint = abs((bounds[3] - bounds[0]) * (bounds[4] - bounds[1]))
|
||||
candidates.append({
|
||||
"point_mm": point,
|
||||
"normal": direction,
|
||||
"bbox_mm": bounds,
|
||||
"footprint_bbox_area_mm2": round(footprint, 4),
|
||||
})
|
||||
# Favor outward horizontal faces, then the broadest support surface.
|
||||
# A base top commonly supports an outer slot while a taller boss does
|
||||
# not; ordering by Z would teach the opposite choice.
|
||||
candidates.sort(key=lambda item: (
|
||||
-float(item["normal"][2]),
|
||||
-float(item["footprint_bbox_area_mm2"]),
|
||||
-float(item["point_mm"][2]),
|
||||
item["bbox_mm"],
|
||||
))
|
||||
return candidates[:limit]
|
||||
|
||||
def _replanning_evidence(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
"""Provide bounded server evidence that led to this local replan."""
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for event in reversed(self.repository.ledger_events(task_id)):
|
||||
if event.get("plan_hash") != state.feature_plan_hash:
|
||||
continue
|
||||
if event.get("event") == "feature_node_failed":
|
||||
evidence.append({
|
||||
"kind": "node_failure",
|
||||
"node_id": str(event.get("node_id") or ""),
|
||||
"failure_class": str(event.get("failure_class") or ""),
|
||||
"attempt": int(event.get("attempt") or 0),
|
||||
"message": str(event.get("message") or "")[:500],
|
||||
"blockers": (event.get("blockers") or [])[:8] if isinstance(event.get("blockers"), list) else [],
|
||||
})
|
||||
elif event.get("event") == "final_visual_reviewed" and event.get("visual_not_passed"):
|
||||
evidence.append({
|
||||
"kind": "final_visual_review",
|
||||
"claim_ids": [str(value) for value in event.get("visual_not_passed") or () if str(value)],
|
||||
"issues": [str(value)[:500] for value in event.get("issues") or () if str(value)],
|
||||
"evidence": [str(value)[:500] for value in event.get("evidence") or () if str(value)],
|
||||
})
|
||||
elif event.get("event") == "feature_plan_completion_failed":
|
||||
evidence.append({
|
||||
"kind": "final_deterministic_validation",
|
||||
"claim_ids": [str(value) for value in event.get("failed_claim_ids") or () if str(value)],
|
||||
"message": str(event.get("message") or "")[:500],
|
||||
"claim_results": [
|
||||
self._public_claim_result(item)
|
||||
for item in event.get("claim_results") or ()
|
||||
if isinstance(item, dict) and item.get("deterministic") and item.get("status") != "pass"
|
||||
],
|
||||
})
|
||||
if len(evidence) == 8:
|
||||
break
|
||||
return evidence
|
||||
|
||||
def waiting_for_user_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]:
|
||||
"""Expose the persisted requirement question when a task is parked.
|
||||
|
||||
@@ -1419,7 +1832,15 @@ class WorkflowCoordinator:
|
||||
@staticmethod
|
||||
def _public_pending_context(state: TaskState) -> dict[str, Any] | None:
|
||||
action = state.pending_action
|
||||
return {"intent": action.intent, "operation": action.atomic_id, "expected_change": action.expected_change} if action else None
|
||||
return {
|
||||
"node_id": action.node_id,
|
||||
"plan_hash": action.plan_hash,
|
||||
"intent": action.intent,
|
||||
"operation": action.atomic_id,
|
||||
"expected_change": action.expected_change,
|
||||
"claim_ids": list(action.claim_ids),
|
||||
"depends_on_node_ids": list(action.depends_on_node_ids),
|
||||
} if action else None
|
||||
|
||||
@staticmethod
|
||||
def _coordinate_protocol(state: TaskState) -> dict[str, str]:
|
||||
@@ -1471,13 +1892,20 @@ class WorkflowCoordinator:
|
||||
def _recent_failure_constraints(self, task_id: str, state: TaskState) -> list[dict[str, Any]]:
|
||||
constraints: list[dict[str, Any]] = []
|
||||
for event in reversed(self.repository.ledger_events(task_id)):
|
||||
if event.get("checkpoint_revision") != state.active_revision:
|
||||
is_current_feature_failure = (
|
||||
event.get("event") == "feature_node_failed"
|
||||
and event.get("plan_hash") == state.feature_plan_hash
|
||||
and state.pending_feature is not None
|
||||
and event.get("node_id") == state.pending_feature.node_id
|
||||
)
|
||||
if not is_current_feature_failure and event.get("checkpoint_revision") != state.active_revision:
|
||||
continue
|
||||
if not event.get("normalized_error_code"):
|
||||
normalized_error_code = str(event.get("normalized_error_code") or event.get("code") or "")
|
||||
if not normalized_error_code:
|
||||
continue
|
||||
constraints.append({
|
||||
"atomic_id": str(event.get("atomic_id") or ""),
|
||||
"normalized_error_code": str(event.get("normalized_error_code") or ""),
|
||||
"normalized_error_code": normalized_error_code,
|
||||
"fragment_hash": str(event.get("fragment_hash") or ""),
|
||||
"prohibited_exact_fingerprint": str(event.get("failure_exact_fingerprint") or ""),
|
||||
"attempt": int(event.get("attempt") or 1),
|
||||
@@ -1487,6 +1915,42 @@ class WorkflowCoordinator:
|
||||
break
|
||||
return constraints
|
||||
|
||||
def _feature_replan_exhausted(self, task_id: str, state: TaskState) -> dict[str, Any] | None:
|
||||
"""Bound repeated replacement plans at one immutable checkpoint.
|
||||
|
||||
Node IDs must change on every subgraph revision, so a node-local retry
|
||||
counter alone cannot stop a planner from replacing the same failed
|
||||
atomic operation forever. A successful feature creates a new revision;
|
||||
therefore checkpoint + atomic operation is a stable, narrow boundary
|
||||
for this cross-plan budget.
|
||||
"""
|
||||
if state.phase != TaskPhase.REPLANNING_FEATURE_SUBGRAPH:
|
||||
return None
|
||||
terminal = [
|
||||
event for event in self.repository.ledger_events(task_id)
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and bool(event.get("terminal"))
|
||||
and str(event.get("checkpoint_revision") or "") == state.active_revision
|
||||
]
|
||||
if not terminal:
|
||||
return None
|
||||
atomic_id = str(terminal[-1].get("atomic_id") or "")
|
||||
if not atomic_id:
|
||||
return None
|
||||
matching = [
|
||||
event for event in terminal
|
||||
if str(event.get("atomic_id") or "") == atomic_id
|
||||
]
|
||||
if len(matching) < _FEATURE_REPLAN_FAILURE_LIMIT:
|
||||
return None
|
||||
return {
|
||||
"atomic_id": atomic_id,
|
||||
"checkpoint_revision": state.active_revision,
|
||||
"terminal_failure_count": len(matching),
|
||||
"limit": _FEATURE_REPLAN_FAILURE_LIMIT,
|
||||
"node_ids": [str(event.get("node_id") or "") for event in matching],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _selector_tokens_for_contract(contract: dict[str, Any], tokens: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""Narrow dynamic selector enums to the current contract's kind."""
|
||||
@@ -1697,6 +2161,18 @@ class WorkflowCoordinator:
|
||||
feedback[:] = [self._feedback(error)]
|
||||
if counters[name] < self.config.format_error_limit:
|
||||
return None
|
||||
if state.feature_plan_hash:
|
||||
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),
|
||||
"checkpoint_preserved": bool(state.active_revision), "revision_id": state.active_revision,
|
||||
}])
|
||||
return "task_terminal", {
|
||||
"taskId": task_id, "lifecycle": "failed", "revisionId": state.active_revision,
|
||||
"code": ErrorCode.FAILED_AUTHOR_FORMAT.value,
|
||||
"message": f"{actor.capitalize()} repeatedly failed the canonical schema; the last executable checkpoint remains available.",
|
||||
"tool": name, "field_errors": list(error.field_errors),
|
||||
}
|
||||
terminal = self._best_effort_terminal(
|
||||
task_id,
|
||||
state,
|
||||
@@ -1760,6 +2236,14 @@ class WorkflowCoordinator:
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
if error.retryable or error.code == ErrorCode.STORAGE_FAILURE:
|
||||
return self._service_failure(task_id, state, error)
|
||||
if tool == "write_feature_plan":
|
||||
# A plan revision is authored against an already-frozen contract.
|
||||
# Its field errors must use the plan tool's own retry budget, not
|
||||
# the requirements compiler's shared counter. Otherwise one
|
||||
# earlier requirements correction can make the first replan
|
||||
# attempt terminal, even though the state remains perfectly
|
||||
# recoverable in REPLANNING_FEATURE_SUBGRAPH.
|
||||
return self._format_failure(task_id, state, tool, error, counters, feedback)
|
||||
return self._requirements_format_failure(task_id, state, error, counters, feedback, tool=tool)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import Settings
|
||||
from app.services.storage import WorkspaceStore
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -35,11 +36,13 @@ def compose_v3(settings: Settings) -> V3Services:
|
||||
# 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)
|
||||
if repository.protocol_reset:
|
||||
WorkspaceStore(settings).clear_current_task_references()
|
||||
artifacts = FileArtifactStore(settings.task_root)
|
||||
runtime = ProfileCadRuntime(settings)
|
||||
registry = default_registry()
|
||||
verifier = RegistryVerifierExecutor(registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry)
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, verifier)
|
||||
fallbacks = tuple(
|
||||
ModelIdentity(provider.id, model.id)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Immutable feature-DAG planning contracts and deterministic scheduling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class FeatureNode(_StrictModel):
|
||||
node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
priority: int = Field(ge=0, le=100_000)
|
||||
intent: str = Field(min_length=1, max_length=360)
|
||||
atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=64)
|
||||
claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
expected_change: str = Field(min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_references(self) -> "FeatureNode":
|
||||
if len(self.depends_on) != len(set(self.depends_on)):
|
||||
raise ValueError("depends_on must not contain duplicates")
|
||||
if len(self.claim_ids) != len(set(self.claim_ids)):
|
||||
raise ValueError("claim_ids must not contain duplicates")
|
||||
if self.node_id in self.depends_on:
|
||||
raise ValueError("a feature node cannot depend on itself")
|
||||
return self
|
||||
|
||||
|
||||
class FeaturePlan(_StrictModel):
|
||||
schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$")
|
||||
parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$")
|
||||
replaces_node_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
nodes: list[FeatureNode] = Field(min_length=1, max_length=256)
|
||||
final_claim_ids: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _unique_plan_fields(self) -> "FeaturePlan":
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
priorities = [node.priority for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id values must be unique")
|
||||
if len(priorities) != len(set(priorities)):
|
||||
raise ValueError("priority values must be unique")
|
||||
if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)):
|
||||
raise ValueError("replaces_node_ids must not contain duplicates")
|
||||
if len(self.final_claim_ids) != len(set(self.final_claim_ids)):
|
||||
raise ValueError("final_claim_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str:
|
||||
payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def node_hash(node: FeatureNode | dict[str, Any]) -> str:
|
||||
payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str):
|
||||
result[claim["claim_id"]] = claim
|
||||
return result
|
||||
|
||||
|
||||
def validate_feature_plan(
|
||||
plan: FeaturePlan,
|
||||
contract: dict[str, Any],
|
||||
atomic_ids: set[str] | frozenset[str] | tuple[str, ...],
|
||||
*,
|
||||
previous_plan: FeaturePlan | None = None,
|
||||
completed_node_hashes: dict[str, str] | None = None,
|
||||
required_replacements: set[str] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Return stable, tool-facing validation errors for a frozen plan."""
|
||||
errors: list[dict[str, str]] = []
|
||||
nodes = {node.node_id: node for node in plan.nodes}
|
||||
known_atoms = set(atomic_ids)
|
||||
claims = contract_claims(contract)
|
||||
assigned: dict[str, str] = {}
|
||||
|
||||
for index, node in enumerate(plan.nodes):
|
||||
prefix = f"/nodes/{index}"
|
||||
if node.atomic_id not in known_atoms:
|
||||
errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"})
|
||||
for dependency in node.depends_on:
|
||||
if dependency not in nodes:
|
||||
errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"})
|
||||
for claim_id in node.claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
continue
|
||||
if claim.get("verification_mode") != "deterministic":
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"})
|
||||
owner = assigned.setdefault(claim_id, node.node_id)
|
||||
if owner != node.node_id:
|
||||
errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"})
|
||||
|
||||
final = set(plan.final_claim_ids)
|
||||
for claim_id in plan.final_claim_ids:
|
||||
claim = claims.get(claim_id)
|
||||
if claim is None:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"})
|
||||
elif claim.get("verification_mode") == "deterministic":
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"})
|
||||
|
||||
for claim_id, claim in claims.items():
|
||||
deterministic = claim.get("verification_mode") == "deterministic"
|
||||
if deterministic and claim_id not in assigned:
|
||||
errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"})
|
||||
if not deterministic and claim_id not in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"})
|
||||
if deterministic and claim_id in final:
|
||||
errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"})
|
||||
|
||||
errors.extend(_cycle_errors(nodes))
|
||||
if previous_plan is None:
|
||||
if plan.parent_plan_hash:
|
||||
errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"})
|
||||
if plan.replaces_node_ids:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"})
|
||||
else:
|
||||
errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set()))
|
||||
return errors
|
||||
|
||||
|
||||
def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
errors: list[dict[str, str]] = []
|
||||
|
||||
def walk(current: str) -> None:
|
||||
if current in visiting:
|
||||
errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"})
|
||||
return
|
||||
if current in visited:
|
||||
return
|
||||
visiting.add(current)
|
||||
for dependency in nodes[current].depends_on:
|
||||
if dependency in nodes:
|
||||
walk(dependency)
|
||||
visiting.remove(current)
|
||||
visited.add(current)
|
||||
|
||||
for node_id in nodes:
|
||||
walk(node_id)
|
||||
return errors[:1]
|
||||
|
||||
|
||||
def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
next_nodes = {node.node_id: node for node in plan.nodes}
|
||||
if plan.parent_plan_hash != plan_hash(previous):
|
||||
errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"})
|
||||
replaced = set(plan.replaces_node_ids)
|
||||
if required_replacements and replaced != required_replacements:
|
||||
errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"})
|
||||
for node_id, frozen_hash in completed.items():
|
||||
node = next_nodes.get(node_id)
|
||||
if node is None:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"})
|
||||
elif node_hash(node) != frozen_hash:
|
||||
errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"})
|
||||
if node_id in replaced:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"})
|
||||
for node_id, old_node in old_nodes.items():
|
||||
if node_id in replaced:
|
||||
continue
|
||||
current = next_nodes.get(node_id)
|
||||
if current is None:
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"})
|
||||
elif node_hash(current) != node_hash(old_node):
|
||||
errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"})
|
||||
for node_id in replaced:
|
||||
if node_id not in old_nodes:
|
||||
errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"})
|
||||
if node_id in next_nodes:
|
||||
errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"})
|
||||
return errors
|
||||
|
||||
|
||||
class FeatureScheduler:
|
||||
"""Derive a plan's runnable node from immutable ledger evidence."""
|
||||
|
||||
def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None:
|
||||
self.plan = plan
|
||||
self.events = events
|
||||
self._nodes = {node.node_id: node for node in plan.nodes}
|
||||
|
||||
def statuses(self) -> dict[str, str]:
|
||||
states: dict[str, str] = {node_id: "pending" for node_id in self._nodes}
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if node_id not in states or event.get("node_hash") != expected_hashes[node_id]:
|
||||
continue
|
||||
if event.get("event") == "feature_node_verified":
|
||||
states[node_id] = "done"
|
||||
elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done":
|
||||
states[node_id] = "invalidated"
|
||||
elif event.get("event") == "feature_node_failed" and states[node_id] != "done":
|
||||
states[node_id] = "failed" if bool(event.get("terminal")) else "pending"
|
||||
elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending":
|
||||
states[node_id] = "running"
|
||||
for node in self.plan.nodes:
|
||||
if states[node.node_id] in {"done", "failed", "invalidated"}:
|
||||
continue
|
||||
dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on]
|
||||
if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states):
|
||||
states[node.node_id] = "blocked"
|
||||
elif all(value == "done" for value in dependency_states):
|
||||
states[node.node_id] = "ready" if states[node.node_id] != "running" else "running"
|
||||
return states
|
||||
|
||||
def next_ready(self) -> FeatureNode | None:
|
||||
statuses = self.statuses()
|
||||
ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"]
|
||||
return min(ready, key=lambda node: node.priority) if ready else None
|
||||
|
||||
def all_done(self) -> bool:
|
||||
return all(value == "done" for value in self.statuses().values())
|
||||
|
||||
def feature_ids(self) -> dict[str, str]:
|
||||
expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()}
|
||||
result: dict[str, str] = {}
|
||||
for event in self.events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
feature_id = str(event.get("feature_id") or "")
|
||||
if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id:
|
||||
result[node_id] = feature_id
|
||||
return result
|
||||
|
||||
def completed_node_hashes(self) -> dict[str, str]:
|
||||
statuses = self.statuses()
|
||||
return {
|
||||
node_id: node_hash(self._nodes[node_id])
|
||||
for node_id, status in statuses.items()
|
||||
if status == "done"
|
||||
}
|
||||
|
||||
def failure_count(self, node_id: str, failure_class: str) -> int:
|
||||
expected = node_hash(self._nodes[node_id])
|
||||
return sum(
|
||||
1
|
||||
for event in self.events
|
||||
if event.get("event") == "feature_node_failed"
|
||||
and event.get("node_id") == node_id
|
||||
and event.get("node_hash") == expected
|
||||
and event.get("failure_class") == failure_class
|
||||
)
|
||||
@@ -12,6 +12,13 @@ class TaskPhase(StrEnum):
|
||||
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
|
||||
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
|
||||
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
|
||||
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
|
||||
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
|
||||
FEATURE_PENDING = "FEATURE_PENDING"
|
||||
FEATURE_BUILDING = "FEATURE_BUILDING"
|
||||
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
|
||||
# Legacy v3.1 phases remain readable only so an interrupted process can
|
||||
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
|
||||
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
|
||||
AWAITING_ACTION = "AWAITING_ACTION"
|
||||
ACTION_PENDING = "ACTION_PENDING"
|
||||
@@ -35,6 +42,15 @@ class PendingAction:
|
||||
expected_change: str
|
||||
contract_hash: str
|
||||
idempotency_key: str
|
||||
node_id: str = ""
|
||||
plan_hash: str = ""
|
||||
claim_ids: tuple[str, ...] = ()
|
||||
depends_on_node_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
|
||||
# v3.2 code uses this alias to make the ownership boundary explicit.
|
||||
PendingFeature = PendingAction
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -53,9 +69,16 @@ class TaskState:
|
||||
requirements_document_path: str = ""
|
||||
completion_target_path: str = ""
|
||||
modeling_plan_path: str = ""
|
||||
feature_plan_path: str = ""
|
||||
feature_plan_hash: str = ""
|
||||
feature_stage_id: str = ""
|
||||
clarification_path: str = ""
|
||||
requirements_contract_path: str = ""
|
||||
|
||||
@property
|
||||
def pending_feature(self) -> PendingFeature | None:
|
||||
return self.pending_action
|
||||
|
||||
@property
|
||||
def working_head(self) -> str:
|
||||
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
|
||||
@@ -67,7 +90,21 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(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.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
|
||||
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
|
||||
# exposes this event or accepts a Markdown plan from a model.
|
||||
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
|
||||
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(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
|
||||
@@ -89,6 +126,7 @@ _TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
|
||||
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
|
||||
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
|
||||
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
|
||||
}
|
||||
@@ -111,6 +149,11 @@ _RETRY_RESUMABLE_PHASES = frozenset({
|
||||
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
|
||||
TaskPhase.DRAFTING_COMPLETION_TARGET,
|
||||
TaskPhase.COMPILING_REQUIREMENTS,
|
||||
TaskPhase.COMPILING_FEATURE_PLAN,
|
||||
TaskPhase.SCHEDULING_FEATURE,
|
||||
TaskPhase.FEATURE_PENDING,
|
||||
TaskPhase.FEATURE_BUILDING,
|
||||
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
|
||||
TaskPhase.DRAFTING_MODELING_PLAN,
|
||||
TaskPhase.AWAITING_ACTION,
|
||||
TaskPhase.ACTION_PENDING,
|
||||
@@ -139,7 +182,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, 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:
|
||||
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, feature_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, feature_plan_path: str | None = None, feature_plan_hash: 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:
|
||||
@@ -147,7 +190,7 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
||||
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
|
||||
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
|
||||
next_pending = state.pending_action if pending_action is ... else pending_action
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
|
||||
next_pending = None
|
||||
return replace(
|
||||
state,
|
||||
@@ -155,8 +198,8 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
||||
version=state.version + 1,
|
||||
active_revision=state.active_revision if active_revision is None else active_revision,
|
||||
pending_action=next_pending,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
|
||||
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
|
||||
repair_required=state.repair_required if repair_required is None else repair_required,
|
||||
last_error=error,
|
||||
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
|
||||
@@ -164,6 +207,9 @@ def transition(state: TaskState, event: str, *, pending_action: PendingAction |
|
||||
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,
|
||||
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
|
||||
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
|
||||
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -647,6 +647,60 @@ def _coaxial_through_bore_group(expected: dict[str, Any], facts: dict[str, Any])
|
||||
return _pass({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance}) if all(item["axis_dot"] >= 1 - 1e-6 and item["axis_distance_mm"] <= tolerance for item in deviations) else _fail({"diameter_mm": expected["diameter_mm"], "axes": deviations, "tolerance_mm": tolerance})
|
||||
|
||||
|
||||
def _concentric_bore_to_outer_cylinder(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||
"""Verify one bore axis coincides with one external cylindrical shell.
|
||||
|
||||
This is distinct from ``coaxial_through_bore_group``, which compares two
|
||||
or more inner bores. A centered flange bore has only one inner cylinder,
|
||||
so treating its outer wall as a second bore makes the old verifier
|
||||
permanently pending.
|
||||
"""
|
||||
if not _records(facts):
|
||||
return _pending("topology is unavailable")
|
||||
tolerance = float(expected["tolerance_mm"])
|
||||
bore = _matching_cylinders({
|
||||
"diameter_mm": expected["bore_diameter_mm"],
|
||||
"tolerance_mm": tolerance,
|
||||
}, facts)
|
||||
outer_shells = _outer_cylinder_shells({
|
||||
"diameter_mm": expected["outer_diameter_mm"],
|
||||
"tolerance_mm": tolerance,
|
||||
}, facts)
|
||||
if not bore:
|
||||
return _pending("the target bore has not been introduced at this checkpoint")
|
||||
if not outer_shells:
|
||||
return _pending("the target outer cylindrical surface has not been introduced at this checkpoint")
|
||||
if len(bore) != 1 or len(outer_shells) != 1:
|
||||
return _fail({
|
||||
"expected_bore_count": 1,
|
||||
"actual_bore_count": len(bore),
|
||||
"expected_outer_count": 1,
|
||||
"actual_outer_count": len(outer_shells),
|
||||
"bore_diameter_mm": expected["bore_diameter_mm"],
|
||||
"outer_diameter_mm": expected["outer_diameter_mm"],
|
||||
})
|
||||
bore_point = _cylinder_axis_point(bore[0])
|
||||
bore_axis = _cylinder_axis_direction(bore[0])
|
||||
outer_point = _cylinder_axis_point(outer_shells[0][0])
|
||||
outer_axis = _cylinder_axis_direction(outer_shells[0][0])
|
||||
if None in {bore_point, bore_axis, outer_point, outer_axis}:
|
||||
return _pending("the bore or outer-cylinder axis is not measurable")
|
||||
assert bore_point is not None and bore_axis is not None
|
||||
assert outer_point is not None and outer_axis is not None
|
||||
axis_dot = abs(sum(bore_axis[index] * outer_axis[index] for index in range(3)))
|
||||
delta = tuple(bore_point[index] - outer_point[index] for index in range(3))
|
||||
axial = sum(delta[index] * outer_axis[index] for index in range(3))
|
||||
radial_distance = sqrt(sum((delta[index] - axial * outer_axis[index]) ** 2 for index in range(3)))
|
||||
evidence = {
|
||||
"bore_record_id": bore[0].get("record_id"),
|
||||
"outer_record_ids": [record.get("record_id") for record in outer_shells[0]],
|
||||
"axis_dot": axis_dot,
|
||||
"axis_distance_mm": radial_distance,
|
||||
"tolerance_mm": tolerance,
|
||||
}
|
||||
return _pass(evidence) if axis_dot >= 1 - 1e-6 and radial_distance <= tolerance else _fail(evidence)
|
||||
|
||||
|
||||
def _orthogonal_intersecting_through_bores(expected: dict[str, Any], facts: dict[str, Any]) -> ClaimResult:
|
||||
first = _matching_cylinders({"diameter_mm": expected["first_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
|
||||
second = _matching_cylinders({"diameter_mm": expected["second_diameter_mm"], "tolerance_mm": expected["tolerance_mm"]}, facts)
|
||||
@@ -889,6 +943,7 @@ def default_registry() -> VerifierRegistry:
|
||||
ClaimDefinition("circular_hole_pattern", _closed_object({"count": {"type": "integer", "minimum": 2}, "diameter_mm": positive, "pitch_radius_mm": positive, "concentric_bore_diameter_mm": positive, "tolerance_mm": tolerance}, ["count", "diameter_mm", "pitch_radius_mm", "tolerance_mm"]), ("topology",), _hole_pattern),
|
||||
ClaimDefinition("collinear_through_bore_chain", bore_chain, ("topology",), _collinear_bore_chain),
|
||||
ClaimDefinition("coaxial_through_bore_group", _closed_object({"diameter_mm": positive, "count": {"type": "integer", "minimum": 2, "maximum": 16}, "tolerance_mm": alignment_tolerance}, ["diameter_mm", "count", "tolerance_mm"]), ("topology",), _coaxial_through_bore_group),
|
||||
ClaimDefinition("concentric_bore_to_outer_cylinder", _closed_object({"bore_diameter_mm": positive, "outer_diameter_mm": positive, "tolerance_mm": alignment_tolerance}, ["bore_diameter_mm", "outer_diameter_mm", "tolerance_mm"]), ("topology",), _concentric_bore_to_outer_cylinder),
|
||||
ClaimDefinition("orthogonal_intersecting_through_bores", _closed_object({"first_diameter_mm": positive, "second_diameter_mm": positive, "first_axis": {"enum": ["x", "y", "z"]}, "second_axis": {"enum": ["x", "y", "z"]}, "tolerance_mm": alignment_tolerance}, ["first_diameter_mm", "second_diameter_mm", "first_axis", "second_axis", "tolerance_mm"]), ("topology",), _orthogonal_intersecting_through_bores),
|
||||
ClaimDefinition("rectangular_corner_through_bore_pattern", _closed_object({"diameter_mm": positive, "count": {"const": 4}, "edge_offset_mm": positive, "tolerance_mm": tolerance}, ["diameter_mm", "count", "edge_offset_mm", "tolerance_mm"]), ("rebuild_report", "topology"), _rectangular_corner_bore_pattern),
|
||||
ClaimDefinition("coaxial", _closed_object({"record_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "maxItems": 2, "uniqueItems": True}, "tolerance": alignment_tolerance}, ["record_ids", "tolerance"]), ("topology",), _coaxial),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Create a resumable live-evaluation task without starting its workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
from app.cad_agent.composition import compose_v3
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create one isolated live CAD task for resume_one_step.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--task-id")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
root = arguments.report_root.resolve()
|
||||
settings = get_settings()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=root / "artifacts",
|
||||
conversation_root=root / "conversations",
|
||||
))
|
||||
task_id = arguments.task_id or f"cad_{secrets.token_hex(6)}"
|
||||
services.workflow.create_task(task_id, arguments.prompt)
|
||||
print(json.dumps({"task_id": task_id, "report_root": str(root)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -36,7 +36,7 @@ from app.cad_agent.evals.token_baseline import (
|
||||
from app.settings import BACKEND_ROOT, get_settings
|
||||
|
||||
|
||||
_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"candidate.json",
|
||||
"candidate-review.json",
|
||||
"model.cdsl.json",
|
||||
@@ -48,6 +48,19 @@ _CANDIDATE_EVIDENCE_FILES = frozenset({
|
||||
"renders/contact-sheet.jpg",
|
||||
})
|
||||
|
||||
# A v3.2 Feature DAG publishes one atomic checkpoint after local verification.
|
||||
# It deliberately has no candidate review or technical render bundle; the
|
||||
# final review owns that later evidence. GLB is optional because a preview
|
||||
# conversion outage must not invalidate an otherwise sound STEP checkpoint.
|
||||
_FEATURE_NODE_EVIDENCE_FILES = frozenset({
|
||||
"input.json",
|
||||
"model.cdsl.json",
|
||||
"model.step",
|
||||
"model.topology.json",
|
||||
"node-verification.json",
|
||||
"rebuild-report.json",
|
||||
})
|
||||
|
||||
_FAILURE_LAYERS = frozenset({
|
||||
"model_format_or_decision",
|
||||
"v3_contract_or_verifier",
|
||||
@@ -240,13 +253,14 @@ def _artifact_evidence_complete(
|
||||
revisions: list[str],
|
||||
active_revision: str,
|
||||
artifact_manifest: dict[str, Any] | None,
|
||||
ledger: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Require every published CAD decision to retain its reviewable evidence.
|
||||
|
||||
A non-empty report manifest is not enough: a task could otherwise report
|
||||
only a rendered contract view while silently losing the STEP, topology, or
|
||||
candidate review used to accept a revision. Candidate manifests protect
|
||||
the immutable build inputs/outputs; the report manifest additionally
|
||||
node verification used to accept a revision. Checkpoint manifests protect
|
||||
immutable build inputs and outputs; the report manifest additionally
|
||||
protects the frozen contract and final independent review written later.
|
||||
"""
|
||||
if not revisions or not active_revision:
|
||||
@@ -264,9 +278,19 @@ def _artifact_evidence_complete(
|
||||
return False
|
||||
if not all((artifact_root / path).is_file() for path in required_report_files):
|
||||
return False
|
||||
feature_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_verified"
|
||||
}
|
||||
for revision_id in sorted(set(revisions)):
|
||||
revision_root = artifact_root / "revisions" / revision_id
|
||||
required_paths = {revision_root / relative for relative in _CANDIDATE_EVIDENCE_FILES}
|
||||
evidence_files = (
|
||||
_FEATURE_NODE_EVIDENCE_FILES
|
||||
if revision_id in feature_revisions
|
||||
else _LEGACY_CANDIDATE_EVIDENCE_FILES
|
||||
)
|
||||
required_paths = {revision_root / relative for relative in evidence_files}
|
||||
manifest_path = revision_root / "manifest.json"
|
||||
if not manifest_path.is_file() or not all(path.is_file() for path in required_paths):
|
||||
return False
|
||||
@@ -275,7 +299,7 @@ def _artifact_evidence_complete(
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
declared = manifest.get("files") if isinstance(manifest, dict) else None
|
||||
if not isinstance(declared, dict) or not _CANDIDATE_EVIDENCE_FILES.issubset(declared):
|
||||
if not isinstance(declared, dict) or not evidence_files.issubset(declared):
|
||||
return False
|
||||
for relative, digest in declared.items():
|
||||
path = revision_root / str(relative)
|
||||
@@ -284,7 +308,7 @@ def _artifact_evidence_complete(
|
||||
if sha256(path.read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
if artifact_manifest is not None:
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in _CANDIDATE_EVIDENCE_FILES}
|
||||
expected_report_paths = {f"revisions/{revision_id}/{relative}" for relative in evidence_files}
|
||||
if not expected_report_paths.issubset(report_files):
|
||||
return False
|
||||
return True
|
||||
@@ -356,9 +380,24 @@ def _run_checks(
|
||||
reviewer_calls = [item for item in records if isinstance(item, dict) and item.get("role") == "reviewer"]
|
||||
total_tokens = int(usage.get("prompt_tokens") or 0) + int(usage.get("completion_tokens") or 0)
|
||||
audit_ledger = ledger if ledger is not None else [item for item in projection.get("action_ledger_summary") or () if isinstance(item, dict)]
|
||||
accepted = [item for item in audit_ledger if isinstance(item, dict) and item.get("event") == "accepted"]
|
||||
revisions = [str(item.get("revision_id") or "") for item in accepted]
|
||||
operation_ids = {str(item.get("actual_atomic_id") or "") for item in accepted}
|
||||
published = [
|
||||
item for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") in {"accepted", "feature_node_verified"}
|
||||
]
|
||||
revisions = [str(item.get("revision_id") or "") for item in published]
|
||||
scheduled_atomic_ids = {
|
||||
str(item.get("node_id") or ""): str(item.get("atomic_id") or "")
|
||||
for item in audit_ledger
|
||||
if isinstance(item, dict) and item.get("event") == "feature_node_scheduled"
|
||||
}
|
||||
operation_ids = {
|
||||
str(
|
||||
item.get("actual_atomic_id")
|
||||
or item.get("atomic_id")
|
||||
or scheduled_atomic_ids.get(str(item.get("node_id") or ""), "")
|
||||
)
|
||||
for item in published
|
||||
}
|
||||
required_operation_groups = [
|
||||
{str(atomic_id) for atomic_id in group if isinstance(atomic_id, str) and atomic_id}
|
||||
for group in scenario.get("required_any_atomic_id_groups") or ()
|
||||
@@ -386,6 +425,7 @@ def _run_checks(
|
||||
revisions,
|
||||
active_revision,
|
||||
artifact_manifest,
|
||||
audit_ledger,
|
||||
)
|
||||
return {
|
||||
"expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"),
|
||||
@@ -712,7 +752,7 @@ async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, An
|
||||
"reviewer": str(reviewer_capability.get("mode") or ""),
|
||||
},
|
||||
"git_revision": _git_revision(),
|
||||
"protocol_version": "3.0",
|
||||
"protocol_version": "3.2",
|
||||
"runtime_profile_sha256": runtime_profile_hash,
|
||||
"operation_contracts": contracts,
|
||||
"verifier_registry_version": "cad.verifier-registry.v1",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Advance one persisted event for a task in an isolated live-evaluation root.
|
||||
|
||||
Long real-provider evaluations can outlive a command host's execution window.
|
||||
This utility takes exactly one event from ``WorkflowCoordinator.run`` and
|
||||
closes the async generator after that event has been durably handled. Repeated
|
||||
invocations therefore resume the same task without re-running already
|
||||
persisted nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
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 get_settings
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Advance one event for an isolated live CAD evaluation task.")
|
||||
parser.add_argument("--report-root", required=True, type=Path)
|
||||
parser.add_argument("--task-id", required=True)
|
||||
parser.add_argument("--author-provider")
|
||||
parser.add_argument("--author-model")
|
||||
parser.add_argument("--review-provider")
|
||||
parser.add_argument("--review-model")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def _advance(arguments: argparse.Namespace) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
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)
|
||||
report_root = arguments.report_root.resolve()
|
||||
services = compose_v3(replace(
|
||||
settings,
|
||||
task_root=report_root / "artifacts",
|
||||
conversation_root=report_root / "conversations",
|
||||
))
|
||||
before = services.repository.get_state(arguments.task_id)
|
||||
if before is None:
|
||||
raise ValueError(f"Unknown task {arguments.task_id!r} in {report_root}")
|
||||
runner = services.workflow.run(
|
||||
task_id=arguments.task_id,
|
||||
author=ModelIdentity(author_provider.id, author_model.id),
|
||||
reviewer=ModelIdentity(review_provider.id, review_model.id),
|
||||
)
|
||||
try:
|
||||
name, payload = await anext(runner)
|
||||
except StopAsyncIteration:
|
||||
name, payload = "workflow_exhausted", {}
|
||||
finally:
|
||||
await runner.aclose()
|
||||
after = services.repository.get_state(arguments.task_id)
|
||||
return {
|
||||
"task_id": arguments.task_id,
|
||||
"event": {"name": name, "payload": payload},
|
||||
"before": {"phase": before.phase.value, "version": before.version},
|
||||
"after": {
|
||||
"phase": after.phase.value if after is not None else "",
|
||||
"version": after.version if after is not None else -1,
|
||||
"active_revision": after.active_revision if after is not None else "",
|
||||
"last_error": after.last_error.value if after is not None and after.last_error else "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = _arguments()
|
||||
try:
|
||||
result = asyncio.run(_advance(arguments))
|
||||
except BaseException as error:
|
||||
result = {"error": f"{type(error).__name__}: {str(error)[:1000]}"}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -86,7 +86,10 @@ class CadRuntime(Protocol):
|
||||
def operation_contract(self, atomic_id: str) -> dict[str, Any]: ...
|
||||
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: ...
|
||||
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 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, depends_on_feature_ids: tuple[str, ...] | list[str] = ()) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
||||
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]: ...
|
||||
def create_preview(self, output_dir: str) -> dict[str, Any]: ...
|
||||
def render_review_bundle(self, output_dir: str) -> 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]]]: ...
|
||||
|
||||
|
||||
+39
-6
@@ -7,6 +7,7 @@ from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.models.contracts import ChatRequest, ConversationPatch
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler
|
||||
from app.services.agent_service import AgentService
|
||||
from app.services.library import CdslLibrary
|
||||
from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id
|
||||
@@ -158,12 +159,27 @@ async def read_task(task_id: str) -> JSONResponse:
|
||||
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
|
||||
plan_path = agent.v3.artifacts.artifact_path(safe_id, state.feature_plan_path) if state is not None and state.feature_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 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["feature_plan"] = agent.v3.artifacts.read_json(safe_id, state.feature_plan_path) if state is not None and state.feature_plan_path else None
|
||||
task["feature_plan_path"] = state.feature_plan_path if plan_path and plan_path.is_file() else ""
|
||||
task["feature_plan_hash"] = state.feature_plan_hash if state is not None else ""
|
||||
if isinstance(task["feature_plan"], dict):
|
||||
plan = FeaturePlan.model_validate(task["feature_plan"])
|
||||
statuses = FeatureScheduler(plan, agent.v3.repository.ledger_events(safe_id)).statuses()
|
||||
node_evidence = {str(item.get("node_id") or ""): item for item in task.get("feature_nodes") or () if isinstance(item, dict)}
|
||||
task["feature_nodes"] = [
|
||||
{
|
||||
"node_id": str(node.get("node_id") or ""), "intent": str(node.get("intent") or ""),
|
||||
"atomic_id": str(node.get("atomic_id") or ""), "priority": node.get("priority"),
|
||||
"depends_on": node.get("depends_on") or [], "claim_ids": node.get("claim_ids") or [],
|
||||
"status": statuses.get(str(node.get("node_id") or ""), "pending"),
|
||||
**node_evidence.get(str(node.get("node_id") or ""), {}),
|
||||
}
|
||||
for node in task["feature_plan"].get("nodes") or () if isinstance(node, dict)
|
||||
]
|
||||
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)
|
||||
@@ -179,7 +195,7 @@ def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[st
|
||||
"""
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for event in reversed(ledger if isinstance(ledger, list) else []):
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed"}:
|
||||
if not isinstance(event, dict) or event.get("event") not in {"accepted", "completed", "feature_node_verified", "final_visual_reviewed"}:
|
||||
continue
|
||||
values = event.get("claim_results")
|
||||
if not isinstance(values, list):
|
||||
@@ -291,8 +307,25 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse:
|
||||
raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable")
|
||||
return FileResponse(path, filename=path.name)
|
||||
|
||||
# A running task may render its current checkpoint in the browser, but
|
||||
# cannot expose any other checkpoint artifact or a failed-task preview.
|
||||
v32_checkpoint_paths = {
|
||||
f"revisions/{revision_id}/model.cdsl.json",
|
||||
f"revisions/{revision_id}/model.step",
|
||||
f"revisions/{revision_id}/model.glb",
|
||||
f"revisions/{revision_id}/rebuild-report.json",
|
||||
}
|
||||
verified_revisions = {
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
}
|
||||
# Each v3.2 node revision is immutable and manifest-published. Make those
|
||||
# checkpoints inspectable from the DAG while keeping every staging input,
|
||||
# rejected candidate and arbitrary task artifact private.
|
||||
if task.get("schema_version") == "3.2" and revision_id in verified_revisions and artifact_path in v32_checkpoint_paths:
|
||||
return FileResponse(path, media_type="model/gltf-binary" if path.suffix == ".glb" else None, headers={"Content-Disposition": "inline" if path.suffix == ".glb" else f"attachment; filename={path.name}"})
|
||||
|
||||
# A legacy running task may render its active checkpoint in the browser,
|
||||
# but cannot expose any other checkpoint artifact or failed-task preview.
|
||||
if (
|
||||
str(task.get("lifecycle") or "") != "running"
|
||||
or revision_id != active_revision
|
||||
|
||||
@@ -96,6 +96,19 @@ class WorkspaceStore:
|
||||
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
|
||||
return _read_json(self.conversation_path(conversation_id))
|
||||
|
||||
def clear_current_task_references(self) -> int:
|
||||
"""Detach conversations from task artifacts removed by a protocol reset."""
|
||||
cleared = 0
|
||||
for path in self.settings.conversation_root.glob("conv_*/conversation.json"):
|
||||
record = _read_json(path)
|
||||
if not isinstance(record, dict) or not record.get("current_task_id"):
|
||||
continue
|
||||
record["current_task_id"] = ""
|
||||
record["updated_at"] = now_iso()
|
||||
_write_json(path, record)
|
||||
cleared += 1
|
||||
return cleared
|
||||
|
||||
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
|
||||
record = self.ensure_conversation(conversation_id, current_task_id)
|
||||
known = {str(item.get("id") or "") for item in record["messages"] if isinstance(item, dict)}
|
||||
|
||||
@@ -18,12 +18,13 @@ sys.path.insert(0, str(ROOT / "backend"))
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
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.runtime import ProfileCadRuntime, RuntimeAdapterError
|
||||
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 (
|
||||
AcceptanceClaimInput,
|
||||
CandidateReview,
|
||||
CompiledRequirementsSpec,
|
||||
MarkdownDocument,
|
||||
@@ -42,9 +43,9 @@ from app.cad_agent.application.outbox import OutboxDispatcher
|
||||
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.errors import ErrorCode, WorkflowError
|
||||
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.state import PendingAction, TaskPhase, TaskState, legal_transitions, retry_resume_event, transition
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.models.contracts import ChatMessage
|
||||
from app.services.agent_service import AgentService
|
||||
@@ -107,7 +108,10 @@ def completion_target() -> MarkdownDocument:
|
||||
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": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"assumptions": [], "acceptance_claims": [{"claim_kind": "circular_hole_pattern", "expected": {"count": 4, "diameter_mm": 10, "pitch_radius_mm": 35, "tolerance_mm": 0.1}}]},
|
||||
]})
|
||||
|
||||
@@ -196,6 +200,194 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
selector = schema["properties"]["feature"]["properties"]["selector_tokens"]["items"]
|
||||
self.assertEqual(selector, {"enum": ["selector_one"]})
|
||||
|
||||
def test_counterbore_can_reuse_a_matching_pilot_inside_an_annular_host_face(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
host = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
|
||||
"bbox_mm": [-48, -48, 34, 48, 48, 34],
|
||||
"boundary_loops_mm": [
|
||||
[[-48, -48, 34], [48, -48, 34], [48, 48, 34], [-48, 48, 34]],
|
||||
[[-16, -16, 34], [16, -16, 34], [16, 16, 34], [-16, 16, 34]],
|
||||
],
|
||||
},
|
||||
}
|
||||
pilot = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "cylinder", "cylinder_role": "inner", "through": True,
|
||||
"radius_mm": 16, "axis_origin_mm": [0, 0, 0], "axis_direction": [0, 0, 1],
|
||||
"bbox_mm": [-16, -16, 0, 16, 16, 34],
|
||||
},
|
||||
}
|
||||
fragment = {
|
||||
"feature": {
|
||||
"atomic_id": "hole_counterbore", "selector_tokens": ["host"],
|
||||
"params": {"diameter_mm": 32, "depth_mm": 34, "counterbore_diameter_mm": 62, "counterbore_depth_mm": 12, "positions": [{"mm": [0, 0, 34]}]},
|
||||
},
|
||||
}
|
||||
runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False)
|
||||
fragment["feature"]["params"]["diameter_mm"] = 30
|
||||
with self.assertRaises(RuntimeAdapterError):
|
||||
runtime._preflight_hole_positions_on_host_plane(fragment, {"host": host, "pilot": pilot}, None, False)
|
||||
|
||||
def test_counterbore_operation_verifier_measures_the_new_recess_not_the_existing_pilot(self) -> None:
|
||||
class CounterboreVerifier:
|
||||
def __init__(self) -> None:
|
||||
self.operation_claims: list[dict[str, object]] = []
|
||||
|
||||
def evaluate(self, claims: list[dict[str, object]], _facts: dict[str, object]) -> list[dict[str, object]]:
|
||||
if claims[0]["claim_id"] == "operation_parent_bore_count":
|
||||
self.assertEqual(claims[0]["expected"]["diameter_mm"], 62.0)
|
||||
return [{"evidence": {"actual_count": 0}}]
|
||||
self.operation_claims = claims
|
||||
return [{"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": True, "status": "pass", "evidence": {}} for claim in claims]
|
||||
|
||||
def assertEqual(self, actual: object, expected: object) -> None:
|
||||
if actual != expected:
|
||||
raise AssertionError(f"{actual!r} != {expected!r}")
|
||||
|
||||
verifier = CounterboreVerifier()
|
||||
handler = ActionCommandHandler(None, None, None, verifier)
|
||||
action = PendingAction(
|
||||
action_id="counterbore", working_head="cad_test:rev_001:v1", intent="Counterbore.", requirement_ids=(),
|
||||
atomic_id="hole_counterbore", expected_change="Cut a counterbore.", contract_hash="contract", idempotency_key="key",
|
||||
)
|
||||
results = handler._operation_candidate_results(
|
||||
action,
|
||||
{"candidate_verifiers": ["cylindrical_bore"]},
|
||||
{"features": [{"atomic_id": "hole_counterbore", "params": {"diameter_mm": 32, "counterbore_diameter_mm": 62, "positions": [{"mm": [0, 0, 34]}]}}]},
|
||||
{"health": {}, "topology": {}, "report": {}},
|
||||
parent_facts={"health": {}, "topology": {}, "report": {}},
|
||||
require_through=False,
|
||||
)
|
||||
self.assertEqual(verifier.operation_claims[0]["expected"], {"diameter_mm": 62.0, "count": 1, "tolerance_mm": 0.01})
|
||||
self.assertEqual(results[0]["status"], "pass")
|
||||
|
||||
def test_extrude_cut_rejects_a_slot_profile_floating_above_the_base(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
base_face = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1],
|
||||
"boundary_loops_mm": [[[-90, -50, 16], [90, -50, 16], [90, 50, 16], [-90, 50, 16]]],
|
||||
},
|
||||
}
|
||||
boss_face = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
|
||||
"boundary_loops_mm": [[[48, 0, 34], [0, 48, 34], [-48, 0, 34], [0, -48, 34]]],
|
||||
},
|
||||
}
|
||||
fragment = {
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 34], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "analytic_contours", "contours": [{"closed": True, "role": "outer", "segments": [
|
||||
{"type": "line", "start": [68, 40], "end": [85, 40]},
|
||||
{"type": "line", "start": [85, 40], "end": [85, 49]},
|
||||
{"type": "line", "start": [85, 49], "end": [68, 49]},
|
||||
{"type": "line", "start": [68, 49], "end": [68, 40]},
|
||||
]}]},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}},
|
||||
}
|
||||
with self.assertRaisesRegex(RuntimeAdapterError, "profile does not contact material"):
|
||||
runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face})
|
||||
fragment["sketch"]["workplane"]["origin_mm"][2] = 16
|
||||
runtime._preflight_extrude_cut_contacts_material(fragment, {"base": base_face, "boss": boss_face})
|
||||
|
||||
def test_surface_attached_cut_direction_is_normalized_into_material(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cut_direction",
|
||||
"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"}],
|
||||
}
|
||||
fragment = {
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [6, 0], "radius_mm": 1},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 2}},
|
||||
}
|
||||
top_face = {
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 5], "normal": [0, 0, 1],
|
||||
"boundary_loops_mm": [[[-10, -10, 5], [10, -10, 5], [10, 10, 5], [-10, 10, 5]]],
|
||||
},
|
||||
}
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
fragment,
|
||||
runtime.operation_contract("extrude_cut_blind"),
|
||||
{"top": top_face},
|
||||
runtime.reference_tokens(base),
|
||||
)
|
||||
self.assertTrue(document["features"][-1]["params"]["reverse"])
|
||||
direction = next(item for item in audit["server_normalizations"] if item["path"] == "feature.params.reverse")
|
||||
self.assertFalse(direction["submitted"])
|
||||
self.assertTrue(direction["materialized"])
|
||||
|
||||
def test_sketch_workplane_candidates_prefer_the_broad_base_support(self) -> None:
|
||||
candidates = WorkflowCoordinator._sketch_workplane_candidates({"records": [
|
||||
{
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 34], "normal": [0, 0, 1],
|
||||
"bbox_mm": [-48, -48, 34, 48, 48, 34],
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [0, 0, 16], "normal": [0, 0, 1],
|
||||
"bbox_mm": [-90, -50, 16, 90, 50, 16],
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "face",
|
||||
"geometry": {
|
||||
"surface_type": "plane", "center_mm": [90, 0, 8], "normal": [1, 0, 0],
|
||||
"bbox_mm": [90, -50, 0, 90, 50, 16],
|
||||
},
|
||||
},
|
||||
]})
|
||||
self.assertEqual(candidates[0]["point_mm"], [0.0, 0.0, 16.0])
|
||||
self.assertEqual(candidates[0]["footprint_bbox_area_mm2"], 18000.0)
|
||||
self.assertEqual(candidates[1]["point_mm"], [0.0, 0.0, 34.0])
|
||||
|
||||
def test_replan_budget_spans_replacement_node_ids_at_one_checkpoint(self) -> None:
|
||||
events = [
|
||||
{
|
||||
"event": "feature_node_failed", "node_id": node_id,
|
||||
"atomic_id": "extrude_cut_blind", "checkpoint_revision": "rev_005", "terminal": True,
|
||||
}
|
||||
for node_id in ("corner_slots_v1", "corner_slots_v2", "corner_slots_v3")
|
||||
]
|
||||
|
||||
class Repository:
|
||||
@staticmethod
|
||||
def ledger_events(_task_id: str) -> list[dict[str, object]]:
|
||||
return events
|
||||
|
||||
workflow = object.__new__(WorkflowCoordinator)
|
||||
workflow.repository = Repository()
|
||||
state = TaskState("cad_123456abcdef", TaskPhase.REPLANNING_FEATURE_SUBGRAPH, 10, active_revision="rev_005")
|
||||
exhausted = workflow._feature_replan_exhausted(state.task_id, state)
|
||||
self.assertIsNotNone(exhausted)
|
||||
self.assertEqual(exhausted["terminal_failure_count"], 3)
|
||||
self.assertEqual(exhausted["node_ids"], ["corner_slots_v1", "corner_slots_v2", "corner_slots_v3"])
|
||||
|
||||
def test_root_extrusion_schema_fixes_world_xy_datum_without_deciding_z(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(settings(Path(temporary)))
|
||||
@@ -445,7 +637,10 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
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": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"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)
|
||||
@@ -467,18 +662,125 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
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": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1, "tolerance_mm": 0.1}},
|
||||
{"claim_kind": "concentric_bore_to_outer_cylinder", "expected": {"bore_diameter_mm": 30, "outer_diameter_mm": 100, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"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.assertEqual([claim["claim_kind"] for claim in claims], ["visual", "through_cylindrical_bore", "concentric_bore_to_outer_cylinder", "visual"])
|
||||
self.assertEqual([claim["verification_mode"] for claim in claims], ["visual", "deterministic", "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_unbacked_coaxial_bore_group_is_not_frozen_as_a_deterministic_claim(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())
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 40, "count": 2, "tolerance_mm": 0.01}},
|
||||
]},
|
||||
]})
|
||||
normalized, warnings = handler._normalize_compiled_spec(output, ["A through bore.", "The bore is concentric with the outer profile."])
|
||||
self.assertEqual(normalized.requirements[1].acceptance_claims[0].claim_kind, "visual")
|
||||
self.assertTrue(any("no matching multi-bore target" in warning for warning in warnings))
|
||||
|
||||
def test_obround_slot_is_not_compiled_as_a_corner_bore_pattern(self) -> None:
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [{
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [{
|
||||
"claim_kind": "rectangular_corner_through_bore_pattern",
|
||||
"expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 26, "tolerance_mm": 0.1},
|
||||
}],
|
||||
}]})
|
||||
normalized, warnings = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec(
|
||||
output,
|
||||
["Four 26 x 9 mm oblong adjustment slots are present at the four corners."],
|
||||
)
|
||||
claim = normalized.requirements[0].acceptance_claims[0]
|
||||
self.assertEqual(claim.claim_kind, "visual")
|
||||
self.assertEqual(claim.expected["description"], "Four 26 x 9 mm oblong adjustment slots are present at the four corners.")
|
||||
self.assertTrue(any("describes an obround slot" in warning for warning in warnings))
|
||||
|
||||
def test_centered_bore_checklist_item_requires_concentric_claim_coverage(self) -> None:
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [{
|
||||
"assumptions": [],
|
||||
"acceptance_claims": [{"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}}],
|
||||
}]})
|
||||
errors = RequirementsCommandHandler._relationship_claim_errors(
|
||||
output,
|
||||
["A centered 40 mm through bore is present."],
|
||||
)
|
||||
self.assertEqual(errors[0]["path"], "/requirements/0/acceptance_claims")
|
||||
output.requirements[0].acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {"bore_diameter_mm": 40, "outer_diameter_mm": 120, "tolerance_mm": 0.01},
|
||||
}))
|
||||
self.assertEqual(
|
||||
RequirementsCommandHandler._relationship_claim_errors(output, ["A centered 40 mm through bore is present."]),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_compiler_derives_concentric_claim_from_frozen_outer_cylinder_and_centered_bore(self) -> None:
|
||||
output = CompiledRequirementsSpec.model_validate({"requirements": [
|
||||
{"assumptions": [], "acceptance_claims": [{
|
||||
"claim_kind": "outer_cylindrical_surface",
|
||||
"expected": {"diameter_mm": 120, "axial_span_mm": 12, "count": 1, "tolerance_mm": 0.01},
|
||||
}]},
|
||||
{"assumptions": [], "acceptance_claims": [{
|
||||
"claim_kind": "through_cylindrical_bore",
|
||||
"expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01},
|
||||
}]},
|
||||
]})
|
||||
normalized, _ = RequirementsCommandHandler(None, None, default_registry())._normalize_compiled_spec(
|
||||
output,
|
||||
["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."],
|
||||
)
|
||||
derived = normalized.requirements[1].acceptance_claims[-1]
|
||||
self.assertEqual(derived.claim_kind, "concentric_bore_to_outer_cylinder")
|
||||
self.assertEqual(derived.expected, {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01})
|
||||
self.assertEqual(
|
||||
RequirementsCommandHandler._relationship_claim_errors(
|
||||
normalized,
|
||||
["A 120 mm cylindrical outer flange is present.", "A centered 40 mm through bore is present."],
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_concentric_bore_to_outer_cylinder_verifier_measures_axis_offset(self) -> None:
|
||||
outer = {
|
||||
"record_id": "outer", "geometry": {
|
||||
"surface_type": "cylinder", "cylinder_role": "outer", "radius_mm": 60.0,
|
||||
"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0],
|
||||
"bbox_mm": [-60.0, -60.0, 0.0, 60.0, 60.0, 12.0],
|
||||
},
|
||||
}
|
||||
bore = {
|
||||
"record_id": "bore", "geometry": {
|
||||
"surface_type": "cylinder", "cylinder_role": "inner", "radius_mm": 20.0,
|
||||
"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, 0.0, 1.0],
|
||||
"bbox_mm": [-20.0, -20.0, 0.0, 20.0, 20.0, 12.0], "through": True,
|
||||
},
|
||||
}
|
||||
expected = {"bore_diameter_mm": 40.0, "outer_diameter_mm": 120.0, "tolerance_mm": 0.01}
|
||||
registry = default_registry()
|
||||
result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}})
|
||||
self.assertEqual(result["status"], "pass")
|
||||
bore["geometry"]["axis_origin_mm"] = [0.1, 0.0, 0.0]
|
||||
result = registry.evaluate("concentric_bore_to_outer_cylinder", expected, {"topology": {"records": [outer, bore]}})
|
||||
self.assertEqual(result["status"], "fail")
|
||||
self.assertAlmostEqual(result["evidence"]["axis_distance_mm"], 0.1)
|
||||
|
||||
def test_local_cylindrical_span_does_not_become_global_bbox_requirement(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
@@ -572,6 +874,56 @@ class CadV3ProtocolTests(unittest.TestCase):
|
||||
self.assertEqual(result.error.code, ErrorCode.REQUIREMENTS_SPEC_INVALID)
|
||||
self.assertEqual(repository.get_state(task_id), before)
|
||||
|
||||
def test_feature_plan_rejection_has_an_independent_retry_budget(self) -> None:
|
||||
"""A failed replan must not consume a prior requirements-format retry."""
|
||||
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()))
|
||||
workflow = WorkflowCoordinator(
|
||||
WorkflowConfig(max_turns=8, format_error_limit=2),
|
||||
repository,
|
||||
artifacts,
|
||||
runtime,
|
||||
object(), # The direct retry-budget test does not call a model.
|
||||
object(),
|
||||
requirements,
|
||||
actions,
|
||||
)
|
||||
initial = repository.create_task("cad_123456abcdef", "Create a plate.")
|
||||
documented = transition(initial, "requirements_document_written", requirements_document_path="requirements.md")
|
||||
targeted = transition(documented, "completion_target_written", completion_target_path="completion-target.md")
|
||||
compiling = transition(targeted, "requirements_compiled", requirements_contract_path="requirements-contract.json")
|
||||
scheduled = transition(
|
||||
compiling,
|
||||
"feature_plan_written",
|
||||
feature_plan_path="plans/feature-plan-active.json",
|
||||
feature_plan_hash="a" * 64,
|
||||
)
|
||||
replanning = transition(scheduled, "feature_replan", error=ErrorCode.CANDIDATE_REVIEW_REJECTED)
|
||||
self.assertTrue(repository.compare_and_swap(documented))
|
||||
self.assertTrue(repository.compare_and_swap(targeted))
|
||||
self.assertTrue(repository.compare_and_swap(compiling))
|
||||
self.assertTrue(repository.compare_and_swap(scheduled))
|
||||
self.assertTrue(repository.compare_and_swap(replanning))
|
||||
counters = {"requirements_spec": 1}
|
||||
feedback: list[dict[str, object]] = []
|
||||
terminal = workflow._requirements_rejection(
|
||||
replanning.task_id,
|
||||
replanning,
|
||||
WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan violates immutable-node rules."),
|
||||
counters,
|
||||
feedback,
|
||||
tool="write_feature_plan",
|
||||
)
|
||||
self.assertIsNone(terminal)
|
||||
self.assertEqual(repository.get_state(replanning.task_id).phase, TaskPhase.REPLANNING_FEATURE_SUBGRAPH)
|
||||
self.assertEqual(counters, {"requirements_spec": 1, "write_feature_plan": 1})
|
||||
self.assertEqual(len(feedback), 1)
|
||||
|
||||
def test_completion_result_reports_frozen_checklist(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.adapters.artifact_store import FileArtifactStore
|
||||
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.action_handlers import ActionCommandHandler
|
||||
from app.cad_agent.application.llm_contracts import CompiledRequirementsSpec, MarkdownDocument
|
||||
from app.cad_agent.application.requirements import RequirementsCommandHandler
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.state import TaskPhase, transition
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
def runtime_settings(root: Path) -> Settings:
|
||||
provider = ProviderConfig("test", "Test", "https://test.invalid/v1", "key", (ProviderModel("test-model"),))
|
||||
return Settings(
|
||||
task_root=root / "tasks", conversation_root=root / "conversations",
|
||||
library_root=ROOT / "backend" / "cdsl_library", engine_root=ROOT / "backend" / "engine" / "cdsl_engine",
|
||||
llm_base_url=provider.base_url, llm_api_key=provider.api_key, llm_model="test-model", llm_timeout_s=1,
|
||||
default_provider_id="test", providers=(provider,),
|
||||
)
|
||||
|
||||
|
||||
def contract() -> dict[str, object]:
|
||||
return {
|
||||
"requirements": [
|
||||
{
|
||||
"requirement_id": "req_001",
|
||||
"acceptance_claims": [
|
||||
{"claim_id": "claim_base", "verification_mode": "deterministic"},
|
||||
{"claim_id": "claim_visual", "verification_mode": "visual"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_002",
|
||||
"acceptance_claims": [{"claim_id": "claim_bore", "verification_mode": "deterministic"}],
|
||||
},
|
||||
{
|
||||
"requirement_id": "req_003",
|
||||
"acceptance_claims": [{"claim_id": "claim_pattern", "verification_mode": "deterministic"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def initial_plan() -> FeaturePlan:
|
||||
return FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1",
|
||||
"parent_plan_hash": "",
|
||||
"replaces_node_ids": [],
|
||||
"nodes": [
|
||||
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_base"], "expected_change": "One base solid."},
|
||||
{"node_id": "bore", "priority": 20, "intent": "Cut the bore.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
|
||||
{"node_id": "pattern", "priority": 30, "intent": "Add the pattern.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "Mounting holes."},
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
|
||||
|
||||
class FeaturePlanTests(unittest.TestCase):
|
||||
def test_plan_requires_exact_claim_ownership(self) -> None:
|
||||
plan = initial_plan()
|
||||
self.assertEqual(validate_feature_plan(plan, contract(), {"extrude_add_blind", "hole_blind"}), [])
|
||||
broken = plan.model_copy(deep=True)
|
||||
broken.nodes[1].claim_ids = ["claim_base"]
|
||||
messages = [item["message"] for item in validate_feature_plan(broken, contract(), {"extrude_add_blind", "hole_blind"})]
|
||||
self.assertTrue(any("already owned" in message for message in messages))
|
||||
self.assertTrue(any("has no owner" in message for message in messages))
|
||||
|
||||
def test_visual_claim_repeated_on_a_node_is_removed_before_persisting_the_plan(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(), atomic_ids=lambda: ("extrude_add_blind", "hole_blind"))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a part.")
|
||||
artifacts.initialize_task(task_id, "Create a part.")
|
||||
self.assertIsInstance(handler.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a part."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(handler.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] A base and a visual edge treatment."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "visual", "expected": {"description": "A visible edge treatment."}},
|
||||
]}]})
|
||||
self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
submitted = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One base solid."}],
|
||||
"final_claim_ids": ["claim_002"],
|
||||
})
|
||||
accepted = handler.submit_feature_plan(task_id, submitted, invocation_id="plan")
|
||||
self.assertIsInstance(accepted, Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
persisted = artifacts.read_json(task_id, state.feature_plan_path)
|
||||
self.assertEqual(persisted["nodes"][0]["claim_ids"], ["claim_001"])
|
||||
self.assertEqual(persisted["final_claim_ids"], ["claim_002"])
|
||||
|
||||
def test_unowned_global_health_claim_is_bound_to_the_unique_root_add_feature(self) -> None:
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [
|
||||
{"node_id": "base", "priority": 10, "intent": "Create the base.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_bore"], "expected_change": "One base solid."},
|
||||
{"node_id": "finish", "priority": 20, "intent": "Finish the part.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_pattern"], "expected_change": "One bore."},
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
health_contract = contract()
|
||||
health_contract["requirements"][0]["acceptance_claims"][0]["claim_kind"] = "single_connected_body"
|
||||
normalized = RequirementsCommandHandler._assign_unowned_global_health_claims(plan, health_contract)
|
||||
self.assertEqual(normalized.nodes[0].claim_ids, ["claim_bore", "claim_base"])
|
||||
self.assertEqual(validate_feature_plan(normalized, health_contract, {"extrude_add_blind", "hole_blind"}), [])
|
||||
|
||||
def test_scheduler_uses_ready_nodes_and_fixed_priority(self) -> None:
|
||||
plan = initial_plan()
|
||||
scheduler = FeatureScheduler(plan, [])
|
||||
self.assertEqual(scheduler.next_ready().node_id, "base")
|
||||
base = plan.nodes[0]
|
||||
events = [{
|
||||
"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base),
|
||||
"feature_id": "feature_001", "revision_id": "rev_001",
|
||||
}]
|
||||
scheduler = FeatureScheduler(plan, events)
|
||||
self.assertEqual(scheduler.statuses()["base"], "done")
|
||||
# Both bore and pattern are ready; priority decides deterministically.
|
||||
self.assertEqual(scheduler.next_ready().node_id, "bore")
|
||||
self.assertEqual(scheduler.feature_ids(), {"base": "feature_001"})
|
||||
|
||||
def test_revision_cannot_change_done_node_and_replaces_failed_subgraph(self) -> None:
|
||||
previous = initial_plan()
|
||||
base = previous.nodes[0]
|
||||
bore = previous.nodes[1]
|
||||
events = [
|
||||
{"event": "feature_node_verified", "node_id": "base", "node_hash": node_hash(base), "feature_id": "feature_001", "revision_id": "rev_001"},
|
||||
{"event": "feature_node_failed", "node_id": "bore", "node_hash": node_hash(bore), "failure_class": "engine_build", "attempt": 2, "terminal": True},
|
||||
]
|
||||
completed = FeatureScheduler(previous, events).completed_node_hashes()
|
||||
revision = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1",
|
||||
"parent_plan_hash": plan_hash(previous),
|
||||
"replaces_node_ids": ["bore"],
|
||||
"nodes": [
|
||||
base.model_dump(mode="json"),
|
||||
{"node_id": "bore_revised", "priority": 20, "intent": "Cut the bore with revised operation.", "atomic_id": "hole_blind", "depends_on": ["base"], "claim_ids": ["claim_bore"], "expected_change": "One through bore."},
|
||||
previous.nodes[2].model_dump(mode="json"),
|
||||
],
|
||||
"final_claim_ids": ["claim_visual"],
|
||||
})
|
||||
self.assertEqual(
|
||||
validate_feature_plan(revision, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"}),
|
||||
[],
|
||||
)
|
||||
changed = revision.model_copy(deep=True)
|
||||
changed.nodes[0].intent = "Changed completed node."
|
||||
self.assertTrue(any("completed node 'base' was modified" in item["message"] for item in validate_feature_plan(changed, contract(), {"extrude_add_blind", "hole_blind"}, previous_plan=previous, completed_node_hashes=completed, required_replacements={"bore"})))
|
||||
|
||||
def test_materialized_feature_uses_direct_dag_dependencies_not_previous_history_item(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "dag_test",
|
||||
"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": "reference_plane", "params": {"plane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}}, "depends_on": ["feature_001"]},
|
||||
],
|
||||
}
|
||||
contract = runtime.operation_contract("reference_axis")
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
{"feature": {"atomic_id": "reference_axis", "params": {"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}}},
|
||||
contract,
|
||||
{},
|
||||
runtime.reference_tokens(base),
|
||||
depends_on_feature_ids=("feature_001",),
|
||||
)
|
||||
self.assertEqual(document["features"][-1]["depends_on"], ["feature_001"])
|
||||
self.assertEqual(audit["depends_on_feature_ids"], ["feature_001"])
|
||||
|
||||
def test_required_through_cut_gets_a_server_recorded_exit_allowance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
runtime = ProfileCadRuntime(runtime_settings(Path(temporary)))
|
||||
base = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "through_cut",
|
||||
"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"}],
|
||||
}
|
||||
document, audit = runtime.materialize_fragment(
|
||||
base,
|
||||
{
|
||||
"sketch": {
|
||||
"workplane": {"origin_mm": [0, 0, 5], "normal": [0, 0, 1], "x_dir": [1, 0, 0]},
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 2},
|
||||
},
|
||||
"feature": {"atomic_id": "extrude_cut_blind", "params": {"distance_mm": 5, "reverse": True}},
|
||||
},
|
||||
runtime.operation_contract("extrude_cut_blind"),
|
||||
{"body": {"kind": "body", "geometry": {"bbox_mm": [-10, -10, 0, 10, 10, 5]}}},
|
||||
runtime.reference_tokens(base),
|
||||
require_through=True,
|
||||
depends_on_feature_ids=("feature_001",),
|
||||
)
|
||||
self.assertAlmostEqual(document["features"][-1]["params"]["distance_mm"], 5.01)
|
||||
self.assertEqual(audit["server_normalizations"][0]["submitted_mm"], 5.0)
|
||||
self.assertEqual(audit["server_normalizations"][0]["reason"], "required through-cut exit allowance")
|
||||
|
||||
def test_verified_node_publishes_without_a_render_bundle_or_candidate_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
registry = default_registry()
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, registry, atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, RegistryVerifierExecutor(registry))
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One connected disk with 20 mm diameter and 5 mm thickness."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "axial_span_mm": 5, "tolerance_mm": 0.1}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.SCHEDULING_FEATURE)
|
||||
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
|
||||
result = actions.submit_feature_fragment(task_id, {
|
||||
"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}},
|
||||
}, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Accepted)
|
||||
state = repository.get_state(task_id)
|
||||
self.assertEqual(state.phase, TaskPhase.SCHEDULING_FEATURE)
|
||||
self.assertEqual(state.active_revision, "rev_001")
|
||||
self.assertFalse((artifacts.task_dir(task_id) / "revisions" / "rev_001" / "renders").exists())
|
||||
events = repository.ledger_events(task_id)
|
||||
self.assertTrue(any(event.get("event") == "feature_node_verified" for event in events))
|
||||
self.assertFalse(any(event.get("event") == "candidate_built" for event in events))
|
||||
final_gate = actions.schedule_next_feature(task_id)
|
||||
self.assertIsInstance(final_gate, Accepted)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FINAL_VALIDATION)
|
||||
self.assertTrue(any(event.get("event") == "feature_plan_complete" for event in repository.ledger_events(task_id)))
|
||||
|
||||
def test_feature_plan_schema_binds_plan_revision_lineage_to_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
scheduled = transition(repository.get_state(task_id), "feature_scheduled")
|
||||
self.assertTrue(repository.compare_and_swap(scheduled))
|
||||
replanning = transition(repository.get_state(task_id), "feature_replan")
|
||||
self.assertTrue(repository.compare_and_swap(replanning))
|
||||
schema = requirements.feature_plan_schema(task_id)
|
||||
properties = schema["properties"]
|
||||
self.assertEqual(properties["parent_plan_hash"]["enum"], [plan_hash(plan)])
|
||||
self.assertEqual(properties["replaces_node_ids"]["minItems"], 0)
|
||||
self.assertEqual(properties["replaces_node_ids"]["maxItems"], 0)
|
||||
node_schema = schema["$defs"]["FeatureNode"]["properties"]
|
||||
self.assertEqual(node_schema["claim_ids"]["items"], {"enum": ["claim_001"]})
|
||||
self.assertEqual(properties["final_claim_ids"]["items"], {"enum": []})
|
||||
|
||||
def test_pending_assigned_claim_rejects_the_node_checkpoint(self) -> None:
|
||||
class PendingAssignedClaimVerifier:
|
||||
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": "pending" if claim.get("claim_id") == "claim_002" else "pass",
|
||||
"evidence": {"reason": "target geometry has not been introduced"},
|
||||
}
|
||||
for claim in claims
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
runtime = ProfileCadRuntime(runtime_settings(root))
|
||||
repository = SqliteTaskRepository(root / "state.sqlite3")
|
||||
artifacts = FileArtifactStore(root / "tasks")
|
||||
requirements = RequirementsCommandHandler(repository, artifacts, default_registry(), atomic_ids=runtime.supported_atomic_ids)
|
||||
actions = ActionCommandHandler(repository, artifacts, runtime, PendingAssignedClaimVerifier())
|
||||
task_id = "cad_123456abcdef"
|
||||
repository.create_task(task_id, "Create a disk.")
|
||||
artifacts.initialize_task(task_id, "Create a disk.")
|
||||
self.assertIsInstance(requirements.submit_requirements_document(task_id, MarkdownDocument(markdown="Create a disk."), invocation_id="requirements"), Accepted)
|
||||
self.assertIsInstance(requirements.submit_completion_target(task_id, MarkdownDocument(markdown="- [ ] One disk."), invocation_id="target"), Accepted)
|
||||
compiled = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [
|
||||
{"claim_kind": "single_connected_body", "expected": {}},
|
||||
{"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "tolerance_mm": 0.1}},
|
||||
]}]})
|
||||
self.assertIsInstance(requirements.submit_compiled_spec(task_id, compiled, invocation_id="compile"), Accepted)
|
||||
plan = FeaturePlan.model_validate({
|
||||
"schema_version": "cad.v3.2.feature-plan.v1", "parent_plan_hash": "", "replaces_node_ids": [],
|
||||
"nodes": [{"node_id": "base", "priority": 10, "intent": "Create disk.", "atomic_id": "extrude_add_blind", "depends_on": [], "claim_ids": ["claim_001", "claim_002"], "expected_change": "One disk solid."}],
|
||||
"final_claim_ids": [],
|
||||
})
|
||||
self.assertIsInstance(requirements.submit_feature_plan(task_id, plan, invocation_id="plan"), Accepted)
|
||||
self.assertIsInstance(actions.schedule_next_feature(task_id), Accepted)
|
||||
result = actions.submit_feature_fragment(task_id, {
|
||||
"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}},
|
||||
}, invocation_id="fragment")
|
||||
self.assertIsInstance(result, Rejected)
|
||||
self.assertEqual(repository.get_state(task_id).phase, TaskPhase.FEATURE_PENDING)
|
||||
events = repository.ledger_events(task_id)
|
||||
failure = next(event for event in reversed(events) if event.get("event") == "feature_node_failed")
|
||||
self.assertEqual(failure["failure_class"], "node_validation")
|
||||
self.assertTrue(any(item.get("claim_id") == "claim_002" and item.get("status") == "pending" for item in failure["blockers"]))
|
||||
self.assertFalse(any(event.get("event") == "feature_node_verified" for event in events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import argparse, json
|
||||
from pathlib import Path
|
||||
from .pipeline import load_samples, run_stage, scan, select_samples
|
||||
from .reports import generate_reports, read_json
|
||||
from .reports import generate_markdown_report, generate_reports, read_json
|
||||
|
||||
DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test")
|
||||
DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output")
|
||||
@@ -25,6 +25,8 @@ def _parser() -> argparse.ArgumentParser:
|
||||
command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp")
|
||||
command.add_argument("--force", action="store_true")
|
||||
command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)")
|
||||
if name == "report":
|
||||
command.add_argument("--markdown", action="store_true", help="also write full_run_report.md")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -35,6 +37,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif args.command == "report":
|
||||
manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()]
|
||||
result = generate_reports(args.output, records)
|
||||
if args.markdown:
|
||||
command_text = "python -m cadfs_to_cdsl report --markdown"
|
||||
result = {**result, "markdown_report": str(generate_markdown_report(args.output, records, input_root=args.input, command=command_text))}
|
||||
else:
|
||||
if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8")
|
||||
samples = select_samples(load_samples(args.input, args.output), sample_ids=args.sample_id, offset=args.offset, limit=args.limit, seed=args.seed)
|
||||
|
||||
+112
-26
@@ -24,6 +24,11 @@ class LoweringResult:
|
||||
history: list[dict[str, Any]]
|
||||
|
||||
|
||||
class UnsupportedCapability(ValueError):
|
||||
def __init__(self, capability: str, message: str):
|
||||
super().__init__(message); self.capability = capability
|
||||
|
||||
|
||||
def plain(value: Any) -> Any:
|
||||
if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line}
|
||||
if isinstance(value, list): return [plain(item) for item in value]
|
||||
@@ -38,7 +43,7 @@ def _bool(value: Any) -> bool:
|
||||
def _number(value: Any, units: bool = False) -> float:
|
||||
if isinstance(value, (float, int)): return float(value)
|
||||
if isinstance(value, str):
|
||||
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8}
|
||||
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8, "degree": 1.}
|
||||
if value in constants: return constants[value]
|
||||
return float(value)
|
||||
if isinstance(value, Call) and value.name == "__binary__":
|
||||
@@ -71,19 +76,40 @@ def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]:
|
||||
return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]}
|
||||
|
||||
|
||||
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = 0.0) -> dict[str, Any]:
|
||||
shifted = _shift_plane(plane, distance)
|
||||
return {**shifted, "normal": [normal_sign * value for value in plane["normal"]]}
|
||||
|
||||
|
||||
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
for call in walk_calls(value):
|
||||
if call.name in {"makeId", "qCreatedBy"}:
|
||||
text = " ".join(symbolic_string(arg) for arg in call.args)
|
||||
for name, plane in PLANES.items():
|
||||
if f"{name}.planeOp" in text: return dict(plane)
|
||||
query = parse_query(value)
|
||||
if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source:
|
||||
return dict(sketch_by_source[query.source_sketch]["workplane"])
|
||||
frame = feature_frames.get(query.owner_feature or "")
|
||||
if frame:
|
||||
if frame and query.topology_type == "CAP_FACE":
|
||||
return dict(frame["start" if query.is_start is not False else "end"])
|
||||
if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls:
|
||||
return dict(frame["start"])
|
||||
raise ValueError("unsupported or unresolved sketch workplane")
|
||||
|
||||
|
||||
def _bound_name(value: Any) -> str:
|
||||
return str(value or "BLIND").split(".")[-1].upper()
|
||||
|
||||
|
||||
def _end_condition(value: Any) -> dict[str, Any]:
|
||||
name = _bound_name(value)
|
||||
if name == "BLIND": return {"type": "blind", "solidworks_code": 0}
|
||||
if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8}
|
||||
if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1}
|
||||
raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}")
|
||||
|
||||
|
||||
def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]:
|
||||
ax, ay = start; bx, by = mid; cx, cy = end
|
||||
d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by))
|
||||
@@ -134,7 +160,10 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An
|
||||
if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction:
|
||||
profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]}
|
||||
else:
|
||||
contours, construction = _contours(segments); construction.extend(explicit_construction)
|
||||
contours, open_segments = _contours(segments)
|
||||
if open_segments:
|
||||
raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)")
|
||||
construction = list(explicit_construction)
|
||||
if not contours:
|
||||
profile = {"type": "analytic_contours", "contours": [], "construction": construction}
|
||||
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
|
||||
@@ -164,6 +193,13 @@ def _source_sketch(params: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _profile_query_kind(params: dict[str, Any]) -> str | None:
|
||||
for key in ("entities", "sheetProfilesArray"):
|
||||
if key in params:
|
||||
return parse_query(params[key]).topology_type
|
||||
return None
|
||||
|
||||
|
||||
def _profile_executable(sketch: dict[str, Any]) -> bool:
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") == "circle": return True
|
||||
@@ -188,7 +224,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
if isinstance(step, SketchIR):
|
||||
history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]})
|
||||
try:
|
||||
plane = _plane_from_query(step.workplane, feature_frames)
|
||||
plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source)
|
||||
lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||||
except Exception as exc:
|
||||
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
|
||||
@@ -200,20 +236,52 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
try:
|
||||
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
|
||||
if item.operation == "cPlane":
|
||||
base = _plane_from_query(p.get("entities"), feature_frames); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
|
||||
plane_type = str(p.get("cplaneType") or "OFFSET").split(".")[-1].upper()
|
||||
if plane_type != "OFFSET":
|
||||
raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter only supports exact OFFSET reference planes, not {plane_type}")
|
||||
base = _plane_from_query(p.get("entities"), feature_frames, sketch_by_source); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"}
|
||||
feature_frames[item.feature_id] = {"start": plane, "end": plane}
|
||||
elif item.operation == "extrude":
|
||||
if p.get("surfaceOperationType") is not None:
|
||||
raise UnsupportedCapability("extrude_surface_or_mixed", "current CDSL engine has no exact surface or mixed solid/surface extrusion operation")
|
||||
profile_kind = _profile_query_kind(p)
|
||||
if profile_kind and profile_kind not in {"IMPRINT"}:
|
||||
raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}")
|
||||
source = _source_sketch(p)
|
||||
if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved")
|
||||
if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile")
|
||||
depth = _number(p.get("depth"), True); operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection"))
|
||||
atomic = "extrude_cut_blind" if any(x in operation for x in ("REMOVE", "CUT")) else "extrude_add_two_sided" if _bool(p.get("hasSecondDirection")) else "extrude_add_blind"
|
||||
params = {"distance_mm": depth, "reverse": reverse}
|
||||
if atomic == "extrude_add_two_sided": params["reverse_distance_mm"] = _number(p.get("secondDirectionDepth", depth), True)
|
||||
end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound"))
|
||||
depth_value = p.get("depth"); depth = _number(depth_value, True) if depth_value is not None else 1.0
|
||||
operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT"))
|
||||
second = _bool(p.get("hasSecondDirection"))
|
||||
if cutting and (second or end["type"] != "blind"):
|
||||
capability = "extrude_cut_two_sided" if second or end["type"] == "mid_plane" else f"extrude_cut_{end['type']}"
|
||||
raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation")
|
||||
if not cutting and not second and end["type"] not in {"blind", "mid_plane"}:
|
||||
capability = f"extrude_add_{end['type']}"
|
||||
raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation")
|
||||
if second:
|
||||
atomic = "extrude_add_two_sided"; params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
|
||||
reverse_depth = _number(p.get("secondDirectionDepth", depth), True)
|
||||
params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": _end_condition(p.get("secondDirectionBound"))})
|
||||
elif end["type"] == "mid_plane" and not cutting:
|
||||
blind = _end_condition("BLIND")
|
||||
atomic = "extrude_add_two_sided"; params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)}
|
||||
else:
|
||||
atomic = "extrude_cut_blind" if cutting else "extrude_add_blind"
|
||||
params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"}
|
||||
plane = sketch_by_source[source]["workplane"]; feature_frames[item.feature_id] = {"start": plane, "end": _shift_plane(plane, -depth if reverse else depth)}
|
||||
plane = sketch_by_source[source]["workplane"]
|
||||
if end["type"] == "blind" and not second:
|
||||
direction = -1 if reverse else 1
|
||||
feature_frames[item.feature_id] = {"start": dict(plane), "end": _shift_plane(plane, direction * depth)}
|
||||
elif end["type"] == "mid_plane":
|
||||
direction = -1 if reverse else 1
|
||||
feature_frames[item.feature_id] = {"start": _shift_plane(plane, -direction * depth / 2), "end": _shift_plane(plane, direction * depth / 2)}
|
||||
elif item.operation == "revolve":
|
||||
if p.get("surfaceOperationType") is not None and p.get("operationType") is None:
|
||||
raise UnsupportedCapability("revolve_surface", "current CDSL engine has no exact surface-revolve operation")
|
||||
source = _source_sketch(p)
|
||||
if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved")
|
||||
if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile")
|
||||
@@ -223,17 +291,24 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction]
|
||||
operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add"
|
||||
full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0))
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
|
||||
elif item.operation in {"fillet", "chamfer"}:
|
||||
key = "radius" if item.operation == "fillet" else "width"; amount = _number(p.get(key), True); selectors = []
|
||||
key = "radius" if item.operation == "fillet" else "width"
|
||||
chamfer_type = str(p.get("chamferType") or "EQUAL_OFFSETS").split(".")[-1].upper()
|
||||
amount_value = p.get(key)
|
||||
if item.operation == "chamfer" and chamfer_type == "TWO_OFFSETS": amount_value = p.get("width1")
|
||||
amount = _number(amount_value, True); selectors = []
|
||||
for index, query_value in enumerate(_queries(p.get("entities"))):
|
||||
query = parse_query(query_value); owner = query.owner_feature
|
||||
if not owner: raise ValueError("selector owner is unresolved")
|
||||
selector_kind = "face" if query.kind in {"face", "entitytype.face"} or query.topology_type in {"CAP_FACE", "SWEPT_FACE"} else "edge"
|
||||
refs = _source_refs(query_value)
|
||||
source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
|
||||
geometry: dict[str, Any] = {}
|
||||
frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"]
|
||||
if source_entity and cap:
|
||||
if selector_kind == "face" and query.topology_type == "CAP_FACE" and cap:
|
||||
geometry = {"normal": cap["normal"], "plane_offset_mm": sum(cap["normal"][i] * cap["origin_mm"][i] for i in range(3))}
|
||||
if selector_kind == "edge" and source_entity and cap:
|
||||
if query.topology_type == "SWEPT_EDGE" and frame:
|
||||
local_point = source_entity.get("point") if source_entity["type"] == "point" else None
|
||||
if len(refs) >= 2:
|
||||
@@ -244,20 +319,28 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
start, end = _global(frame["start"], local_point), _global(frame["end"], local_point)
|
||||
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
|
||||
elif source_entity["type"] == "circle":
|
||||
# OCC/build123d commonly splits a closed circular edge into four
|
||||
# quarter-circle records. Bind all four deterministic arc centres.
|
||||
radius = source_entity["radius_mm"]; center = source_entity["center"]
|
||||
for quadrant, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))):
|
||||
local = [center[0] + sx*radius/math.sqrt(2), center[1] + sy*radius/math.sqrt(2)]
|
||||
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}_{quadrant}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"curve_type": "circle", "center_mm": _global(cap, local)}})
|
||||
# Keep the source circle signature until the prefix has been
|
||||
# rebuilt. OCC may expose it as one edge or several arcs.
|
||||
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}})
|
||||
continue
|
||||
elif source_entity["type"] == "line":
|
||||
start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"])
|
||||
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
|
||||
if not geometry: raise ValueError("selector geometry is unresolved")
|
||||
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
|
||||
if selector_kind == "face" and not geometry:
|
||||
raise ValueError(f"{query.topology_type or 'face'} selector geometry is unresolved")
|
||||
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
|
||||
params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount}
|
||||
if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation"))
|
||||
elif chamfer_type == "TWO_OFFSETS":
|
||||
second = _number(p.get("width2"), True)
|
||||
if _bool(p.get("oppositeDirection")): params["distance_mm"], second = second, params["distance_mm"]
|
||||
params["distance_2_mm"] = second
|
||||
elif chamfer_type == "OFFSET_ANGLE":
|
||||
angle = math.radians(_number(p.get("angle")))
|
||||
if _bool(p.get("oppositeDirection")):
|
||||
second = amount * math.tan(angle); params["distance_mm"] = second; params["distance_2_mm"] = amount
|
||||
else: params["angle_rad"] = angle
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"}
|
||||
elif item.operation == "hole":
|
||||
locations = _queries(p.get("locations")); positions = []; host_plane = None
|
||||
@@ -274,11 +357,12 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
depth_value = p.get("holeDepth") or p.get("tappedDepth")
|
||||
if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved")
|
||||
depth = _number(depth_value, True) if depth_value is not None else 1.0
|
||||
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": 1}, "positions": positions, "host_face": {"frame": frame}}
|
||||
if "COUNTERSINK" in style.upper():
|
||||
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or 90.0))}
|
||||
if "COUNTERBORE" in style.upper():
|
||||
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth"), True)}
|
||||
condition_code = {"blind": 0, "through_all": 1, "through_all_both": 2}[condition]
|
||||
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}}
|
||||
if style.upper() in {"COUNTERSINK", "C_SINK"}:
|
||||
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("cSinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or p.get("cSinkAngle") or 90.0))}
|
||||
if style.upper() in {"COUNTERBORE", "C_BORE"}:
|
||||
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)}
|
||||
if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"}
|
||||
elif item.operation == "mirror":
|
||||
@@ -304,6 +388,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
else:
|
||||
raise ValueError(f"operation mapping not implemented: {item.operation}")
|
||||
features.append(feature); previous.append(fid)
|
||||
except UnsupportedCapability as exc:
|
||||
diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
|
||||
except Exception as exc:
|
||||
diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
|
||||
if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history)
|
||||
|
||||
+21
-10
@@ -11,7 +11,7 @@ from .lowering import lower_model
|
||||
from .rebuild import rebuild_candidate
|
||||
from .reports import generate_reports, read_json, write_json, write_manifest
|
||||
|
||||
PIPELINE_VERSION = "cadfs_to_cdsl.v1"
|
||||
PIPELINE_VERSION = "cadfs_to_cdsl.v2"
|
||||
|
||||
|
||||
def _fingerprint(sample: Sample) -> str:
|
||||
@@ -55,6 +55,9 @@ def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[st
|
||||
cached = read_json(status_path)
|
||||
if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"):
|
||||
return cached
|
||||
if force:
|
||||
for name in ("candidate.cdsl.json", "bound.cdsl.json", "rebuild.step", "rebuild.json", "rebuild.worker.json", "comparison.json", "comparison.worker.json"):
|
||||
(directory / name).unlink(missing_ok=True)
|
||||
diagnostics = list(sample.diagnostics)
|
||||
try:
|
||||
feature_path = Path(sample.files["featurescript"])
|
||||
@@ -85,7 +88,7 @@ def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
|
||||
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
|
||||
|
||||
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> bool:
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str:
|
||||
result_path.unlink(missing_ok=True)
|
||||
context = multiprocessing.get_context("spawn")
|
||||
process = context.Process(target=target, args=args)
|
||||
@@ -93,8 +96,8 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec
|
||||
if process.is_alive():
|
||||
process.terminate(); process.join(5)
|
||||
if process.is_alive(): process.kill(); process.join()
|
||||
return False
|
||||
return process.exitcode == 0 and result_path.exists()
|
||||
return "timeout"
|
||||
return "completed" if process.exitcode == 0 and result_path.exists() else "failed"
|
||||
|
||||
|
||||
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
|
||||
@@ -105,11 +108,16 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
|
||||
if not force and rebuild_path.exists() and status.get("rebuild_status"):
|
||||
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
|
||||
worker_result = directory / "rebuild.worker.json"
|
||||
completed = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
|
||||
if completed: result = read_json(worker_result); worker_result.unlink(missing_ok=True)
|
||||
outcome = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
|
||||
if outcome == "completed":
|
||||
result = read_json(worker_result); worker_result.unlink(missing_ok=True)
|
||||
bound_cdsl = result.pop("bound_cdsl", None)
|
||||
if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl)
|
||||
else:
|
||||
step_path.unlink(missing_ok=True)
|
||||
result = {"status": "rebuild_timeout", "error": {"type": "TimeoutError", "message": f"rebuild exceeded {timeout_seconds:g} seconds"}}
|
||||
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
|
||||
message = f"rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "rebuild worker exited without a result"
|
||||
result = {"status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed", "error": {"type": error_type, "message": message}}
|
||||
write_json(rebuild_path, result)
|
||||
status["rebuild_status"] = result["status"]; status["status"] = result["status"]
|
||||
write_json(status_path, status); return status
|
||||
@@ -123,10 +131,13 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
|
||||
if not force and status.get("status") in {"comparison_failed", "comparison_timeout"}: return status
|
||||
if force or not comparison_path.exists():
|
||||
worker_result = directory / "comparison.worker.json"
|
||||
completed = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
|
||||
if completed: comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
|
||||
outcome = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
|
||||
if outcome == "completed": comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
|
||||
else:
|
||||
status["status"] = "comparison_timeout"; status["comparison_error"] = {"type": "TimeoutError", "message": f"comparison exceeded {timeout_seconds:g} seconds"}; write_json(status_path, status); return status
|
||||
status["status"] = "comparison_timeout" if outcome == "timeout" else "comparison_failed"
|
||||
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
|
||||
message = f"comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "comparison worker exited without a result"
|
||||
status["comparison_error"] = {"type": error_type, "message": message}; write_json(status_path, status); return status
|
||||
else: comparison = read_json(comparison_path)
|
||||
status["comparison_decision"] = comparison["decision"]
|
||||
accepted = comparison[compare_mode]["passed"]
|
||||
|
||||
@@ -6,13 +6,15 @@ from typing import Any
|
||||
|
||||
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
|
||||
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
||||
from .selector_binding import bind_candidate_selectors
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
|
||||
if not analysis.runtime_eligible:
|
||||
return {"status": "runtime_ineligible", "analysis": analysis_dict}
|
||||
try:
|
||||
result = rebuild_cdsl(cdsl, output, strict=True)
|
||||
return {"status": "rebuilt", "analysis": analysis_dict, "result": result}
|
||||
bound, binding = bind_candidate_selectors(cdsl)
|
||||
result = rebuild_cdsl(bound, output, strict=True)
|
||||
return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result}
|
||||
except Exception as exc:
|
||||
detail = {"type": type(exc).__name__, "message": str(exc)}
|
||||
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
|
||||
|
||||
+314
-2
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv, json
|
||||
import csv, json, re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -31,7 +32,8 @@ def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, A
|
||||
if diagnostics_path.exists():
|
||||
for diagnostic in read_json(diagnostics_path):
|
||||
reasons[str(diagnostic.get("code") or "unknown")] += 1
|
||||
if diagnostic.get("operation"): gaps[str(diagnostic["operation"])].append(str(item["sample_id"]))
|
||||
gap = diagnostic.get("capability") or diagnostic.get("operation")
|
||||
if gap: gaps[str(gap)].append(str(item["sample_id"]))
|
||||
history_path = sample_dir / "history.json"
|
||||
if history_path.exists():
|
||||
for step in read_json(history_path): operations[str(step.get("operation") or "unknown")] += 1
|
||||
@@ -48,3 +50,313 @@ def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, A
|
||||
with (output / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=["sample_id", "decision", "bbox_max_delta_mm", "volume_relative_error", "surface_area_relative_error"]); writer.writeheader(); writer.writerows(rows)
|
||||
return summary
|
||||
|
||||
|
||||
def _pct(count: int, total: int) -> str:
|
||||
return "0.00%" if total <= 0 else f"{count / total * 100:.2f}%"
|
||||
|
||||
|
||||
def _table(headers: list[str], rows: list[list[Any]]) -> list[str]:
|
||||
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
|
||||
for row in rows:
|
||||
lines.append("| " + " | ".join(str(value).replace("\n", " ") for value in row) + " |")
|
||||
return lines
|
||||
|
||||
|
||||
def _read_optional_json(path: Path) -> Any | None:
|
||||
return read_json(path) if path.exists() else None
|
||||
|
||||
|
||||
def _normalize_error(message: str) -> str:
|
||||
message = re.sub(r"/Users/[^ ]+", "<path>", message)
|
||||
message = re.sub(r"0x[0-9a-fA-F]+", "0x...", message)
|
||||
message = re.sub(r"\d+\.\d{4,}", "<float>", message)
|
||||
return message[:180] if len(message) > 180 else message
|
||||
|
||||
|
||||
def generate_markdown_report(
|
||||
output: Path,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
input_root: Path | None = None,
|
||||
command: str | None = None,
|
||||
report_name: str = "full_run_report.md",
|
||||
) -> Path:
|
||||
summary = generate_reports(output, records)
|
||||
total = len(records)
|
||||
sample_root = output / "samples"
|
||||
status_records = []
|
||||
modality_missing: Counter[str] = Counter()
|
||||
alignment_fallbacks = 0
|
||||
dataset_index = _read_optional_json(output / "dataset_index.json") or {}
|
||||
indexed_records = {str(item.get("sample_id")): item for item in dataset_index.get("records") or []}
|
||||
for record in records:
|
||||
indexed = indexed_records.get(str(record["sample_id"]), {})
|
||||
for diagnostic in indexed.get("diagnostics") or record.get("diagnostics") or []:
|
||||
if diagnostic.get("code") == "missing_modality":
|
||||
modality_missing[str(diagnostic.get("modality") or "unknown")] += 1
|
||||
if diagnostic.get("code") == "alignment_fallback":
|
||||
alignment_fallbacks += 1
|
||||
status = _read_optional_json(sample_root / str(record["sample_id"]) / "status.json") or record
|
||||
status_records.append(status)
|
||||
|
||||
statuses = Counter(str(item.get("status") or "unknown") for item in status_records)
|
||||
conversion_statuses = Counter(str(item.get("conversion_status") or "missing") for item in status_records)
|
||||
file_counts = {
|
||||
"candidate.cdsl.json": sum(1 for _ in sample_root.glob("*/candidate.cdsl.json")),
|
||||
"bound.cdsl.json": sum(1 for _ in sample_root.glob("*/bound.cdsl.json")),
|
||||
"rebuild.step": sum(1 for _ in sample_root.glob("*/rebuild.step")),
|
||||
"comparison.json": sum(1 for _ in sample_root.glob("*/comparison.json")),
|
||||
"status.json": sum(1 for _ in sample_root.glob("*/status.json")),
|
||||
}
|
||||
|
||||
comparison_decisions: Counter[str] = Counter()
|
||||
strict_pass = 0
|
||||
rp_pass = 0
|
||||
comparison_failures: Counter[str] = Counter()
|
||||
comparison_errors: Counter[str] = Counter()
|
||||
comparison_error_examples: dict[str, list[str]] = defaultdict(list)
|
||||
metric_rows = []
|
||||
for status in status_records:
|
||||
error = status.get("comparison_error") or {}
|
||||
if error:
|
||||
key = f"{error.get('type') or 'Error'}: {_normalize_error(str(error.get('message') or ''))}"
|
||||
comparison_errors[key] += 1
|
||||
if len(comparison_error_examples[key]) < 5:
|
||||
comparison_error_examples[key].append(str(status.get("sample_id") or "unknown"))
|
||||
for path in sample_root.glob("*/comparison.json"):
|
||||
comparison = _read_optional_json(path)
|
||||
if not comparison:
|
||||
continue
|
||||
sample_id = path.parent.name
|
||||
comparison_decisions[str(comparison.get("decision") or "unknown")] += 1
|
||||
strict_pass += 1 if ((comparison.get("strict") or {}).get("passed")) else 0
|
||||
rp_pass += 1 if ((comparison.get("rp") or {}).get("passed")) else 0
|
||||
for reason in ((comparison.get("raw") or {}).get("failure_reasons") or []):
|
||||
comparison_failures[str(reason)] += 1
|
||||
metrics = ((comparison.get("raw") or {}).get("metrics") or {})
|
||||
metric_rows.append((
|
||||
sample_id,
|
||||
comparison.get("decision"),
|
||||
metrics.get("bbox_max_delta_mm"),
|
||||
metrics.get("volume_relative_error"),
|
||||
metrics.get("surface_area_relative_error"),
|
||||
))
|
||||
|
||||
rebuild_statuses: Counter[str] = Counter()
|
||||
rebuild_errors: Counter[str] = Counter()
|
||||
rebuild_error_examples: dict[str, list[str]] = defaultdict(list)
|
||||
for path in sample_root.glob("*/rebuild.json"):
|
||||
rebuild = _read_optional_json(path)
|
||||
if not rebuild:
|
||||
continue
|
||||
rebuild_statuses[str(rebuild.get("status") or "unknown")] += 1
|
||||
error = rebuild.get("error") or {}
|
||||
if error:
|
||||
key = f"{error.get('type') or 'Error'}: {_normalize_error(str(error.get('message') or ''))}"
|
||||
rebuild_errors[key] += 1
|
||||
if len(rebuild_error_examples[key]) < 5:
|
||||
rebuild_error_examples[key].append(path.parent.name)
|
||||
|
||||
diagnostic_counts: Counter[str] = Counter()
|
||||
diagnostic_examples: dict[str, list[str]] = defaultdict(list)
|
||||
capability_examples: dict[str, list[str]] = defaultdict(list)
|
||||
capability_source: dict[str, str] = {}
|
||||
for record in records:
|
||||
sample_id = str(record["sample_id"])
|
||||
diagnostics = _read_optional_json(sample_root / sample_id / "diagnostics.json") or []
|
||||
for diagnostic in diagnostics:
|
||||
code = str(diagnostic.get("code") or "unknown")
|
||||
diagnostic_counts[code] += 1
|
||||
if len(diagnostic_examples[code]) < 8:
|
||||
diagnostic_examples[code].append(sample_id)
|
||||
capability = diagnostic.get("capability") or diagnostic.get("operation")
|
||||
if capability:
|
||||
capability = str(capability)
|
||||
if len(capability_examples[capability]) < 10:
|
||||
capability_examples[capability].append(sample_id)
|
||||
capability_source.setdefault(capability, str(diagnostic.get("operation") or capability))
|
||||
|
||||
gap_payload = _read_optional_json(output / "capability_gaps.json") or {}
|
||||
top_gaps = sorted(
|
||||
((name, int(value.get("sample_count") or 0), ", ".join((value.get("sample_ids") or [])[:8])) for name, value in gap_payload.items()),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
|
||||
unsupported_ops = {
|
||||
"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern",
|
||||
"moveFace", "replaceFace", "deleteFace", "import", "derive",
|
||||
}
|
||||
exact_mappings = {
|
||||
"extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind",
|
||||
"revolve": "revolve_add / revolve_cut",
|
||||
"fillet": "fillet",
|
||||
"chamfer": "chamfer",
|
||||
"hole": "hole_wizard",
|
||||
"mirror": "pattern_mirror",
|
||||
"cPlane": "reference_plane (OFFSET only)",
|
||||
}
|
||||
try:
|
||||
from engine.cdsl_engine.runtime import EXECUTORS
|
||||
engine_atomic_ids = sorted(EXECUTORS)
|
||||
except Exception:
|
||||
engine_atomic_ids = []
|
||||
|
||||
operation_rows = sorted(
|
||||
((name, count) for name, count in (summary.get("operation_counts") or {}).items()),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
status_rows = [[name, count, _pct(count, total)] for name, count in sorted(statuses.items(), key=lambda item: (-item[1], item[0]))]
|
||||
conversion_rows = [[name, count, _pct(count, total)] for name, count in sorted(conversion_statuses.items(), key=lambda item: (-item[1], item[0]))]
|
||||
|
||||
lines: list[str] = [
|
||||
"# CADFS full conversion report",
|
||||
"",
|
||||
f"- Generated at: {datetime.now().isoformat(timespec='seconds')}",
|
||||
f"- Input: `{input_root}`" if input_root else "- Input: not recorded",
|
||||
f"- Output: `{output}`",
|
||||
f"- Command: `{command}`" if command else "- Command: not recorded",
|
||||
f"- Total samples: {total}",
|
||||
f"- Manifest rows: {sum(1 for _ in (output / 'manifest.jsonl').open(encoding='utf-8')) if (output / 'manifest.jsonl').exists() else 'missing'}",
|
||||
"",
|
||||
"## Acceptance summary",
|
||||
"",
|
||||
f"- RP accepted samples: {rp_pass} ({_pct(rp_pass, total)})",
|
||||
f"- Strict accepted samples: {strict_pass} ({_pct(strict_pass, total)})",
|
||||
f"- Rebuilt STEP files present: {file_counts['rebuild.step']}",
|
||||
f"- Comparison reports present: {file_counts['comparison.json']}",
|
||||
f"- Candidate CDSL files present: {file_counts['candidate.cdsl.json']}",
|
||||
f"- Bound CDSL files present: {file_counts['bound.cdsl.json']}",
|
||||
"",
|
||||
"## Final statuses",
|
||||
"",
|
||||
*_table(["Status", "Count", "Share"], status_rows),
|
||||
"",
|
||||
"## Conversion statuses",
|
||||
"",
|
||||
*_table(["Conversion status", "Count", "Share"], conversion_rows),
|
||||
"",
|
||||
"## Modality and alignment",
|
||||
"",
|
||||
]
|
||||
if modality_missing:
|
||||
lines.extend(_table(["Missing modality", "Count"], sorted(modality_missing.items())))
|
||||
else:
|
||||
lines.append("- No missing local modalities were recorded in the manifest.")
|
||||
lines.extend(["", f"- JSONL content alignment fallbacks: {alignment_fallbacks}", ""])
|
||||
|
||||
lines.extend([
|
||||
"## Comparison results",
|
||||
"",
|
||||
*_table(["Decision", "Count"], sorted(comparison_decisions.items(), key=lambda item: (-item[1], item[0]))),
|
||||
"",
|
||||
"Top strict/RP comparison failure checks:",
|
||||
"",
|
||||
])
|
||||
if comparison_failures:
|
||||
lines.extend(_table(["Failure check", "Count"], sorted(comparison_failures.items(), key=lambda item: (-item[1], item[0]))[:12]))
|
||||
else:
|
||||
lines.append("- No comparison failure checks were recorded.")
|
||||
lines.extend(["", "Comparison worker errors:", ""])
|
||||
if comparison_errors:
|
||||
rows = [[name, count, ", ".join(comparison_error_examples[name])] for name, count in sorted(comparison_errors.items(), key=lambda item: (-item[1], item[0]))[:12]]
|
||||
lines.extend(_table(["Error", "Count", "Examples"], rows))
|
||||
else:
|
||||
lines.append("- No comparison worker errors were recorded.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Rebuild outcomes",
|
||||
"",
|
||||
*_table(["Rebuild status", "Count"], sorted(rebuild_statuses.items(), key=lambda item: (-item[1], item[0]))),
|
||||
"",
|
||||
"Top rebuild/runtime errors:",
|
||||
"",
|
||||
])
|
||||
if rebuild_errors:
|
||||
rows = [[name, count, ", ".join(rebuild_error_examples[name])] for name, count in sorted(rebuild_errors.items(), key=lambda item: (-item[1], item[0]))[:12]]
|
||||
lines.extend(_table(["Error", "Count", "Examples"], rows))
|
||||
else:
|
||||
lines.append("- No rebuild errors were recorded.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Diagnostics",
|
||||
"",
|
||||
*_table(
|
||||
["Diagnostic code", "Count", "Example samples"],
|
||||
[[name, count, ", ".join(diagnostic_examples[name])] for name, count in sorted(diagnostic_counts.items(), key=lambda item: (-item[1], item[0]))],
|
||||
),
|
||||
"",
|
||||
"## Capability gaps",
|
||||
"",
|
||||
*_table(["Capability", "Affected samples", "Example samples"], top_gaps[:25]),
|
||||
"",
|
||||
"## FeatureScript operation counts",
|
||||
"",
|
||||
*_table(["Operation", "Occurrences"], operation_rows),
|
||||
"",
|
||||
"## Exact mapping policy",
|
||||
"",
|
||||
"- The converter keeps FeatureScript operation identity in `history.json` and diagnostics.",
|
||||
"- Unsupported operations are not rewritten as substitute atomics.",
|
||||
"- Parameters are statically evaluated from FeatureScript only; STEP geometry is not used to infer or tune CDSL parameters.",
|
||||
"",
|
||||
*_table(["FeatureScript operation", "CDSL atomic policy"], sorted(exact_mappings.items())),
|
||||
"",
|
||||
"Known unsupported FeatureScript operations recorded as capability gaps:",
|
||||
"",
|
||||
", ".join(sorted(unsupported_ops)),
|
||||
"",
|
||||
"Engine executor atomic IDs:",
|
||||
"",
|
||||
", ".join(engine_atomic_ids) if engine_atomic_ids else "Unable to import engine executor registry while generating this report.",
|
||||
"",
|
||||
"## Regression check",
|
||||
"",
|
||||
])
|
||||
regression = _read_optional_json(sample_root / "00000173" / "comparison.json")
|
||||
if regression:
|
||||
metrics = ((regression.get("raw") or {}).get("metrics") or {})
|
||||
lines.extend([
|
||||
"- Sample `00000173` decision: `" + str(regression.get("decision")) + "`",
|
||||
"- RP passed: `" + str((regression.get("rp") or {}).get("passed")) + "`, strict passed: `" + str((regression.get("strict") or {}).get("passed")) + "`",
|
||||
f"- BBox max delta: `{metrics.get('bbox_max_delta_mm')}` mm",
|
||||
f"- Volume relative error: `{metrics.get('volume_relative_error')}`",
|
||||
f"- Surface area relative error: `{metrics.get('surface_area_relative_error')}`",
|
||||
"- This is consistent with the known CADFS RP radius quantization case: FeatureScript uses 9.53 mm while the source STEP is about 9.525 mm.",
|
||||
])
|
||||
else:
|
||||
lines.append("- Sample `00000173` has no comparison report.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Output locations",
|
||||
"",
|
||||
f"- Per-sample artifacts: `{sample_root}/<sample_id>/`",
|
||||
f"- Manifest: `{output / 'manifest.jsonl'}`",
|
||||
f"- Summary JSON: `{output / 'summary.json'}`",
|
||||
f"- Capability gaps JSON: `{output / 'capability_gaps.json'}`",
|
||||
f"- Unsupported capabilities Markdown: `{output / 'unsupported_capabilities.md'}`",
|
||||
f"- Comparison CSV: `{output / 'comparison_summary.csv'}`",
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
"- The source CADFS directory was treated as read-only by the pipeline.",
|
||||
"- `workers=1` was used for OCC stability and reproducibility.",
|
||||
"- Accepted samples require a generated CDSL candidate, rebuilt STEP, and comparison report.",
|
||||
"- Deferred or rejected samples retain evidence in `diagnostics.json`, `history.json`, `rebuild.json`, or `comparison.json`.",
|
||||
"",
|
||||
])
|
||||
if metric_rows:
|
||||
worst_bbox = sorted(metric_rows, key=lambda item: (item[2] is None, item[2] or 0), reverse=True)[:5]
|
||||
worst_volume = sorted(metric_rows, key=lambda item: (item[3] is None, item[3] or 0), reverse=True)[:5]
|
||||
lines.extend(["## Largest observed comparison deltas", "", "BBox delta:", ""])
|
||||
lines.extend(_table(["Sample", "Decision", "BBox max delta mm", "Volume rel err", "Area rel err"], worst_bbox))
|
||||
lines.extend(["", "Volume relative error:", ""])
|
||||
lines.extend(_table(["Sample", "Decision", "BBox max delta mm", "Volume rel err", "Area rel err"], worst_volume))
|
||||
lines.append("")
|
||||
|
||||
report_path = output / report_name
|
||||
report_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -17,6 +20,11 @@ def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
|
||||
try: delta = abs(float(expected[key]) - float(actual[key]))
|
||||
except Exception: return None
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
if "bbox_mm" in expected:
|
||||
actual_box = actual.get("bbox_mm")
|
||||
if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None
|
||||
delta = max(abs(float(a) - float(b)) for a, b in zip(expected["bbox_mm"], actual_box))
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
return sum(scores) / len(scores) if scores else 0.0
|
||||
|
||||
|
||||
@@ -31,4 +39,69 @@ def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], re
|
||||
if not candidates: raise ValueError("selector_not_found")
|
||||
if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous")
|
||||
score, record = candidates[0]
|
||||
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "cadfs_featurescript", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
|
||||
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
|
||||
|
||||
|
||||
def _dot(left: list[float], right: list[float]) -> float: return sum(float(a)*float(b) for a, b in zip(left, right))
|
||||
|
||||
|
||||
def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
center = expected.get("source_circle_center_mm"); normal = expected.get("source_plane_normal"); radius = float(expected.get("source_circle_radius_mm") or 0)
|
||||
if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0: return []
|
||||
plane_offset = _dot(center, normal); matches = []
|
||||
for record in records:
|
||||
geometry = record.get("geometry") or {}
|
||||
if record.get("kind") != "edge" or geometry.get("curve_type") != "circle": continue
|
||||
points = [geometry.get("start_mm"), geometry.get("end_mm")]
|
||||
if not all(isinstance(point, list) and len(point) == 3 for point in points): continue
|
||||
if any(abs(_dot(point, normal) - plane_offset) > 0.05 for point in points): continue
|
||||
radial = []
|
||||
for point in points:
|
||||
delta = [float(point[i])-float(center[i]) for i in range(3)]; axial = _dot(delta, normal)
|
||||
radial.append(math.sqrt(max(0.0, sum(value*value for value in delta)-axial*axial)))
|
||||
if all(abs(value-radius) <= max(0.05, radius*1e-4) for value in radial): matches.append(record)
|
||||
return matches
|
||||
|
||||
|
||||
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Rebuild every selector-bearing prefix and bind against its active body."""
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
bound = deepcopy(cdsl); evidence = []
|
||||
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
|
||||
for index, feature in enumerate(bound.get("features") or []):
|
||||
placeholders = list(feature.get("selectors") or [])
|
||||
if not placeholders: continue
|
||||
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index]
|
||||
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
|
||||
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.step", strict=True)
|
||||
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
|
||||
records = [item for item in report.get("topology_records") or [] if not body_id or item.get("body_id") == body_id or str(item.get("body_id") or "").startswith(f"{body_id}:")]
|
||||
resolved = []
|
||||
for placeholder in placeholders:
|
||||
geometry = placeholder.get("geometry") or {}
|
||||
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
|
||||
if not candidates:
|
||||
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
|
||||
owner = placeholder.get("owner_feature_id")
|
||||
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
|
||||
pool = owner_matches or same_kind
|
||||
if not geometry and len(pool) != 1:
|
||||
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
||||
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
|
||||
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
|
||||
if scored:
|
||||
if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
|
||||
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
||||
candidates = [scored[0][1]]
|
||||
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
|
||||
for record in candidates:
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
|
||||
resolved.append({"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}})
|
||||
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values())
|
||||
if feature.get("atomic_id") == "pattern_mirror":
|
||||
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
|
||||
if len(planes) != 1:
|
||||
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
|
||||
feature.setdefault("params", {})["mirror_plane"] = planes[0]
|
||||
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"]})
|
||||
return bound, evidence
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from cadfs_to_cdsl.compare import compare_steps
|
||||
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
||||
from cadfs_to_cdsl.lowering import lower_model
|
||||
from cadfs_to_cdsl.rebuild import rebuild_candidate
|
||||
|
||||
|
||||
class IntegrationTests(unittest.TestCase):
|
||||
@@ -13,13 +14,31 @@ class IntegrationTests(unittest.TestCase):
|
||||
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
|
||||
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
|
||||
cdsl = lower_model(parse_featurescript(feature.read_text(), "00000173"), {}).cdsl
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rebuilt = Path(tmp) / "rebuild.step"; rebuild_cdsl(cdsl, rebuilt, strict=True)
|
||||
rebuilt = Path(tmp) / "rebuild.step"
|
||||
result = rebuild_candidate(cdsl, rebuilt)
|
||||
self.assertEqual(result["status"], "rebuilt")
|
||||
comparison = compare_steps(gold, rebuilt)
|
||||
self.assertFalse(comparison["strict"]["passed"])
|
||||
self.assertTrue(comparison["rp"]["passed"])
|
||||
self.assertEqual(comparison["decision"], "approximate_pass")
|
||||
|
||||
def test_counterbore_abbreviation_00002243(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0000/00002243.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00002243"), {})
|
||||
first = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
|
||||
self.assertEqual(first["atomic_id"], "extrude_add_two_sided")
|
||||
self.assertEqual(first["params"]["distance_mm"], 125.0)
|
||||
self.assertEqual(first["params"]["reverse_distance_mm"], 125.0)
|
||||
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F3")
|
||||
self.assertEqual(gap["code"], "unsupported_engine_capability")
|
||||
self.assertEqual(gap["capability"], "extrude_cut_two_sided")
|
||||
hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard")
|
||||
self.assertEqual(hole["params"]["hole_type"], "c_bore")
|
||||
self.assertEqual(hole["params"]["counterbore"]["diameter_mm"], 17.25)
|
||||
self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
||||
@@ -30,6 +30,36 @@ class LoweringTests(unittest.TestCase):
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation")
|
||||
|
||||
def test_symmetric_cut_is_not_disguised_as_blind_cut(self):
|
||||
source = SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.REMOVE, "depth":120 * mm, "symmetric":true')
|
||||
result = lower_model(parse_featurescript(source, "symmetric-cut"), {})
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
|
||||
self.assertEqual(result.diagnostics[0]["capability"], "extrude_cut_two_sided")
|
||||
|
||||
def test_open_nonconstruction_geometry_is_not_silently_dropped(self):
|
||||
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
|
||||
result = lower_model(parse_featurescript(source, "open-profile"), {})
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred")
|
||||
self.assertIn("open non-construction", result.diagnostics[0]["message"])
|
||||
|
||||
def test_surface_revolve_is_not_disguised_as_solid_revolve(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0095/00957738.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {})
|
||||
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F2")
|
||||
self.assertEqual(gap["capability"], "revolve_surface")
|
||||
self.assertIsNone(result.cdsl)
|
||||
|
||||
def test_feature_face_profile_is_not_reused_as_original_sketch(self):
|
||||
source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})')
|
||||
result = lower_model(parse_featurescript(source, "face-profile"), {})
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
|
||||
self.assertIn("extrude_profile_topology:cap_face", result.diagnostics[0]["capability"])
|
||||
|
||||
def test_conversion_writes_status_and_sidecars(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
|
||||
|
||||
@@ -395,7 +395,9 @@ button:disabled {
|
||||
.studio-main { display: flex; min-height: 0; flex: 1; }
|
||||
.task-documents { border-top: 1px solid var(--ui-border); max-height: 34vh; overflow: auto; background: var(--ui-panel-bg); }
|
||||
.task-checklist { padding: 8px 12px; border-bottom: 1px solid var(--ui-border); display: grid; gap: 5px; }
|
||||
.task-checklist-item { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 8px; font-size: 12px; line-height: 1.35; }
|
||||
.task-checklist-item { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; gap: 8px; font-size: 12px; line-height: 1.35; align-items: start; }
|
||||
.task-checklist-item small { display: block; color: var(--ui-muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||
.task-checklist-item button { min-height: 24px; padding: 2px 7px; font-size: 12px; }
|
||||
.task-checklist-item > span { color: var(--ui-muted); }
|
||||
.task-checklist-item.is-pass > span { color: #16784d; }
|
||||
.task-checklist-item.is-fail > span { color: #b73c32; }
|
||||
|
||||
@@ -561,7 +561,23 @@ function StudioShell({
|
||||
<div className="studio-main">
|
||||
<aside className="agent-pane">
|
||||
<AgentThread attachments={attachments} uploading={uploading} uploadError={uploadError} taskRunning={taskRunning} onUpload={onUpload} onCancel={onCancel} />
|
||||
<TaskDocuments task={taskRecord} />
|
||||
<TaskDocuments task={taskRecord} onSelectRevision={(revisionId) => {
|
||||
const revision = taskRecord?.revisions.find((item) => item.revision_id === revisionId);
|
||||
if (!revision?.cdsl_path || !revision.step_path || !revision.glb_path || !revision.report_path || !taskRecord) return;
|
||||
onCadResult({
|
||||
taskId: taskRecord.task_id,
|
||||
revisionId,
|
||||
cdslPath: revision.cdsl_path,
|
||||
stepPath: revision.step_path,
|
||||
glbPath: revision.glb_path,
|
||||
reportPath: revision.report_path,
|
||||
summary: revision.summary || "CDSL CAD model",
|
||||
referenceIds: revision.reference_ids || [],
|
||||
engine: revision.engine || "cdsl_only",
|
||||
checkpoint: revision.visibility === "checkpoint",
|
||||
lifecycle: taskRecord.lifecycle || "running",
|
||||
});
|
||||
}} />
|
||||
</aside>
|
||||
<section className="preview-pane">
|
||||
<CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onError={handleViewerError} onSelectionChange={onSelectionChange} />
|
||||
@@ -571,17 +587,26 @@ function StudioShell({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDocuments({ task }: { task: TaskRecord | null }) {
|
||||
function TaskDocuments({ task, onSelectRevision }: { task: TaskRecord | null; onSelectRevision: (revisionId: string) => void }) {
|
||||
const documents = [
|
||||
["需求文档", task?.requirements_markdown],
|
||||
["完成目标", task?.completion_target_markdown],
|
||||
["建模计划", task?.modeling_plan_markdown],
|
||||
] as const;
|
||||
if (!documents.some(([, markdown]) => markdown)) return null;
|
||||
if (!documents.some(([, markdown]) => markdown) && !task?.feature_nodes?.length) return null;
|
||||
return <section className="task-documents" aria-label="任务文档">
|
||||
{task?.checklist_progress?.length ? <div className="task-checklist" aria-label="验收进度">
|
||||
{task.checklist_progress.map((item) => <div key={item.requirement_id || item.statement} className={`task-checklist-item is-${item.status}`}><span>{item.status === "pass" ? "完成" : item.status === "fail" ? "未通过" : "待验证"}</span>{item.statement}</div>)}
|
||||
</div> : null}
|
||||
{task?.feature_nodes?.length ? <details open className="task-document" aria-label="特征 DAG">
|
||||
<summary>特征 DAG</summary>
|
||||
<div className="task-checklist">
|
||||
{task.feature_nodes.slice().sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)).map((node) => <div key={node.node_id} className={`task-checklist-item is-${node.status || "pending"}`}>
|
||||
<span>{node.status === "done" ? "完成" : node.status === "running" ? "执行中" : node.status === "failed" ? "失败" : node.status === "blocked" ? "阻塞" : "待执行"}</span>
|
||||
<div><strong>{node.intent || node.node_id}</strong><small>{node.atomic_id} · 优先级 {node.priority}{node.depends_on?.length ? ` · 依赖 ${node.depends_on.join(", ")}` : ""}{node.attempt ? ` · 尝试 ${node.attempt}` : ""}</small>{node.error ? <small>{node.error}</small> : null}</div>
|
||||
{node.status === "done" && node.revision_id ? <button type="button" title="查看此特征检查点" onClick={() => onSelectRevision(node.revision_id!)}>查看</button> : null}
|
||||
</div>)}
|
||||
</div>
|
||||
</details> : null}
|
||||
{documents.map(([title, markdown]) => markdown ? <details key={title} className="task-document"><summary>{title}</summary><MarkdownDocument>{markdown}</MarkdownDocument></details> : null)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@ export function activeCheckpointPreview(task: TaskRecord | null): CadResult | nu
|
||||
|
||||
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
|
||||
if (!task) return null;
|
||||
// Checkpoints are intentionally private after a failed run. Returning one
|
||||
// here makes the restored viewer request an artifact the backend denies.
|
||||
if (task.lifecycle === "failed" && !task.published_revision) return null;
|
||||
// v3.2 deliberately exposes its last verified checkpoint on a failed DAG:
|
||||
// failure means requirements were not completed, not that earlier geometry
|
||||
// should disappear. Legacy task projections retain the former policy.
|
||||
if (task.lifecycle === "failed" && !task.published_revision && task.schema_version !== "3.2") return null;
|
||||
const current =
|
||||
task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ??
|
||||
[...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint");
|
||||
|
||||
@@ -180,6 +180,21 @@ test("does not restore a private checkpoint after a failed run", () => {
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("restores the last verified v3.2 DAG checkpoint after a failed run", () => {
|
||||
const result = latestSuccessfulResult({
|
||||
schema_version: "3.2",
|
||||
task_id: "cad_abc",
|
||||
current_revision: "rev_002",
|
||||
active_revision: "rev_002",
|
||||
lifecycle: "failed",
|
||||
revisions: [
|
||||
{ revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" },
|
||||
],
|
||||
});
|
||||
assert.equal(result?.revisionId, "rev_002");
|
||||
assert.equal(result?.checkpoint, true);
|
||||
});
|
||||
|
||||
test("restores an active checkpoint only while the task is running", () => {
|
||||
const result = activeCheckpointPreview({
|
||||
task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running",
|
||||
|
||||
@@ -124,8 +124,32 @@ export type TaskRecord = {
|
||||
requirements_document_path?: string;
|
||||
completion_target_markdown?: string | null;
|
||||
completion_target_path?: string;
|
||||
modeling_plan_markdown?: string | null;
|
||||
modeling_plan_path?: string;
|
||||
feature_plan?: {
|
||||
schema_version?: string;
|
||||
parent_plan_hash?: string;
|
||||
replaces_node_ids?: string[];
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
final_claim_ids?: string[];
|
||||
} | null;
|
||||
feature_plan_path?: string;
|
||||
feature_plan_hash?: string;
|
||||
current_feature_node_id?: string;
|
||||
pending_feature?: { action_id: string; node_id: string; plan_hash: string; atomic_id: string; claim_ids: string[]; depends_on_node_ids: string[] } | null;
|
||||
feature_nodes?: Array<{
|
||||
node_id: string;
|
||||
intent?: string;
|
||||
atomic_id?: string;
|
||||
priority?: number;
|
||||
depends_on?: string[];
|
||||
claim_ids?: string[];
|
||||
status?: "pending" | "ready" | "running" | "done" | "failed" | "blocked" | "invalidated" | string;
|
||||
attempt?: number;
|
||||
failure_class?: string;
|
||||
error?: string;
|
||||
revision_id?: string;
|
||||
feature_id?: string;
|
||||
evidence?: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
completion_result_markdown?: string | null;
|
||||
completion_result_path?: string;
|
||||
claim_summary?: Array<{
|
||||
|
||||
@@ -129,6 +129,8 @@ def download_one(ref: PartStudioRef, authorization: str, raw_root: Path, *, time
|
||||
("tessellated_edges", part_studio_path(ref, "/tessellatededges"), "tessellated_edges.json", common),
|
||||
("shaded_views", part_studio_path(ref, "/shadedviews"), "shaded_views.json", {"viewMatrix": "front", "outputWidth": 512, "outputHeight": 512, "edges": "show", "showAllParts": "true"}),
|
||||
]
|
||||
if os.environ.get("ONSHAPE_FAST_STEP") == "1":
|
||||
resources = [item for item in resources if item[0] not in {"tessellated_faces", "tessellated_edges", "shaded_views"}]
|
||||
# These document and Part Studio evidence endpoints are independent. A
|
||||
# bounded fan-out keeps an interactive acquisition from being cut off
|
||||
# before it can reach the asynchronous STEP export, while preserving the
|
||||
@@ -136,12 +138,13 @@ def download_one(ref: PartStudioRef, authorization: str, raw_root: Path, *, time
|
||||
worker_count = max(1, min(16, int(os.environ.get("ONSHAPE_DOWNLOAD_WORKERS", "4"))))
|
||||
with ThreadPoolExecutor(max_workers=worker_count, thread_name_prefix="onshape-download") as executor:
|
||||
artifacts.extend(executor.map(lambda item: _save(client, root, *item), resources))
|
||||
artifacts.extend(_sketch_artifacts(client, root))
|
||||
artifacts.extend([
|
||||
_save(client, root, "parasolid", part_studio_path(ref, "/parasolid"), "model.x_t", {"version": "0", "includeExportIds": "true", "binaryExport": "false"}, "text/plain, application/octet-stream"),
|
||||
_save(client, root, "gltf", part_studio_path(ref, "/gltf"), "model.gltf", {**common, "outputSeparateFaceNodes": "true"}, "model/gltf+json, application/octet-stream"),
|
||||
_export_step(client, root, ref.sample_id, poll_seconds, poll_limit),
|
||||
])
|
||||
if os.environ.get("ONSHAPE_FAST_STEP") != "1":
|
||||
artifacts.extend(_sketch_artifacts(client, root))
|
||||
manifest = {"schema": "onshape_to_cdsl.raw_sample.v1", "source": ref.as_dict(), "resource_count": len(artifacts), "downloaded_count": sum(item.status in {"downloaded", "reused"} for item in artifacts), "unavailable_count": sum(item.status not in {"downloaded", "reused"} for item in artifacts), "resources": [asdict(item) for item in artifacts]}
|
||||
write_json(root / "manifest.json", manifest)
|
||||
return manifest
|
||||
|
||||
Reference in New Issue
Block a user