修改agent流程,分步构建cdsl
This commit is contained in:
+229
-2632
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model."""
|
||||
"""Append-only CDSL materialisation for the autonomous authoring loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,11 +7,13 @@ from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.services.generation_plan import GenerationPlanError
|
||||
|
||||
|
||||
class CdslFragmentError(ValueError):
|
||||
"""A node fragment cannot be applied safely to its declared base revision."""
|
||||
"""A fragment cannot safely be applied to the current CDSL state."""
|
||||
|
||||
|
||||
class AutonomousFragmentError(CdslFragmentError):
|
||||
"""A free-form candidate violates the autonomous append-only boundary."""
|
||||
|
||||
|
||||
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
|
||||
@@ -19,212 +21,8 @@ def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
|
||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _items(value: Any, field: str) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise CdslFragmentError(f"{field} must be an array of objects")
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _ids(items: list[dict[str, Any]], field: str) -> list[str]:
|
||||
values = [str(item.get("id") or "").strip() for item in items]
|
||||
if not all(values) or len(values) != len(set(values)):
|
||||
raise CdslFragmentError(f"{field} must have unique non-empty ids")
|
||||
return values
|
||||
|
||||
|
||||
def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
|
||||
if node is None:
|
||||
raise CdslFragmentError(f"Fragment references an unknown node: {node_id}")
|
||||
return node
|
||||
|
||||
|
||||
def _single_output_id(node: dict[str, Any], field: str) -> str:
|
||||
values = [str(item) for item in node.get(field) or () if str(item)]
|
||||
if len(values) != 1:
|
||||
raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output")
|
||||
return values[0]
|
||||
|
||||
|
||||
def _node_feature_id(plan: dict[str, Any], node_id: str) -> str:
|
||||
return _single_output_id(_node(plan, node_id), "cdsl_feature_ids")
|
||||
|
||||
|
||||
def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any:
|
||||
"""Translate fragment-only node references into CDSL feature references."""
|
||||
if isinstance(value, list):
|
||||
return [_materialize_node_references(item, plan) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()}
|
||||
owner_node_id = materialized.pop("owner_node_id", None)
|
||||
if owner_node_id is not None:
|
||||
if not isinstance(owner_node_id, str) or not owner_node_id.strip():
|
||||
raise CdslFragmentError("owner_node_id must be a non-empty plan node id")
|
||||
materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip())
|
||||
source_node_ids = materialized.pop("source_node_ids", None)
|
||||
if source_node_ids is not None:
|
||||
if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids):
|
||||
raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids")
|
||||
materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids]
|
||||
return materialized
|
||||
|
||||
|
||||
def _selector_references(value: Any) -> list[dict[str, Any]]:
|
||||
"""Collect selector-shaped objects from feature selectors and params."""
|
||||
found: list[dict[str, Any]] = []
|
||||
if isinstance(value, dict):
|
||||
if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value):
|
||||
found.append(value)
|
||||
for child in value.values():
|
||||
found.extend(_selector_references(child))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
found.extend(_selector_references(child))
|
||||
return found
|
||||
|
||||
|
||||
def validate_fragment(
|
||||
fragment: dict[str, Any],
|
||||
*,
|
||||
plan: dict[str, Any],
|
||||
node_id: str,
|
||||
base_revision_id: str,
|
||||
base_cdsl: dict[str, Any] | None,
|
||||
required_snapshot_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(fragment, dict):
|
||||
raise CdslFragmentError("CDSL fragment must be an object")
|
||||
version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1")
|
||||
if version != "cad.cdsl-fragment.v1":
|
||||
raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}")
|
||||
declared_node_id = str(fragment.get("node_id") or "")
|
||||
if declared_node_id != node_id:
|
||||
raise CdslFragmentError("Fragment node_id does not match the active node")
|
||||
if str(fragment.get("base_revision_id") or "") != base_revision_id:
|
||||
raise CdslFragmentError("Fragment base_revision_id does not match the active revision")
|
||||
if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl):
|
||||
raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL")
|
||||
snapshot_id = str(fragment.get("required_snapshot_id") or "")
|
||||
if required_snapshot_id and snapshot_id != required_snapshot_id:
|
||||
raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot")
|
||||
if not required_snapshot_id and snapshot_id:
|
||||
raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists")
|
||||
sketches = _items(fragment.get("add_sketches"), "add_sketches")
|
||||
features = _items(fragment.get("add_features"), "add_features")
|
||||
node = _node(plan, node_id)
|
||||
expected = {str(item) for item in node.get("cdsl_feature_ids") or ()}
|
||||
expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()}
|
||||
if len(expected) != 1:
|
||||
# Compatibility path for pre-incremental plans which could own more
|
||||
# than one CDSL feature in a single node.
|
||||
feature_ids = _ids(features, "add_features")
|
||||
if set(feature_ids) != expected:
|
||||
raise CdslFragmentError(
|
||||
f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}"
|
||||
)
|
||||
elif len(features) != 1:
|
||||
raise CdslFragmentError("An atomic plan node must generate exactly one feature")
|
||||
if len(expected_sketches) > 1:
|
||||
sketch_ids = _ids(sketches, "add_sketches")
|
||||
if set(sketch_ids) != expected_sketches:
|
||||
raise CdslFragmentError(
|
||||
f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}"
|
||||
)
|
||||
elif len(sketches) != len(expected_sketches):
|
||||
expected_description = "one" if expected_sketches else "no"
|
||||
raise CdslFragmentError(f"This node requires {expected_description} new sketch")
|
||||
|
||||
# The planner and fragment author never own CDSL object identities. The
|
||||
# merger writes node-derived IDs and dependencies after it has validated
|
||||
# the current immutable plan.
|
||||
if len(expected) == 1:
|
||||
features[0]["id"] = next(iter(expected))
|
||||
if len(expected_sketches) == 1:
|
||||
sketch_id = next(iter(expected_sketches))
|
||||
sketches[0]["id"] = sketch_id
|
||||
features[0]["sketch_id"] = sketch_id
|
||||
elif not expected_sketches and len(features) == 1:
|
||||
features[0].pop("sketch_id", None)
|
||||
features = _materialize_node_references(features, plan)
|
||||
sketches = _materialize_node_references(sketches, plan)
|
||||
sketch_ids = _ids(sketches, "add_sketches")
|
||||
feature_ids = _ids(features, "add_features")
|
||||
atomic_id = str(node.get("atomic_id") or "")
|
||||
if len(expected) == 1:
|
||||
features[0]["atomic_id"] = atomic_id
|
||||
elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features):
|
||||
raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}")
|
||||
base_sketch_ids = {
|
||||
str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or ()
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
base_feature_ids = {
|
||||
str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict)
|
||||
}
|
||||
if base_sketch_ids & set(sketch_ids):
|
||||
raise CdslFragmentError("Fragment attempts to overwrite an existing sketch")
|
||||
if base_feature_ids & set(feature_ids):
|
||||
raise CdslFragmentError("Fragment attempts to overwrite a frozen feature")
|
||||
predecessor_features = {
|
||||
feature_id
|
||||
for dependency in node.get("depends_on") or ()
|
||||
for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ())
|
||||
}
|
||||
if len(expected) == 1:
|
||||
features[0]["depends_on"] = sorted(predecessor_features)
|
||||
available_features = base_feature_ids | set(feature_ids)
|
||||
available_sketches = base_sketch_ids | set(sketch_ids)
|
||||
for feature in features:
|
||||
feature_id = str(feature["id"])
|
||||
dependencies = feature.get("depends_on") or []
|
||||
if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies):
|
||||
raise CdslFragmentError(f"Feature {feature_id} has a missing dependency")
|
||||
sketch_id = str(feature.get("sketch_id") or "")
|
||||
if sketch_id and sketch_id not in available_sketches:
|
||||
raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch")
|
||||
if required_snapshot_id:
|
||||
selectors = [selector for feature in features for selector in _selector_references(feature)]
|
||||
if not selectors:
|
||||
raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors")
|
||||
for selector in selectors:
|
||||
if str(selector.get("snapshot_id") or "") != required_snapshot_id:
|
||||
raise CdslFragmentError("Topology selector must reference the active snapshot")
|
||||
if not str(selector.get("owner_feature_id") or ""):
|
||||
raise CdslFragmentError("Topology selector must declare owner_feature_id")
|
||||
if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]:
|
||||
raise CdslFragmentError("Topology selector must declare a non-empty geometry signature")
|
||||
declared_dependencies = {
|
||||
str(dependency)
|
||||
for feature in features
|
||||
for dependency in feature.get("depends_on") or ()
|
||||
}
|
||||
if not predecessor_features.issubset(declared_dependencies):
|
||||
raise CdslFragmentError("Fragment does not preserve all plan-node dependencies")
|
||||
rules = fragment.get("verification_rules") or []
|
||||
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||
raise CdslFragmentError("verification_rules must be an array of objects")
|
||||
assumptions = fragment.get("assumptions") or []
|
||||
if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
|
||||
raise CdslFragmentError("assumptions must be an array of strings")
|
||||
return {
|
||||
"schema_version": "cad.cdsl-fragment.v1",
|
||||
"node_id": node_id,
|
||||
"base_revision_id": base_revision_id,
|
||||
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
|
||||
"required_snapshot_id": snapshot_id,
|
||||
"add_sketches": sketches,
|
||||
"add_features": features,
|
||||
"expected_feature_ids": sorted(expected),
|
||||
"verification_rules": deepcopy(rules),
|
||||
"assumptions": [item.strip() for item in assumptions if item.strip()],
|
||||
}
|
||||
|
||||
|
||||
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the complete, append-only document that the runtime must rebuild."""
|
||||
"""Create the complete CDSL document that will be rebuilt from scratch."""
|
||||
if base_cdsl is None:
|
||||
document: dict[str, Any] = {
|
||||
"schema": "cad.cdsl.llm.v1",
|
||||
@@ -237,25 +35,22 @@ def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, A
|
||||
else:
|
||||
document = deepcopy(base_cdsl)
|
||||
geometry = document.setdefault("geometry", {})
|
||||
if not isinstance(geometry, dict):
|
||||
raise CdslFragmentError("Base CDSL geometry must be an object")
|
||||
sketches = geometry.setdefault("sketches", [])
|
||||
sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None
|
||||
features = document.setdefault("features", [])
|
||||
if not isinstance(sketches, list) or not isinstance(features, list):
|
||||
raise CdslFragmentError("Base CDSL has invalid collections")
|
||||
raise CdslFragmentError("Base CDSL has invalid geometry collections")
|
||||
sketches.extend(deepcopy(fragment["add_sketches"]))
|
||||
features.extend(deepcopy(fragment["add_features"]))
|
||||
return document
|
||||
|
||||
|
||||
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
|
||||
"""Persist the runtime's actual selector choices as auditable node evidence."""
|
||||
"""Persist runtime selector choices as build evidence."""
|
||||
values = []
|
||||
for resolution in engine_result.get("selector_resolution") or ():
|
||||
if not isinstance(resolution, dict):
|
||||
continue
|
||||
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
|
||||
candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else []
|
||||
values.append({
|
||||
"consumer_node_id": node_id,
|
||||
"consumer_feature_id": str(resolution.get("feature_id") or ""),
|
||||
@@ -265,8 +60,422 @@ def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_i
|
||||
"stable_id": str(selector.get("stable_id") or ""),
|
||||
"geometry": deepcopy(selector.get("geometry") or {}),
|
||||
"status": str(resolution.get("status") or ""),
|
||||
"candidates": deepcopy(list(candidates)),
|
||||
"candidates": deepcopy(list(resolution.get("candidates") or [])),
|
||||
"score": resolution.get("score"),
|
||||
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
|
||||
})
|
||||
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
|
||||
|
||||
|
||||
def _autonomous_id(prefix: str, used: set[str]) -> str:
|
||||
index = 1
|
||||
while True:
|
||||
candidate = f"{prefix}_{index:03d}"
|
||||
if candidate not in used:
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def autonomous_selector_tokens(snapshot: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
||||
"""Make opaque, revision-scoped selector tokens from executable topology."""
|
||||
if not isinstance(snapshot, dict):
|
||||
return {}
|
||||
snapshot_id = str(snapshot.get("snapshot_id") or "")
|
||||
if not snapshot_id:
|
||||
return {}
|
||||
values: dict[str, dict[str, Any]] = {}
|
||||
for record in snapshot.get("records") or ():
|
||||
if not isinstance(record, dict) or not record.get("executable"):
|
||||
continue
|
||||
record_id = str(record.get("record_id") or "")
|
||||
kind = str(record.get("kind") or "")
|
||||
if not record_id or kind not in {"face", "edge", "vertex", "plane", "axis", "body"}:
|
||||
continue
|
||||
token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16]
|
||||
geometry = deepcopy(record.get("geometry") or {})
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""]
|
||||
values[token] = {
|
||||
"token": token,
|
||||
"kind": kind,
|
||||
"geometry": geometry,
|
||||
"selector": {
|
||||
"kind": kind,
|
||||
"stable_id": record_id,
|
||||
"owner_feature_id": str(owners[0] or ""),
|
||||
"geometry": geometry,
|
||||
"source": "runtime_snapshot",
|
||||
"snapshot_id": snapshot_id,
|
||||
"confidence": 1.0,
|
||||
},
|
||||
}
|
||||
return values
|
||||
|
||||
|
||||
def _compact_prompt_geometry(geometry: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep only selector-choice facts useful to an author model.
|
||||
|
||||
Runtime snapshots also carry adjacency signatures, curve endpoints and
|
||||
other diagnostic detail. Those fields are required for deterministic
|
||||
engine work, but repeatedly placing them in an LLM prompt is expensive
|
||||
and does not help choose an opaque token. Full records remain available
|
||||
on disk and through targeted measurement tools.
|
||||
"""
|
||||
useful = (
|
||||
"bbox_mm", "center_mm", "normal", "plane_normal", "plane_offset_mm",
|
||||
"surface_type", "curve_type", "radius_mm", "length_mm", "area_mm2",
|
||||
"volume_mm3", "solid_count",
|
||||
)
|
||||
return {key: deepcopy(geometry[key]) for key in useful if key in geometry}
|
||||
|
||||
|
||||
def autonomous_candidate_prompt_tokens(tokens: dict[str, dict[str, Any]], *, kind: str = "") -> list[dict[str, Any]]:
|
||||
"""Return compact safe token fields, never persistent selector IDs."""
|
||||
return [
|
||||
{"token": token, "kind": value["kind"], "geometry": _compact_prompt_geometry(value["geometry"])}
|
||||
for token, value in sorted(tokens.items())
|
||||
if not kind or value["kind"] == kind
|
||||
]
|
||||
|
||||
|
||||
def _fragment_lists(fragment: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
if not isinstance(fragment, dict):
|
||||
raise AutonomousFragmentError("fragment_json must decode to a JSON object")
|
||||
# Ordinary tool-call providers occasionally lift the unambiguous selector
|
||||
# array one level out of a single-feature payload. Accept that shorthand
|
||||
# only when it can be moved to exactly one feature without choosing or
|
||||
# changing any selector ourselves.
|
||||
allowed = {"sketch", "feature", "sketches", "features", "add_sketches", "add_features", "selector_tokens", "revolve_axis"}
|
||||
unknown = sorted(set(fragment) - allowed)
|
||||
if unknown:
|
||||
raise AutonomousFragmentError("fragment_json may contain only sketch(es) and feature(s); unexpected: " + ", ".join(unknown))
|
||||
sketches_raw = fragment.get("sketches", fragment.get("add_sketches", []))
|
||||
features_raw = fragment.get("features", fragment.get("add_features", []))
|
||||
if "sketch" in fragment:
|
||||
if sketches_raw:
|
||||
raise AutonomousFragmentError("Use either sketch or sketches, not both")
|
||||
sketches_raw = [fragment["sketch"]]
|
||||
if "feature" in fragment:
|
||||
if features_raw:
|
||||
raise AutonomousFragmentError("Use either feature or features, not both")
|
||||
features_raw = [fragment["feature"]]
|
||||
if not isinstance(sketches_raw, list) or not all(isinstance(item, dict) for item in sketches_raw):
|
||||
raise AutonomousFragmentError("fragment sketches must be an array of objects")
|
||||
if not isinstance(features_raw, list) or not features_raw or not all(isinstance(item, dict) for item in features_raw):
|
||||
raise AutonomousFragmentError("fragment features must be a non-empty array of objects")
|
||||
sketches = deepcopy(sketches_raw)
|
||||
features = deepcopy(features_raw)
|
||||
top_level_tokens = fragment.get("selector_tokens")
|
||||
if top_level_tokens is not None:
|
||||
if len(features) != 1:
|
||||
raise AutonomousFragmentError("top-level selector_tokens are allowed only with exactly one feature")
|
||||
if "selector_tokens" in features[0]:
|
||||
raise AutonomousFragmentError("selector_tokens must appear either at fragment top level or feature level, not both")
|
||||
features[0]["selector_tokens"] = deepcopy(top_level_tokens)
|
||||
return sketches, features
|
||||
|
||||
|
||||
def _move_equivalent_field(
|
||||
value: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
target: str,
|
||||
location: str,
|
||||
fixes: list[dict[str, str]],
|
||||
) -> None:
|
||||
"""Move a lossless spelling alias without choosing any CAD geometry."""
|
||||
if source not in value:
|
||||
return
|
||||
if target in value:
|
||||
if value[source] != value[target]:
|
||||
raise AutonomousFragmentError(
|
||||
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and {target} were supplied with different values"
|
||||
)
|
||||
value.pop(source)
|
||||
fixes.append({"path": location, "from": source, "to": target, "action": "deduplicated_equivalent"})
|
||||
return
|
||||
value[target] = value.pop(source)
|
||||
fixes.append({"path": location, "from": source, "to": target, "action": "renamed_equivalent"})
|
||||
|
||||
|
||||
def _move_axis_component(
|
||||
params: dict[str, Any],
|
||||
axis: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
target: str,
|
||||
location: str,
|
||||
fixes: list[dict[str, str]],
|
||||
) -> None:
|
||||
"""Move an explicit top-level axis alias into the canonical axis object."""
|
||||
if source not in params:
|
||||
return
|
||||
value = params.pop(source)
|
||||
if target in axis and axis[target] != value:
|
||||
raise AutonomousFragmentError(
|
||||
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and axis.{target} were supplied with different values"
|
||||
)
|
||||
if target in axis:
|
||||
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "deduplicated_equivalent"})
|
||||
return
|
||||
axis[target] = value
|
||||
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "renamed_equivalent"})
|
||||
|
||||
|
||||
def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
|
||||
"""Normalize only lossless aliases used inside an already explicit axis."""
|
||||
for source in ("origin", "point_mm", "axis_origin_mm", "axis_point_mm"):
|
||||
_move_equivalent_field(axis, source=source, target="origin_mm", location=location, fixes=fixes)
|
||||
for source in ("axis_dir", "axis_direction", "dir"):
|
||||
_move_equivalent_field(axis, source=source, target="direction", location=location, fixes=fixes)
|
||||
|
||||
|
||||
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
|
||||
"""Accept the common one-feature/one-sketch nesting without choosing geometry.
|
||||
|
||||
The public fragment grammar owns one ordered sketch list and one ordered
|
||||
feature list. Some tool-call models naturally nest each sketch below its
|
||||
feature. That representation is losslessly transformable only when every
|
||||
feature supplies exactly one sketch and no root sketch collection exists.
|
||||
"""
|
||||
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
|
||||
return
|
||||
features = fragment.get("features", fragment.get("add_features"))
|
||||
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
|
||||
return
|
||||
lifted: list[dict[str, Any]] = []
|
||||
for index, feature in enumerate(features):
|
||||
nested = feature.get("sketches", feature.get("sketch"))
|
||||
if isinstance(nested, dict):
|
||||
sketches = [nested]
|
||||
elif isinstance(nested, list):
|
||||
sketches = nested
|
||||
else:
|
||||
return
|
||||
if len(sketches) != 1 or not isinstance(sketches[0], dict):
|
||||
return
|
||||
feature.pop("sketch", None)
|
||||
feature.pop("sketches", None)
|
||||
lifted.append(sketches[0])
|
||||
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
|
||||
fragment["sketches"] = lifted
|
||||
|
||||
|
||||
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
|
||||
"""Normalize only explicitly equivalent author spellings.
|
||||
|
||||
Normalization is deliberately narrow. It accepts common CAD vocabulary
|
||||
where the target runtime field has identical units and meaning, while
|
||||
refusing inputs that would need a guessed profile, direction, selector,
|
||||
coordinate system, or topology decision. The original fragment and every
|
||||
applied fix are retained in the candidate audit record.
|
||||
"""
|
||||
if not isinstance(fragment, dict):
|
||||
return fragment, []
|
||||
normalized = deepcopy(fragment)
|
||||
fixes: list[dict[str, str]] = []
|
||||
_lift_feature_local_sketches(normalized, fixes=fixes)
|
||||
feature_values: list[tuple[dict[str, Any], str]] = []
|
||||
feature = normalized.get("feature")
|
||||
if isinstance(feature, dict):
|
||||
feature_values.append((feature, "feature"))
|
||||
features = normalized.get("features", normalized.get("add_features"))
|
||||
if isinstance(features, list):
|
||||
feature_values.extend(
|
||||
(item, f"features[{index}]")
|
||||
for index, item in enumerate(features)
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
|
||||
for current_feature, location in feature_values:
|
||||
atomic_id = str(current_feature.get("atomic_id") or "")
|
||||
params = current_feature.get("params")
|
||||
if not isinstance(params, dict):
|
||||
continue
|
||||
if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"}:
|
||||
# CAD systems commonly call a blind extrusion's travel "depth".
|
||||
# CDSL calls the exact same signed scalar ``distance_mm``.
|
||||
_move_equivalent_field(
|
||||
params,
|
||||
source="depth_mm",
|
||||
target="distance_mm",
|
||||
location=f"{location}.params",
|
||||
fixes=fixes,
|
||||
)
|
||||
if atomic_id in {"revolve_add", "revolve_cut"}:
|
||||
_move_equivalent_field(
|
||||
params,
|
||||
source="angle_degrees",
|
||||
target="angle_deg",
|
||||
location=f"{location}.params",
|
||||
fixes=fixes,
|
||||
)
|
||||
axis = params.get("axis")
|
||||
if axis is None:
|
||||
axis = {}
|
||||
params["axis"] = axis
|
||||
if isinstance(axis, dict):
|
||||
_move_axis_component(params, axis, source="axis_origin_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
|
||||
_move_axis_component(params, axis, source="axis_point_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
|
||||
_move_axis_component(params, axis, source="axis_dir", target="direction", location=f"{location}.params", fixes=fixes)
|
||||
_move_axis_component(params, axis, source="axis_direction", target="direction", location=f"{location}.params", fixes=fixes)
|
||||
axis = params.get("axis")
|
||||
if isinstance(axis, dict):
|
||||
_normalize_axis_mapping(axis, location=f"{location}.params.axis", fixes=fixes)
|
||||
|
||||
shared_axis = normalized.get("revolve_axis")
|
||||
if shared_axis is not None:
|
||||
if not isinstance(shared_axis, dict):
|
||||
raise AutonomousFragmentError("revolve_axis must be an explicit {origin_mm, direction} object")
|
||||
_normalize_axis_mapping(shared_axis, location="revolve_axis", fixes=fixes)
|
||||
revolved = [feature for feature, _ in feature_values if str(feature.get("atomic_id") or "").startswith("revolve_")]
|
||||
if not revolved:
|
||||
raise AutonomousFragmentError("revolve_axis is valid only in a fragment containing revolve_add or revolve_cut")
|
||||
for feature, location in feature_values:
|
||||
if not str(feature.get("atomic_id") or "").startswith("revolve_"):
|
||||
continue
|
||||
params = feature.get("params")
|
||||
if not isinstance(params, dict):
|
||||
continue
|
||||
axis = params.get("axis")
|
||||
if not isinstance(axis, dict) or not axis:
|
||||
params["axis"] = deepcopy(shared_axis)
|
||||
fixes.append({"path": f"{location}.params", "from": "revolve_axis", "to": "axis", "action": "copied_explicit_batch_axis"})
|
||||
|
||||
sketch_values: list[tuple[dict[str, Any], str]] = []
|
||||
sketch = normalized.get("sketch")
|
||||
if isinstance(sketch, dict):
|
||||
sketch_values.append((sketch, "sketch"))
|
||||
sketches = normalized.get("sketches", normalized.get("add_sketches"))
|
||||
if isinstance(sketches, list):
|
||||
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
|
||||
for current_sketch, location in sketch_values:
|
||||
profile = current_sketch.get("profile")
|
||||
if isinstance(profile, dict) and profile.get("type") == "polygon":
|
||||
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
|
||||
return normalized, fixes
|
||||
|
||||
|
||||
def materialize_autonomous_fragment(
|
||||
base_cdsl: dict[str, Any] | None,
|
||||
fragment: dict[str, Any],
|
||||
*,
|
||||
engine: Any,
|
||||
selector_tokens: dict[str, dict[str, Any]],
|
||||
max_features: int,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Append authored geometry while assigning only server-owned metadata.
|
||||
|
||||
Workplanes, profiles, feature parameters, directions and boolean meaning
|
||||
pass through exactly as the author supplied them. Local CDSL validation and
|
||||
full engine rebuild happen after this function, before a checkpoint exists.
|
||||
"""
|
||||
# Delayed import avoids engine_service -> selector_bindings -> this module
|
||||
# becoming an import cycle.
|
||||
from app.services.engine_service import feature_atomic_contract
|
||||
|
||||
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
|
||||
sketches, features = _fragment_lists(normalized_fragment)
|
||||
if len(features) > max_features:
|
||||
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
|
||||
document = materialize_fragment(base_cdsl, {"add_sketches": [], "add_features": []})
|
||||
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
|
||||
existing_sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
|
||||
existing_features = document.get("features") if isinstance(document.get("features"), list) else []
|
||||
used_sketch_ids = {str(item.get("id") or "") for item in existing_sketches if isinstance(item, dict)}
|
||||
used_feature_ids = {str(item.get("id") or "") for item in existing_features if isinstance(item, dict)}
|
||||
last_feature_id = str(existing_features[-1].get("id") or "") if existing_features and isinstance(existing_features[-1], dict) else ""
|
||||
materialized_sketches: list[dict[str, Any]] = []
|
||||
materialized_features: list[dict[str, Any]] = []
|
||||
|
||||
for index, source_feature in enumerate(features):
|
||||
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
|
||||
if forbidden:
|
||||
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
|
||||
atomic_id = str(source_feature.get("atomic_id") or "")
|
||||
if not atomic_id:
|
||||
raise AutonomousFragmentError("Each fragment feature must declare a runtime atomic_id")
|
||||
contract = feature_atomic_contract(engine, atomic_id)
|
||||
params = source_feature.get("params")
|
||||
if not isinstance(params, dict):
|
||||
raise AutonomousFragmentError("Each fragment feature must contain a params object")
|
||||
if (atomic_id.startswith("hole_") or atomic_id == "hole_wizard") and "host_face" in params:
|
||||
raise AutonomousFragmentError(
|
||||
f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use "
|
||||
"positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays"
|
||||
)
|
||||
tokens = source_feature.pop("selector_tokens", [])
|
||||
if not isinstance(tokens, list) or not all(isinstance(token, str) for token in tokens) or len(set(tokens)) != len(tokens):
|
||||
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
|
||||
selected: list[dict[str, Any]] = []
|
||||
for token in tokens:
|
||||
candidate = selector_tokens.get(token)
|
||||
if candidate is None:
|
||||
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
|
||||
selected.append(deepcopy(candidate["selector"]))
|
||||
slot = contract.get("selector_slot")
|
||||
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
|
||||
if not slot and tokens and not token_backed_param:
|
||||
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
|
||||
if isinstance(slot, dict):
|
||||
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
|
||||
if not minimum <= len(selected) <= maximum:
|
||||
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
|
||||
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
|
||||
if len(selected) != 1 or str(selected[0].get("kind") or "") != "face":
|
||||
raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face")
|
||||
elif atomic_id.startswith("revolve_"):
|
||||
axis = params.get("axis")
|
||||
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
|
||||
raise AutonomousFragmentError(
|
||||
f"{atomic_id} requires params.axis with explicit origin_mm and direction; "
|
||||
"the revolve axis is author-defined geometry, not a topology selector token"
|
||||
)
|
||||
|
||||
feature_id = _autonomous_id("feature", used_feature_ids)
|
||||
output = {key: deepcopy(value) for key, value in source_feature.items() if key != "selector_tokens"}
|
||||
output["id"] = feature_id
|
||||
output["depends_on"] = [last_feature_id] if last_feature_id else []
|
||||
if contract["requires_sketch"]:
|
||||
if index >= len(sketches):
|
||||
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
|
||||
sketch = sketches[index]
|
||||
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
|
||||
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
|
||||
sketch_id = _autonomous_id("sketch", used_sketch_ids)
|
||||
sketch["id"] = sketch_id
|
||||
materialized_sketches.append(sketch)
|
||||
output["sketch_id"] = sketch_id
|
||||
elif index < len(sketches):
|
||||
raise AutonomousFragmentError(f"{atomic_id} does not accept a sketch")
|
||||
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
|
||||
if not last_feature_id:
|
||||
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
|
||||
params["source_feature_ids"] = [last_feature_id]
|
||||
if isinstance(slot, dict) and slot.get("path") == "feature.selectors":
|
||||
output["selectors"] = selected
|
||||
elif isinstance(slot, dict) and slot.get("path") == "params.mirror_plane":
|
||||
output["params"]["mirror_plane"] = selected[0]
|
||||
output["selectors"] = []
|
||||
elif isinstance(slot, dict) and slot.get("path") == "params.host_face":
|
||||
output["params"]["host_face"] = selected[0]
|
||||
output["selectors"] = []
|
||||
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
|
||||
output["params"]["host_face"] = selected[0]
|
||||
output["selectors"] = []
|
||||
else:
|
||||
output["selectors"] = []
|
||||
materialized_features.append(output)
|
||||
last_feature_id = feature_id
|
||||
if len(sketches) != len(materialized_sketches):
|
||||
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
|
||||
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
|
||||
return document, {
|
||||
"schema_version": "cad.autonomous-fragment.v1",
|
||||
"source_fragment": deepcopy(fragment),
|
||||
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
|
||||
"compatibility_fixes": compatibility_fixes,
|
||||
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
|
||||
"assigned_feature_ids": [item["id"] for item in materialized_features],
|
||||
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CdslPatchError(ValueError):
|
||||
"""A JSON Patch operation cannot be applied to the requested CDSL revision."""
|
||||
|
||||
|
||||
def _tokens(path: Any) -> list[str]:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'")
|
||||
return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")]
|
||||
|
||||
|
||||
def _index(token: str, length: int, *, allow_append: bool = False) -> int:
|
||||
if allow_append and token == "-":
|
||||
return length
|
||||
if not token.isdigit() or (len(token) > 1 and token.startswith("0")):
|
||||
raise CdslPatchError(f"Invalid JSON Patch array index: {token}")
|
||||
value = int(token)
|
||||
if value < 0 or value >= length:
|
||||
raise CdslPatchError(f"JSON Patch array index is out of range: {token}")
|
||||
return value
|
||||
|
||||
|
||||
def _parent(document: Any, path: str) -> tuple[Any, str]:
|
||||
if path == "":
|
||||
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
|
||||
tokens = _tokens(path)
|
||||
current = document
|
||||
for token in tokens[:-1]:
|
||||
if isinstance(current, dict):
|
||||
if token not in current:
|
||||
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
|
||||
current = current[token]
|
||||
elif isinstance(current, list):
|
||||
current = current[_index(token, len(current))]
|
||||
else:
|
||||
raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}")
|
||||
return current, tokens[-1]
|
||||
|
||||
|
||||
def _get(document: Any, path: str) -> Any:
|
||||
current = document
|
||||
for token in _tokens(path):
|
||||
if isinstance(current, dict):
|
||||
if token not in current:
|
||||
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
|
||||
current = current[token]
|
||||
elif isinstance(current, list):
|
||||
current = current[_index(token, len(current))]
|
||||
else:
|
||||
raise CdslPatchError(f"JSON Patch path does not resolve: {path}")
|
||||
return current
|
||||
|
||||
|
||||
def _add(document: Any, path: str, value: Any) -> None:
|
||||
parent, token = _parent(document, path)
|
||||
if isinstance(parent, dict):
|
||||
parent[token] = deepcopy(value)
|
||||
elif isinstance(parent, list):
|
||||
index = _index(token, len(parent), allow_append=True)
|
||||
parent.insert(index, deepcopy(value))
|
||||
else:
|
||||
raise CdslPatchError(f"JSON Patch add target is not a container: {path}")
|
||||
|
||||
|
||||
def _remove(document: Any, path: str) -> Any:
|
||||
parent, token = _parent(document, path)
|
||||
if isinstance(parent, dict):
|
||||
if token not in parent:
|
||||
raise CdslPatchError(f"JSON Patch path does not exist: {path}")
|
||||
return parent.pop(token)
|
||||
if isinstance(parent, list):
|
||||
return parent.pop(_index(token, len(parent)))
|
||||
raise CdslPatchError(f"JSON Patch remove target is not a container: {path}")
|
||||
|
||||
|
||||
def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]:
|
||||
if not isinstance(cdsl, dict):
|
||||
raise CdslPatchError("The base CDSL must be an object")
|
||||
if not isinstance(patches, list) or not patches:
|
||||
raise CdslPatchError("patches must be a non-empty JSON Patch array")
|
||||
if len(patches) > 32:
|
||||
raise CdslPatchError("At most 32 JSON Patch operations are allowed")
|
||||
result: Any = deepcopy(cdsl)
|
||||
for index, operation in enumerate(patches):
|
||||
if not isinstance(operation, dict):
|
||||
raise CdslPatchError(f"Patch operation {index} must be an object")
|
||||
kind = str(operation.get("op") or "")
|
||||
path = operation.get("path")
|
||||
if kind not in {"add", "remove", "replace", "move", "copy", "test"}:
|
||||
raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or '<missing>'}")
|
||||
if kind in {"add", "replace", "test"} and "value" not in operation:
|
||||
raise CdslPatchError(f"JSON Patch {kind} requires value")
|
||||
if kind in {"move", "copy"} and "from" not in operation:
|
||||
raise CdslPatchError(f"JSON Patch {kind} requires from")
|
||||
if path == "":
|
||||
raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead")
|
||||
if kind == "add":
|
||||
_add(result, path, operation["value"])
|
||||
elif kind == "remove":
|
||||
_remove(result, path)
|
||||
elif kind == "replace":
|
||||
_get(result, path)
|
||||
_remove(result, path)
|
||||
_add(result, path, operation["value"])
|
||||
elif kind == "move":
|
||||
source = str(operation["from"])
|
||||
if path == source or str(path).startswith(source.rstrip("/") + "/"):
|
||||
raise CdslPatchError("JSON Patch move cannot move a value into itself")
|
||||
moved = _remove(result, source)
|
||||
_add(result, path, moved)
|
||||
elif kind == "copy":
|
||||
_add(result, path, _get(result, str(operation["from"])))
|
||||
elif kind == "test" and _get(result, path) != operation["value"]:
|
||||
raise CdslPatchError(f"JSON Patch test failed at {path}")
|
||||
return result
|
||||
@@ -1,223 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from app.services.engine_service import build_revision, load_engine
|
||||
from app.services.storage import WorkspaceStore
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
SUPPORTED_OPERATIONS = {
|
||||
"add_hole", "add_counterbore", "add_countersink", "add_slot",
|
||||
"add_pocket", "add_circular_pocket", "add_hole_pattern",
|
||||
}
|
||||
|
||||
|
||||
def _number(values: dict[str, Any], name: str, fallback: float, minimum: float = 0.01) -> float:
|
||||
value = float(values.get(name, fallback))
|
||||
if not math.isfinite(value) or value < minimum:
|
||||
raise ValueError(f"{name} must be a finite number >= {minimum}")
|
||||
return value
|
||||
|
||||
|
||||
def _selection_frame(selection: dict[str, Any]) -> dict[str, list[float]]:
|
||||
pick = selection.get("pick") if isinstance(selection.get("pick"), dict) else selection
|
||||
surface = pick.get("surface") if isinstance(pick.get("surface"), dict) else {}
|
||||
surface_type = str(surface.get("type") or surface.get("surfaceType") or "").lower()
|
||||
if surface_type and "plane" not in surface_type:
|
||||
raise ValueError("Direct CDSL edits currently require a planar face")
|
||||
frame = pick.get("frame") if isinstance(pick.get("frame"), dict) else {}
|
||||
origin = frame.get("origin_mm") or pick.get("center") or pick.get("point")
|
||||
normal = frame.get("normal") or pick.get("normal")
|
||||
x_dir = frame.get("x_dir") or frame.get("xDir") or [1.0, 0.0, 0.0]
|
||||
y_dir = frame.get("y_dir") or frame.get("yDir") or [0.0, 1.0, 0.0]
|
||||
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, normal, x_dir, y_dir)):
|
||||
raise ValueError("Select a planar face before applying a direct CDSL edit")
|
||||
return {
|
||||
"origin_mm": [float(item) for item in origin[:3]],
|
||||
"normal": [float(item) for item in normal[:3]],
|
||||
"x_dir": [float(item) for item in x_dir[:3]],
|
||||
"y_dir": [float(item) for item in y_dir[:3]],
|
||||
}
|
||||
|
||||
|
||||
def _next_id(prefix: str, existing: set[str]) -> str:
|
||||
index = 1
|
||||
while f"{prefix}_{index:03d}" in existing:
|
||||
index += 1
|
||||
return f"{prefix}_{index:03d}"
|
||||
|
||||
|
||||
def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any], float]:
|
||||
depth = _through_depth(values)
|
||||
if operation in {"add_hole", "add_counterbore", "add_countersink", "add_circular_pocket"}:
|
||||
diameter = _number(values, "holeDiameter", values.get("diameter", 10.0))
|
||||
return {"type": "circle", "center": [0.0, 0.0], "radius_mm": diameter / 2}, depth
|
||||
if operation == "add_slot":
|
||||
width = _number(values, "slotWidth", values.get("width", 8.0))
|
||||
length = _number(values, "slotLength", values.get("length", width * 3))
|
||||
length = max(length, width)
|
||||
radius = width / 2
|
||||
left, right = -length / 2 + radius, length / 2 - radius
|
||||
return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [
|
||||
{"type": "line", "start": [right, radius], "end": [left, radius]},
|
||||
{"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius},
|
||||
{"type": "line", "start": [left, -radius], "end": [right, -radius]},
|
||||
{"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius},
|
||||
]}]}, depth
|
||||
if operation == "add_pocket":
|
||||
width = _number(values, "width", 20.0)
|
||||
height = _number(values, "height", 12.0)
|
||||
return {"type": "polygon", "vertices": [
|
||||
[-width / 2, -height / 2], [width / 2, -height / 2],
|
||||
[width / 2, height / 2], [-width / 2, height / 2],
|
||||
]}, depth
|
||||
if operation == "add_hole_pattern":
|
||||
diameter = _number(values, "holeDiameter", values.get("diameter", 6.0))
|
||||
rows = max(1, int(_number(values, "rows", 2, 1)))
|
||||
columns = max(1, int(_number(values, "columns", 2, 1)))
|
||||
pitch_x = _number(values, "pitchX", 12.0)
|
||||
pitch_y = _number(values, "pitchY", 12.0)
|
||||
return {"type": "analytic_contours", "contours": [
|
||||
{"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [
|
||||
(column - (columns - 1) / 2) * pitch_x,
|
||||
(row - (rows - 1) / 2) * pitch_y,
|
||||
], "radius_mm": diameter / 2}]}
|
||||
for row in range(rows) for column in range(columns)
|
||||
]}, depth
|
||||
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
|
||||
|
||||
|
||||
def _through_depth(values: dict[str, Any]) -> float:
|
||||
# A through cut deliberately exceeds the model bounds. build123d clips the
|
||||
# cutter against the solid, so this remains deterministic for any part size.
|
||||
return 10000.0 if str(values.get("depth") or "").lower() == "through" else _number(values, "depth", 10.0)
|
||||
|
||||
|
||||
def _hole_feature(operation: str, frame: dict[str, list[float]], values: dict[str, Any]) -> dict[str, Any]:
|
||||
diameter = _number(values, "holeDiameter", values.get("diameter", 10.0))
|
||||
params: dict[str, Any] = {
|
||||
"diameter_mm": diameter,
|
||||
"depth_mm": _through_depth(values),
|
||||
"positions": [{"mm": [0.0, 0.0, 0.0]}],
|
||||
"host_face": {"frame": frame},
|
||||
}
|
||||
atomic = "hole_blind"
|
||||
if operation == "add_counterbore":
|
||||
counterbore_diameter = _number(values, "counterboreDiameter", diameter * 2)
|
||||
if counterbore_diameter <= diameter:
|
||||
raise ValueError("counterboreDiameter must be larger than holeDiameter")
|
||||
params["counterbore_diameter_mm"] = counterbore_diameter
|
||||
params["counterbore_depth_mm"] = _number(values, "counterboreDepth", min(diameter, 2.0))
|
||||
atomic = "hole_counterbore"
|
||||
elif operation == "add_countersink":
|
||||
countersink_diameter = _number(values, "countersinkDiameter", diameter * 2)
|
||||
if countersink_diameter <= diameter:
|
||||
raise ValueError("countersinkDiameter must be larger than holeDiameter")
|
||||
params["countersink_diameter_mm"] = countersink_diameter
|
||||
params["countersink_angle_rad"] = math.radians(_number(values, "countersinkAngleDeg", 90.0, 1.0))
|
||||
atomic = "hole_countersink"
|
||||
return {"atomic": atomic, "params": params}
|
||||
|
||||
|
||||
def _slot_frame(frame: dict[str, list[float]], picks: list[dict[str, Any]]) -> tuple[dict[str, list[float]], float]:
|
||||
if len(picks) < 2:
|
||||
raise ValueError("Select the two endpoints for the slot")
|
||||
first = _selection_frame({"pick": picks[0]})
|
||||
second = _selection_frame({"pick": picks[1]})
|
||||
vector = [second["origin_mm"][index] - first["origin_mm"][index] for index in range(3)]
|
||||
length = math.sqrt(sum(value * value for value in vector))
|
||||
if length < 0.01:
|
||||
raise ValueError("Slot endpoints must be distinct")
|
||||
x_dir = [value / length for value in vector]
|
||||
normal = first["normal"]
|
||||
y_dir = [
|
||||
normal[1] * x_dir[2] - normal[2] * x_dir[1],
|
||||
normal[2] * x_dir[0] - normal[0] * x_dir[2],
|
||||
normal[0] * x_dir[1] - normal[1] * x_dir[0],
|
||||
]
|
||||
midpoint = [(first["origin_mm"][index] + second["origin_mm"][index]) / 2 for index in range(3)]
|
||||
return {"origin_mm": midpoint, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, length
|
||||
|
||||
|
||||
def apply_direct_edit(
|
||||
settings: Settings,
|
||||
store: WorkspaceStore,
|
||||
task_id: str,
|
||||
operation: str,
|
||||
selection: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
*,
|
||||
node_id: str = "",
|
||||
branch_id: str = "main",
|
||||
visibility: str = "final",
|
||||
) -> dict[str, Any]:
|
||||
if operation in {"add_chamfer", "add_fillet"}:
|
||||
raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet")
|
||||
if operation not in SUPPORTED_OPERATIONS:
|
||||
raise ValueError(f"Unsupported direct CDSL edit: {operation}")
|
||||
source = store.current_cdsl_path(task_id)
|
||||
task = store.read_task(task_id)
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
if source is None or not revision_id:
|
||||
raise ValueError("Task has no successful CDSL revision")
|
||||
frame = _selection_frame(selection)
|
||||
cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8")))
|
||||
# Historical revisions can contain importer-only profile macros. An edit
|
||||
# creates a new CDSL-only revision, so lower those profiles explicitly
|
||||
# before adding the new generic feature.
|
||||
load_engine(settings)
|
||||
from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles
|
||||
|
||||
lowered_cdsl = lower_legacy_profiles(cdsl)
|
||||
legacy_profiles_lowered = lowered_cdsl != cdsl
|
||||
cdsl = lowered_cdsl
|
||||
features = cdsl.setdefault("features", [])
|
||||
sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", [])
|
||||
picks = selection.get("picks") if isinstance(selection.get("picks"), list) else []
|
||||
if operation == "add_slot":
|
||||
frame, slot_length = _slot_frame(frame, [pick for pick in picks if isinstance(pick, dict)])
|
||||
parameters = {**parameters, "slotLength": slot_length}
|
||||
profile, depth = _profile_for(operation, parameters)
|
||||
feature_id = _next_id("edit", {str(item.get("id")) for item in features})
|
||||
sketch_id = _next_id("edit_sketch", {str(item.get("id")) for item in sketches})
|
||||
dependency = str(features[-1].get("id")) if features else ""
|
||||
sketches.append({"id": sketch_id, "name": operation, "workplane": frame, "profile": profile})
|
||||
feature: dict[str, Any] = {
|
||||
"id": feature_id,
|
||||
"depends_on": [dependency] if dependency else [],
|
||||
"name": operation,
|
||||
"sketch_id": sketch_id,
|
||||
}
|
||||
if operation in {"add_hole", "add_counterbore", "add_countersink"}:
|
||||
hole = _hole_feature(operation, frame, parameters)
|
||||
feature["atomic_id"] = hole["atomic"]
|
||||
feature["params"] = hole["params"]
|
||||
else:
|
||||
feature["atomic_id"] = "extrude_cut_blind"
|
||||
feature["params"] = {"distance_mm": depth}
|
||||
features.append(feature)
|
||||
return build_revision(
|
||||
settings=settings,
|
||||
store=store,
|
||||
task_id=task_id,
|
||||
request=f"Direct CDSL edit: {operation}",
|
||||
cdsl=cdsl,
|
||||
reference_ids=[],
|
||||
summary=f"Applied {operation}",
|
||||
parent_revision_id=revision_id,
|
||||
operation={
|
||||
"type": operation,
|
||||
"selection": selection,
|
||||
"parameters": parameters,
|
||||
"legacy_profiles_lowered": legacy_profiles_lowered,
|
||||
},
|
||||
part_skills=None,
|
||||
generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [],
|
||||
node_id=node_id,
|
||||
branch_id=branch_id,
|
||||
visibility=visibility,
|
||||
)
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -13,22 +13,9 @@ from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
from vendor.cdsl_preview_runtime import step_to_glb
|
||||
from app.services.quality import evaluate_quality, validate_verification
|
||||
from app.services.storage import WorkspaceStore, now_iso, write_json
|
||||
from app.services.cdsl_fragment import selector_bindings
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
class QualityVerificationError(RuntimeError):
|
||||
"""A built CDSL document missed a blocking generic verification rule."""
|
||||
|
||||
def __init__(self, quality_report: dict[str, Any]) -> None:
|
||||
super().__init__("Blocking CDSL verification checks failed")
|
||||
self.quality_report = quality_report
|
||||
self.task_id = ""
|
||||
self.revision_id = ""
|
||||
|
||||
|
||||
def load_engine(settings: Settings) -> Any:
|
||||
parent = str(settings.engine_root.parent)
|
||||
if parent not in sys.path:
|
||||
@@ -50,8 +37,11 @@ def _walk(value: Any) -> list[tuple[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def _engine_schema(engine: Any) -> dict[str, Any]:
|
||||
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
|
||||
@lru_cache(maxsize=16)
|
||||
def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
|
||||
"""Load an immutable runtime schema once per on-disk version."""
|
||||
del modified_ns # The mtime is intentionally part of the cache key.
|
||||
schema_path = Path(path_value)
|
||||
try:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
@@ -61,12 +51,63 @@ def _engine_schema(engine: Any) -> dict[str, Any]:
|
||||
return schema
|
||||
|
||||
|
||||
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
|
||||
def _engine_schema(engine: Any) -> dict[str, Any]:
|
||||
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
|
||||
try:
|
||||
modified_ns = schema_path.stat().st_mtime_ns
|
||||
except OSError as error:
|
||||
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
|
||||
return _read_schema_document(str(schema_path), modified_ns)
|
||||
|
||||
|
||||
def feature_atomic_contract(engine: Any, atomic_id: str) -> dict[str, Any]:
|
||||
"""Return the runtime-supported parameter contract for one atomic feature."""
|
||||
schema = _engine_schema(engine)
|
||||
normalized_id = str(atomic_id or "").strip()
|
||||
contracts = schema["feature_atomic_ids"]
|
||||
contract = contracts.get(normalized_id)
|
||||
registered = {str(item) for item in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
|
||||
declared = {str(item) for item in schema.get("runtime_supported_atomic_ids") or ()}
|
||||
if normalized_id not in registered or normalized_id not in declared or not isinstance(contract, dict):
|
||||
raise ValueError(f"Unsupported runtime atomic_id: {normalized_id}")
|
||||
required = contract.get("required_params") or []
|
||||
optional = contract.get("optional_params") or []
|
||||
if not all(isinstance(item, str) and item for item in [*required, *optional]):
|
||||
raise RuntimeError(f"Runtime feature contract is invalid for {normalized_id}")
|
||||
return {
|
||||
"atomic_id": normalized_id,
|
||||
"summary": str(contract.get("summary") or ""),
|
||||
"required_params": list(dict.fromkeys(required)),
|
||||
"optional_params": [item for item in dict.fromkeys(optional) if item not in required],
|
||||
"position_format": str(contract.get("position_format") or ""),
|
||||
"requires_sketch": contract.get("requires_sketch") is True,
|
||||
"selector_slot": copy.deepcopy(contract.get("selector_slot")) if isinstance(contract.get("selector_slot"), dict) else None,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _cdsl_validator(path_value: str, modified_ns: int) -> Draft202012Validator:
|
||||
"""Compile the JSON Schema once per runtime schema revision."""
|
||||
del modified_ns
|
||||
schema_path = Path(path_value)
|
||||
try:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except (OSError, json.JSONDecodeError, SchemaError) as error:
|
||||
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
|
||||
return Draft202012Validator(schema)
|
||||
|
||||
|
||||
def _cdsl_schema_path(engine: Any) -> Path:
|
||||
document = _engine_schema(engine)
|
||||
schema_name = str(document.get("cdsl_json_schema_file") or "")
|
||||
if not schema_name or Path(schema_name).name != schema_name:
|
||||
raise RuntimeError("The local engine schema has an invalid CDSL JSON Schema path")
|
||||
schema_path = Path(str(engine.__file__)).with_name(schema_name)
|
||||
return Path(str(engine.__file__)).with_name(schema_name)
|
||||
|
||||
|
||||
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
|
||||
schema_path = _cdsl_schema_path(engine)
|
||||
try:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
@@ -76,7 +117,11 @@ def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
validator = Draft202012Validator(load_cdsl_json_schema(engine))
|
||||
schema_path = _cdsl_schema_path(engine)
|
||||
try:
|
||||
validator = _cdsl_validator(str(schema_path), schema_path.stat().st_mtime_ns)
|
||||
except OSError as error:
|
||||
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
|
||||
errors = sorted(validator.iter_errors(cdsl), key=lambda error: (list(error.absolute_path), error.message))
|
||||
if not errors:
|
||||
return
|
||||
@@ -88,76 +133,6 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
|
||||
|
||||
|
||||
def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None:
|
||||
if isinstance(offset, bool) or not isinstance(offset, (int, float)):
|
||||
return None
|
||||
distance = float(offset)
|
||||
definitions = {
|
||||
"XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
"XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||||
"YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
|
||||
}
|
||||
definition = definitions.get(plane.upper())
|
||||
if definition is None:
|
||||
return None
|
||||
origin, x_dir, normal = definition
|
||||
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
|
||||
|
||||
|
||||
def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Convert unambiguous legacy LLM aliases into the current CDSL dialect.
|
||||
|
||||
This intentionally does not infer dimensions, selectors, or feature
|
||||
dependencies. Any non-mechanical error remains visible to the validator.
|
||||
"""
|
||||
normalized = copy.deepcopy(cdsl)
|
||||
repairs: list[str] = []
|
||||
geometry = normalized.get("geometry")
|
||||
sketches = geometry.get("sketches") if isinstance(geometry, dict) else None
|
||||
if isinstance(sketches, list):
|
||||
for index, sketch in enumerate(sketches):
|
||||
if not isinstance(sketch, dict):
|
||||
continue
|
||||
if "id" not in sketch and isinstance(sketch.get("sketch_id"), str):
|
||||
sketch["id"] = sketch.pop("sketch_id")
|
||||
repairs.append(f"geometry.sketches[{index}]: sketch_id -> id")
|
||||
|
||||
legacy_plane: Any = sketch.get("plane")
|
||||
legacy_offset: Any = sketch.get("offset_mm", 0)
|
||||
workplane_value = sketch.get("workplane")
|
||||
if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value:
|
||||
legacy_plane = workplane_value.get("plane")
|
||||
legacy_offset = workplane_value.get("offset_mm", 0)
|
||||
elif "workplane" in sketch:
|
||||
continue
|
||||
|
||||
workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None
|
||||
if workplane is None:
|
||||
continue
|
||||
sketch["workplane"] = workplane
|
||||
sketch.pop("plane", None)
|
||||
sketch.pop("offset_mm", None)
|
||||
repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane")
|
||||
|
||||
features = normalized.get("features")
|
||||
if isinstance(features, list):
|
||||
for index, feature in enumerate(features):
|
||||
if not isinstance(feature, dict):
|
||||
continue
|
||||
if "sketch_id" not in feature and isinstance(feature.get("sketch"), str):
|
||||
feature["sketch_id"] = feature.pop("sketch")
|
||||
repairs.append(f"features[{index}]: sketch -> sketch_id")
|
||||
if "depends_on" not in feature:
|
||||
feature["depends_on"] = []
|
||||
repairs.append(f"features[{index}]: added empty depends_on")
|
||||
params = feature.get("params")
|
||||
axis = params.get("axis") if isinstance(params, dict) else None
|
||||
if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis:
|
||||
axis["origin_mm"] = axis.pop("point_mm")
|
||||
repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm")
|
||||
return normalized, repairs
|
||||
|
||||
|
||||
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
@@ -490,285 +465,3 @@ def topology_sidecars(
|
||||
for name, point, normal, x_dir, y_dir in definitions
|
||||
]
|
||||
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})
|
||||
|
||||
|
||||
def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None:
|
||||
target: Any = document
|
||||
for index, key in enumerate(path):
|
||||
final = index == len(path) - 1
|
||||
if isinstance(target, list):
|
||||
item_index = int(key)
|
||||
if item_index < 0 or item_index >= len(target):
|
||||
raise ValueError("Parameter path is no longer valid")
|
||||
if final:
|
||||
target[item_index] = value
|
||||
else:
|
||||
target = target[item_index]
|
||||
elif isinstance(target, dict):
|
||||
if key not in target:
|
||||
raise ValueError("Parameter path is no longer valid")
|
||||
if final:
|
||||
target[key] = value
|
||||
else:
|
||||
target = target[key]
|
||||
else:
|
||||
raise ValueError("Parameter path is no longer valid")
|
||||
|
||||
|
||||
def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
contract = parameter_contract(cdsl)
|
||||
entries = {str(item.get("id")): item for item in contract["parameters"]}
|
||||
updated = copy.deepcopy(cdsl)
|
||||
for parameter_id, raw_value in values.items():
|
||||
entry = entries.get(parameter_id)
|
||||
value = float(raw_value)
|
||||
if entry is None or not entry.get("editable", False):
|
||||
raise ValueError(f"Unknown editable parameter: {parameter_id}")
|
||||
if not math.isfinite(value):
|
||||
raise ValueError("Parameter values must be finite")
|
||||
minimum, maximum = entry.get("minimum"), entry.get("maximum")
|
||||
if isinstance(minimum, (int, float)) and value < float(minimum):
|
||||
raise ValueError(f"{parameter_id} is below its minimum")
|
||||
if isinstance(maximum, (int, float)) and value > float(maximum):
|
||||
raise ValueError(f"{parameter_id} is above its maximum")
|
||||
path = entry.get("path")
|
||||
if not isinstance(path, list) or not all(isinstance(item, str) for item in path):
|
||||
raise ValueError(f"{parameter_id} has an invalid path")
|
||||
_set_parameter_value(updated, path, value)
|
||||
declared = updated.get("meta", {}).get("editable_parameters")
|
||||
if isinstance(declared, list):
|
||||
for declared_entry in declared:
|
||||
if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id:
|
||||
declared_entry["value"] = value
|
||||
return updated, parameter_contract(updated)
|
||||
|
||||
|
||||
def _part_skill_audit(
|
||||
part_skills: dict[str, Any] | None,
|
||||
request: str,
|
||||
generation_assumptions: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalize the planning audit persisted beside a product revision."""
|
||||
audit = copy.deepcopy(part_skills) if isinstance(part_skills, dict) else {}
|
||||
skills = [item for item in audit.get("skills") or [] if isinstance(item, dict)]
|
||||
skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)]
|
||||
if not skill_ids:
|
||||
skill_ids = [str(item.get("id")) for item in skills if item.get("id")]
|
||||
audit.update({
|
||||
"schema_version": str(audit.get("schema_version") or "1.0"),
|
||||
"request": str(audit.get("request") or request),
|
||||
"structural_intent": str(audit.get("structural_intent") or request),
|
||||
"skill_ids": skill_ids,
|
||||
"skills": skills,
|
||||
"inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)],
|
||||
"assumptions": [str(item) for item in generation_assumptions or []],
|
||||
})
|
||||
return audit
|
||||
|
||||
|
||||
def _generation_context(
|
||||
reference_ids: list[str],
|
||||
part_skill_audit: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)]
|
||||
return {
|
||||
"cdsl_reference_ids": list(reference_ids),
|
||||
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
|
||||
"part_skill_paths": [
|
||||
{
|
||||
"id": str(item.get("id") or ""),
|
||||
"bridge": str(item.get("bridge") or ""),
|
||||
"source": str(item.get("source") or ""),
|
||||
}
|
||||
for item in skills
|
||||
],
|
||||
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
|
||||
}
|
||||
|
||||
|
||||
def build_revision(
|
||||
*,
|
||||
settings: Settings,
|
||||
store: WorkspaceStore,
|
||||
task_id: str | None,
|
||||
request: str,
|
||||
cdsl: dict[str, Any],
|
||||
reference_ids: list[str],
|
||||
summary: str,
|
||||
parent_revision_id: str | None = None,
|
||||
operation: dict[str, Any] | None = None,
|
||||
input_attachments: list[dict[str, Any]] | None = None,
|
||||
part_skills: dict[str, Any] | None = None,
|
||||
generation_assumptions: list[str] | None = None,
|
||||
repair_attempts: int = 0,
|
||||
verification: dict[str, Any] | None = None,
|
||||
reference_records: list[dict[str, Any]] | None = None,
|
||||
feature_plan: dict[str, Any] | None = None,
|
||||
node_id: str = "",
|
||||
fragment: dict[str, Any] | None = None,
|
||||
branch_id: str = "main",
|
||||
visibility: str = "final",
|
||||
) -> dict[str, Any]:
|
||||
engine = load_engine(settings)
|
||||
task = store.ensure_task(task_id, request)
|
||||
revision_id, revision_dir = store.next_revision(task["task_id"])
|
||||
cdsl_path = revision_dir / "model.cdsl.json"
|
||||
step_path = revision_dir / "model.step"
|
||||
glb_path = revision_dir / "model.glb"
|
||||
report_path = revision_dir / "rebuild-report.json"
|
||||
request_path = revision_dir / "request.json"
|
||||
references_path = revision_dir / "references.json"
|
||||
parameters_path = revision_dir / "parameters.json"
|
||||
selector_path = revision_dir / "model.selector.json"
|
||||
edges_path = revision_dir / "model.edges.json"
|
||||
topology_path = revision_dir / "model.topology.json"
|
||||
part_skills_path = revision_dir / "part-skills.json"
|
||||
quality_path = revision_dir / "quality-report.json"
|
||||
snapshot_manifest_path = revision_dir / "snapshot-manifest.json"
|
||||
fragment_path = revision_dir / "fragment.json"
|
||||
selector_bindings_path = revision_dir / "selector-bindings.json"
|
||||
part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions)
|
||||
generation_context = _generation_context(reference_ids, part_skill_audit)
|
||||
write_json(request_path, {"request": request, "created_at": now_iso()})
|
||||
if isinstance(feature_plan, dict):
|
||||
store.write_feature_plan(task["task_id"], feature_plan)
|
||||
write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]})
|
||||
write_json(part_skills_path, part_skill_audit)
|
||||
write_json(snapshot_manifest_path, {
|
||||
"schema_version": "1.0",
|
||||
"status": "unavailable",
|
||||
"reason": "Snapshot runner is not attached to this backend build",
|
||||
"snapshots": [],
|
||||
})
|
||||
def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]:
|
||||
record = {
|
||||
"revision_id": revision_id,
|
||||
"status": status,
|
||||
"created_at": now_iso(),
|
||||
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"snapshot_status": "unavailable",
|
||||
"snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()],
|
||||
"part_skill_ids": list(part_skill_audit.get("skill_ids") or []),
|
||||
"generation_assumptions": list(part_skill_audit.get("assumptions") or []),
|
||||
"reference_ids": reference_ids,
|
||||
"summary": summary,
|
||||
"parent_revision_id": parent_revision_id or "",
|
||||
"operation": operation or {},
|
||||
"input_attachments": input_attachments or [],
|
||||
"repair_attempts": max(0, int(repair_attempts)),
|
||||
"branch_id": branch_id or "main",
|
||||
"visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint",
|
||||
"node_id": node_id,
|
||||
"fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "",
|
||||
"selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
}
|
||||
if status == "success":
|
||||
record.update({
|
||||
"step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
|
||||
"engine": engine_name,
|
||||
})
|
||||
else:
|
||||
record["error"] = error
|
||||
return record
|
||||
|
||||
quality_report: dict[str, Any] | None = None
|
||||
quality_status = ""
|
||||
try:
|
||||
cdsl_copy = copy.deepcopy(cdsl)
|
||||
if not isinstance(cdsl_copy, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
cdsl_copy["part_id"] = task["task_id"]
|
||||
meta = cdsl_copy.setdefault("meta", {})
|
||||
if not isinstance(meta, dict):
|
||||
raise ValueError("CDSL meta must be an object when present")
|
||||
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
|
||||
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
|
||||
write_json(cdsl_path, cdsl_copy)
|
||||
if fragment is not None:
|
||||
write_json(fragment_path, fragment)
|
||||
write_json(parameters_path, parameter_contract(cdsl_copy))
|
||||
validate_cdsl(cdsl_copy, engine)
|
||||
# Product revisions are semantic CDSL artifacts. Do not route them
|
||||
# through the legacy rebuild entry point, which is allowed to use
|
||||
# compiler_context/translator compatibility fallbacks.
|
||||
engine_result = engine.run_cdsl_only(cdsl_copy, step_path)
|
||||
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
|
||||
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview)
|
||||
write_json(topology_path, snapshot)
|
||||
write_json(selector_bindings_path, selector_bindings(
|
||||
engine_result,
|
||||
node_id=node_id,
|
||||
snapshot_id=str(snapshot.get("snapshot_id") or ""),
|
||||
))
|
||||
selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot)
|
||||
write_json(selector_path, selector)
|
||||
write_json(edges_path, edges)
|
||||
rules = validate_verification(verification, cdsl_copy)
|
||||
quality_report = evaluate_quality(rules, cdsl_copy, engine_result)
|
||||
quality_report["evaluated_at"] = now_iso()
|
||||
write_json(quality_path, quality_report)
|
||||
quality_status = (
|
||||
"accepted" if rules and quality_report["status"] == "passed"
|
||||
else "built_with_warnings" if quality_report["status"] == "passed"
|
||||
else "needs_repair"
|
||||
)
|
||||
if quality_report["status"] != "passed":
|
||||
raise QualityVerificationError(quality_report)
|
||||
report = {
|
||||
"engine_result": engine_result,
|
||||
"preview": preview,
|
||||
"generation_context": generation_context,
|
||||
"validated_at": now_iso(),
|
||||
}
|
||||
write_json(report_path, report)
|
||||
revision = revision_record("success", engine_name=str(engine_result["engine"]))
|
||||
revision["quality_status"] = quality_status or "accepted"
|
||||
revision["verification_summary"] = {
|
||||
"requested": bool(rules),
|
||||
"blocking_failures": len(quality_report.get("blocking_failures") or []),
|
||||
"warnings": len(quality_report.get("warnings") or []),
|
||||
}
|
||||
except Exception as error:
|
||||
if not isinstance(error, QualityVerificationError):
|
||||
# The failed candidate is retained in the conversation diagnostics,
|
||||
# not as a task revision. Runtime/schema failures must not create
|
||||
# an editable revision that looks like a model version.
|
||||
shutil.rmtree(revision_dir, ignore_errors=True)
|
||||
raise
|
||||
if not cdsl_path.is_file() and isinstance(cdsl, dict):
|
||||
write_json(cdsl_path, copy.deepcopy(cdsl))
|
||||
if quality_report is not None and not quality_path.is_file():
|
||||
write_json(quality_path, quality_report)
|
||||
write_json(report_path, {
|
||||
"error": str(error),
|
||||
"generation_context": generation_context,
|
||||
"quality": quality_report,
|
||||
"validated_at": now_iso(),
|
||||
})
|
||||
revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error))
|
||||
revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed")
|
||||
if quality_report is not None:
|
||||
revision["verification_summary"] = {
|
||||
"requested": bool(quality_report.get("verification_requested")),
|
||||
"blocking_failures": len(quality_report.get("blocking_failures") or []),
|
||||
"warnings": len(quality_report.get("warnings") or []),
|
||||
}
|
||||
store.update_task(task["task_id"], revision)
|
||||
error.task_id = task["task_id"]
|
||||
error.revision_id = revision_id
|
||||
raise
|
||||
store.update_task(task["task_id"], revision)
|
||||
return {"task_id": task["task_id"], **revision}
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"""Deterministic feature-plan validation and readiness calculations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from copy import deepcopy
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
PLAN_SCHEMA_VERSION = "cad.feature-plan.v1"
|
||||
NODE_STATUSES = {
|
||||
"planned",
|
||||
"ready",
|
||||
"waiting_for_topology",
|
||||
"waiting_for_selection",
|
||||
"blocked",
|
||||
"executing",
|
||||
"executed",
|
||||
"failed",
|
||||
"completed",
|
||||
}
|
||||
|
||||
_TOPOLOGY_REQUIRED_ATOMICS = {
|
||||
"fillet",
|
||||
"chamfer",
|
||||
"hole_blind",
|
||||
"hole_countersink",
|
||||
"hole_counterbore",
|
||||
"pattern_mirror",
|
||||
}
|
||||
|
||||
|
||||
class FeaturePlanError(ValueError):
|
||||
"""A feature plan is not a valid acyclic executable plan."""
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, required: bool = True) -> str:
|
||||
result = str(value or "").strip()
|
||||
if required and not result:
|
||||
raise FeaturePlanError(f"{field} is required")
|
||||
return result
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
return value is True
|
||||
|
||||
|
||||
def _normalise_node(raw: Any, index: int) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise FeaturePlanError(f"nodes[{index}] must be an object")
|
||||
node_id = _text(raw.get("id"), f"nodes[{index}].id")
|
||||
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
|
||||
depends_on = raw.get("depends_on") or []
|
||||
if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on):
|
||||
raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings")
|
||||
feature_ids = raw.get("cdsl_feature_ids")
|
||||
if feature_ids is None:
|
||||
feature_ids = [node_id]
|
||||
if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids):
|
||||
raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array")
|
||||
query = raw.get("topology_query")
|
||||
if query is not None and not isinstance(query, dict):
|
||||
raise FeaturePlanError(f"nodes[{index}].topology_query must be an object")
|
||||
requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS
|
||||
status = str(raw.get("status") or "planned")
|
||||
if status not in NODE_STATUSES:
|
||||
raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}")
|
||||
node = {
|
||||
"id": node_id,
|
||||
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
|
||||
"atomic_id": atomic_id,
|
||||
"depends_on": list(dict.fromkeys(item.strip() for item in depends_on)),
|
||||
"requires_topology": requires_topology,
|
||||
"topology_query": deepcopy(query) if query is not None else None,
|
||||
"status": status,
|
||||
"cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)),
|
||||
}
|
||||
if raw.get("selector_required") is not None:
|
||||
node["selector_required"] = _bool(raw.get("selector_required"))
|
||||
if isinstance(raw.get("failure"), dict):
|
||||
node["failure"] = deepcopy(raw["failure"])
|
||||
return node
|
||||
|
||||
|
||||
def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]:
|
||||
if not isinstance(plan, dict):
|
||||
raise FeaturePlanError("Feature plan must be an object")
|
||||
version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION)
|
||||
if version != PLAN_SCHEMA_VERSION:
|
||||
raise FeaturePlanError(f"Unsupported feature plan schema: {version}")
|
||||
plan_id = _text(plan.get("plan_id"), "plan_id")
|
||||
task_id = _text(plan.get("task_id"), "task_id", required=False)
|
||||
topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False)
|
||||
raw_nodes = plan.get("nodes")
|
||||
if not isinstance(raw_nodes, list) or not raw_nodes:
|
||||
raise FeaturePlanError("Feature plan requires a non-empty nodes array")
|
||||
nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)]
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
node_index: dict[str, int] = {}
|
||||
feature_owner: dict[str, str] = {}
|
||||
supported = {str(item) for item in supported_atomic_ids if str(item)}
|
||||
for index, node in enumerate(nodes):
|
||||
if node["id"] in by_id:
|
||||
raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}")
|
||||
if supported and node["atomic_id"] not in supported:
|
||||
raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}")
|
||||
by_id[node["id"]] = node
|
||||
node_index[node["id"]] = index
|
||||
for feature_id in node["cdsl_feature_ids"]:
|
||||
if feature_id in feature_owner:
|
||||
raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}")
|
||||
feature_owner[feature_id] = node["id"]
|
||||
indegree = {node_id: 0 for node_id in by_id}
|
||||
children: dict[str, list[str]] = defaultdict(list)
|
||||
for node in nodes:
|
||||
for dependency in node["depends_on"]:
|
||||
if dependency not in by_id:
|
||||
raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}")
|
||||
if node_index[dependency] >= node_index[node["id"]]:
|
||||
raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}")
|
||||
indegree[node["id"]] += 1
|
||||
children[dependency].append(node["id"])
|
||||
queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
|
||||
visited: list[str] = []
|
||||
while queue:
|
||||
node_id = queue.popleft()
|
||||
visited.append(node_id)
|
||||
for child in children[node_id]:
|
||||
indegree[child] -= 1
|
||||
if indegree[child] == 0:
|
||||
queue.append(child)
|
||||
if len(visited) != len(nodes):
|
||||
raise FeaturePlanError("Feature plan contains a dependency cycle")
|
||||
return {
|
||||
"schema_version": PLAN_SCHEMA_VERSION,
|
||||
"plan_id": plan_id,
|
||||
"task_id": task_id,
|
||||
"topology_snapshot_id": topology_snapshot_id,
|
||||
"nodes": nodes,
|
||||
"feature_owner": feature_owner,
|
||||
}
|
||||
|
||||
|
||||
def _topology_available(topology: dict[str, Any] | None) -> bool:
|
||||
if not isinstance(topology, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(record, dict)
|
||||
and record.get("executable", True) is not False
|
||||
and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"}
|
||||
for record in topology.get("records") or ()
|
||||
)
|
||||
|
||||
|
||||
def compute_node_statuses(
|
||||
plan: dict[str, Any],
|
||||
*,
|
||||
cdsl: dict[str, Any] | None = None,
|
||||
topology: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a copy with deterministic status and readiness information."""
|
||||
checked = validate_feature_plan(plan)
|
||||
present_features = {
|
||||
str(feature.get("id"))
|
||||
for feature in (cdsl or {}).get("features") or ()
|
||||
if isinstance(feature, dict) and feature.get("id")
|
||||
}
|
||||
has_topology = _topology_available(topology)
|
||||
topology_snapshot_id = str((topology or {}).get("snapshot_id") or "")
|
||||
selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id)
|
||||
by_id = {node["id"]: node for node in checked["nodes"]}
|
||||
result_nodes: list[dict[str, Any]] = []
|
||||
for original in checked["nodes"]:
|
||||
node = deepcopy(original)
|
||||
if node["status"] in {"failed", "blocked"}:
|
||||
result_nodes.append(node)
|
||||
continue
|
||||
if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]):
|
||||
node["status"] = "completed"
|
||||
result_nodes.append(node)
|
||||
continue
|
||||
dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all(
|
||||
feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"]
|
||||
) for item in node["depends_on"])
|
||||
if not dependencies_done:
|
||||
node["status"] = "planned"
|
||||
elif node["requires_topology"] and not has_topology:
|
||||
node["status"] = "waiting_for_topology"
|
||||
elif node["requires_topology"] and not selection_ready:
|
||||
node["status"] = "waiting_for_selection"
|
||||
else:
|
||||
node["status"] = "ready"
|
||||
result_nodes.append(node)
|
||||
ready = [node["id"] for node in result_nodes if node["status"] == "ready"]
|
||||
waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}]
|
||||
blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"]
|
||||
completed = [node["id"] for node in result_nodes if node["status"] == "completed"]
|
||||
return {
|
||||
**checked,
|
||||
"nodes": result_nodes,
|
||||
"ready_nodes": ready,
|
||||
"waiting_nodes": waiting,
|
||||
"blocked_nodes": blocked,
|
||||
"completed_nodes": completed,
|
||||
"complete": len(completed) == len(result_nodes) and not blocked,
|
||||
}
|
||||
|
||||
|
||||
def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]:
|
||||
checked = validate_feature_plan(plan)
|
||||
wanted = set(node_ids)
|
||||
return {
|
||||
feature_id
|
||||
for node in checked["nodes"]
|
||||
if node["id"] in wanted
|
||||
for feature_id in node["cdsl_feature_ids"]
|
||||
}
|
||||
|
||||
|
||||
def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None:
|
||||
checked = validate_feature_plan(plan)
|
||||
return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None)
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Strict contracts for persistent, node-by-node CAD generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
from app.services.feature_plan import FeaturePlanError, validate_feature_plan
|
||||
|
||||
|
||||
GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2"
|
||||
BACKEND_ID_STRATEGY = "backend-derived-v1"
|
||||
REQUIREMENT_SOURCES = {"explicit", "assumption"}
|
||||
REQUIREMENT_PRIORITIES = {"hard", "soft"}
|
||||
# Keep this in sync with the engine's profile_schema.json. The plan is
|
||||
# deliberately atomic: an operation that consumes a profile owns one new
|
||||
# sketch, while all other operations own none.
|
||||
SKETCH_REQUIRED_ATOMICS = {
|
||||
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
|
||||
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add",
|
||||
}
|
||||
|
||||
|
||||
class GenerationPlanError(ValueError):
|
||||
"""The authoring plan cannot safely drive an incremental build."""
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, required: bool = True) -> str:
|
||||
result = str(value or "").strip()
|
||||
if required and not result:
|
||||
raise GenerationPlanError(f"{field} is required")
|
||||
return result
|
||||
|
||||
|
||||
def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]:
|
||||
if value is None:
|
||||
value = []
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
|
||||
raise GenerationPlanError(f"{field} must be an array of non-empty strings")
|
||||
result = list(dict.fromkeys(item.strip() for item in value))
|
||||
if required and not result:
|
||||
raise GenerationPlanError(f"{field} must not be empty")
|
||||
return result
|
||||
|
||||
|
||||
def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise GenerationPlanError(f"requirements[{index}] must be an object")
|
||||
source = _text(raw.get("source") or "assumption", f"requirements[{index}].source")
|
||||
priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority")
|
||||
if source not in REQUIREMENT_SOURCES:
|
||||
raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}")
|
||||
if priority not in REQUIREMENT_PRIORITIES:
|
||||
raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}")
|
||||
return {
|
||||
"id": _text(raw.get("id"), f"requirements[{index}].id"),
|
||||
"source": source,
|
||||
"priority": priority,
|
||||
"description": _text(raw.get("description"), f"requirements[{index}].description"),
|
||||
"value": deepcopy(raw.get("value")),
|
||||
"unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False),
|
||||
"tolerance": deepcopy(raw.get("tolerance")),
|
||||
}
|
||||
|
||||
|
||||
def _backend_cdsl_id(kind: str, node_id: str) -> str:
|
||||
"""Create a valid, stable CDSL identifier without trusting model naming."""
|
||||
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node"
|
||||
digest = sha256(node_id.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{kind}_{slug[:60]}_{digest}"
|
||||
|
||||
|
||||
def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]:
|
||||
feature_ids = [_backend_cdsl_id("feature", node_id)]
|
||||
sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else []
|
||||
return feature_ids, sketch_ids
|
||||
|
||||
|
||||
def _stored_plan_ids(raw: dict[str, Any]) -> bool:
|
||||
"""Retain IDs of plans already materialised by an earlier backend version."""
|
||||
return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict)
|
||||
|
||||
|
||||
def validate_generation_plan(
|
||||
document: dict[str, Any],
|
||||
*,
|
||||
supported_atomic_ids: Iterable[str] = (),
|
||||
task_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Normalise a planner response and prove every hard requirement is owned."""
|
||||
if not isinstance(document, dict):
|
||||
raise GenerationPlanError("Generation plan must be an object")
|
||||
version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION)
|
||||
if version != GENERATION_PLAN_SCHEMA_VERSION:
|
||||
raise GenerationPlanError(f"Unsupported generation plan schema: {version}")
|
||||
requirements_raw = document.get("requirements")
|
||||
if not isinstance(requirements_raw, list) or not requirements_raw:
|
||||
raise GenerationPlanError("Generation plan requires a non-empty requirements array")
|
||||
requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)]
|
||||
requirement_ids = [item["id"] for item in requirements]
|
||||
if len(requirement_ids) != len(set(requirement_ids)):
|
||||
raise GenerationPlanError("Generation plan has duplicate requirement ids")
|
||||
|
||||
raw_nodes = document.get("nodes")
|
||||
if not isinstance(raw_nodes, list) or not raw_nodes:
|
||||
raise GenerationPlanError("Generation plan requires a non-empty nodes array")
|
||||
preserve_stored_ids = _stored_plan_ids(document)
|
||||
feature_nodes: list[dict[str, Any]] = []
|
||||
node_metadata: dict[str, dict[str, Any]] = {}
|
||||
sketch_owner: dict[str, str] = {}
|
||||
for index, raw in enumerate(raw_nodes):
|
||||
if not isinstance(raw, dict):
|
||||
raise GenerationPlanError(f"nodes[{index}] must be an object")
|
||||
node_id = _text(raw.get("id"), f"nodes[{index}].id")
|
||||
atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id")
|
||||
if preserve_stored_ids:
|
||||
feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True)
|
||||
sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids")
|
||||
else:
|
||||
# New plans own semantic node IDs only. CDSL object IDs are a
|
||||
# deterministic backend implementation detail, not model output.
|
||||
feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id)
|
||||
for sketch_id in sketch_ids:
|
||||
previous = sketch_owner.get(sketch_id)
|
||||
if previous:
|
||||
raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})")
|
||||
sketch_owner[sketch_id] = node_id
|
||||
coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids")
|
||||
unknown = sorted(set(coverage) - set(requirement_ids))
|
||||
if unknown:
|
||||
raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}")
|
||||
rules = raw.get("verification_rules") or []
|
||||
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
||||
raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects")
|
||||
targets = raw.get("review_targets") or []
|
||||
if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets):
|
||||
raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects")
|
||||
feature_nodes.append({
|
||||
"id": node_id,
|
||||
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
|
||||
"atomic_id": atomic_id,
|
||||
"depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"),
|
||||
"requires_topology": raw.get("requires_topology") is True,
|
||||
"topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None,
|
||||
"cdsl_feature_ids": feature_ids,
|
||||
})
|
||||
node_metadata[node_id] = {
|
||||
"cdsl_sketch_ids": sketch_ids,
|
||||
"requires_sketch": bool(sketch_ids),
|
||||
"requirement_ids": coverage,
|
||||
"verification_rules": deepcopy(rules),
|
||||
"review_targets": deepcopy(targets),
|
||||
"attempts": {"authoring": 0, "repair": 0, "replan": 0},
|
||||
}
|
||||
try:
|
||||
feature_plan = validate_feature_plan({
|
||||
"schema_version": "cad.feature-plan.v1",
|
||||
"plan_id": document.get("plan_id"),
|
||||
"task_id": task_id or document.get("task_id"),
|
||||
"nodes": feature_nodes,
|
||||
}, supported_atomic_ids=supported_atomic_ids)
|
||||
except FeaturePlanError as error:
|
||||
raise GenerationPlanError(str(error)) from error
|
||||
|
||||
covered = {
|
||||
requirement_id
|
||||
for metadata in node_metadata.values()
|
||||
for requirement_id in metadata["requirement_ids"]
|
||||
}
|
||||
uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered]
|
||||
if uncovered:
|
||||
raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered))
|
||||
nodes = []
|
||||
for node in feature_plan["nodes"]:
|
||||
nodes.append({**node, **node_metadata[node["id"]]})
|
||||
return {
|
||||
"schema_version": GENERATION_PLAN_SCHEMA_VERSION,
|
||||
"id_strategy": BACKEND_ID_STRATEGY,
|
||||
"plan_id": feature_plan["plan_id"],
|
||||
"task_id": task_id or feature_plan["task_id"],
|
||||
"requirements": requirements,
|
||||
"assumptions": _string_list(document.get("assumptions"), "assumptions"),
|
||||
"nodes": nodes,
|
||||
"feature_owner": feature_plan["feature_owner"],
|
||||
"sketch_owner": sketch_owner,
|
||||
}
|
||||
|
||||
|
||||
def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]:
|
||||
"""Return one node and every node whose model depends on it."""
|
||||
nodes = plan.get("nodes") if isinstance(plan, dict) else None
|
||||
if not isinstance(nodes, list):
|
||||
raise GenerationPlanError("Generation plan has no nodes")
|
||||
children: dict[str, set[str]] = defaultdict(set)
|
||||
known = {str(node.get("id")) for node in nodes if isinstance(node, dict)}
|
||||
if root_node_id not in known:
|
||||
raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}")
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for dependency in node.get("depends_on") or ():
|
||||
children[str(dependency)].add(str(node.get("id")))
|
||||
result: set[str] = set()
|
||||
queue: deque[str] = deque([root_node_id])
|
||||
while queue:
|
||||
node_id = queue.popleft()
|
||||
if node_id in result:
|
||||
continue
|
||||
result.add(node_id)
|
||||
queue.extend(sorted(children[node_id] - result))
|
||||
return result
|
||||
|
||||
|
||||
def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]:
|
||||
"""Invalidate a node/subtree after an upstream geometry change or rollback."""
|
||||
updated = deepcopy(plan)
|
||||
stale = descendant_closure(updated, root_node_id)
|
||||
for node in updated.get("nodes") or ():
|
||||
if isinstance(node, dict) and str(node.get("id")) in stale:
|
||||
node["status"] = "planned"
|
||||
node["stale"] = True
|
||||
node["stale_reason"] = reason
|
||||
node.pop("topology_snapshot_id", None)
|
||||
return updated
|
||||
@@ -1,509 +0,0 @@
|
||||
"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment
|
||||
from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl
|
||||
from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan
|
||||
from app.services.quality import validate_verification
|
||||
from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status
|
||||
from app.services.storage import WorkspaceStore, write_json
|
||||
from app.services.visual_review import VisualReviewError, review_checkpoint
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
|
||||
|
||||
Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
PLAN_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plan_generation_task",
|
||||
"description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "const": "cad.generation-plan.v2"},
|
||||
"plan_id": {"type": "string", "minLength": 1},
|
||||
"requirements": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"source": {"enum": ["explicit", "assumption"]},
|
||||
"priority": {"enum": ["hard", "soft"]},
|
||||
"description": {"type": "string", "minLength": 1},
|
||||
"value": {},
|
||||
"unit": {"type": "string"},
|
||||
"tolerance": {},
|
||||
},
|
||||
"required": ["id", "source", "priority", "description"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"assumptions": {"type": "array", "items": {"type": "string"}},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"intent": {"type": "string"},
|
||||
"atomic_id": {"type": "string", "minLength": 1},
|
||||
"depends_on": {"type": "array", "items": {"type": "string"}},
|
||||
"requires_topology": {"type": "boolean"},
|
||||
"topology_query": {"type": "object"},
|
||||
"requirement_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"verification_rules": {"type": "array", "items": {"type": "object"}},
|
||||
"review_targets": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
"required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
FRAGMENT_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_cdsl_fragment",
|
||||
"description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"},
|
||||
"node_id": {"type": "string", "minLength": 1},
|
||||
"base_revision_id": {"type": "string"},
|
||||
"base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64},
|
||||
"required_snapshot_id": {"type": "string"},
|
||||
"add_sketches": {"type": "array", "items": {"type": "object"}},
|
||||
"add_features": {"type": "array", "items": {"type": "object"}},
|
||||
"verification_rules": {"type": "array", "items": {"type": "object"}},
|
||||
"assumptions": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class IncrementalGenerationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]:
|
||||
try:
|
||||
call = response["choices"][0]["message"]["tool_calls"][0]
|
||||
if call["function"]["name"] != expected_name:
|
||||
raise KeyError("wrong tool")
|
||||
result = json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
||||
raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error
|
||||
if not isinstance(result, dict):
|
||||
raise IncrementalGenerationError(f"{expected_name} arguments must be an object")
|
||||
return result
|
||||
|
||||
|
||||
def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
|
||||
if node is None:
|
||||
raise IncrementalGenerationError(f"Generation plan has no node {node_id}")
|
||||
return node
|
||||
|
||||
|
||||
def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Expose semantic node intent, not backend-owned CDSL implementation IDs."""
|
||||
fields = (
|
||||
"id", "intent", "atomic_id", "depends_on", "requires_topology",
|
||||
"requires_sketch", "topology_query", "requirement_ids",
|
||||
"verification_rules", "review_targets",
|
||||
)
|
||||
return {field: deepcopy(node[field]) for field in fields if field in node}
|
||||
|
||||
|
||||
def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None:
|
||||
for node in spec.get("nodes") or ():
|
||||
if not isinstance(node, dict) or node.get("status") == "completed":
|
||||
continue
|
||||
if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _error_code(error: Exception) -> str:
|
||||
message = str(error)
|
||||
for code in (
|
||||
"SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH",
|
||||
"TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED",
|
||||
):
|
||||
if code in message:
|
||||
return code
|
||||
return type(error).__name__.upper()
|
||||
|
||||
|
||||
def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]:
|
||||
"""Invalidate the union of every affected node's downstream closure."""
|
||||
updated = deepcopy(plan)
|
||||
stale: set[str] = set()
|
||||
for node_id in dict.fromkeys(node_ids):
|
||||
stale.update(descendant_closure(updated, node_id))
|
||||
updated = mark_nodes_stale(updated, node_id, reason=reason)
|
||||
return updated, stale
|
||||
|
||||
|
||||
def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]:
|
||||
conversation_id = str(conversation.get("conversation_id") or "")
|
||||
paths: list[Path] = []
|
||||
for attachment in conversation.get("attachments") or ():
|
||||
if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id:
|
||||
continue
|
||||
try:
|
||||
paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or "")))
|
||||
except ValueError:
|
||||
continue
|
||||
return paths
|
||||
|
||||
|
||||
class IncrementalGenerationRunner:
|
||||
"""The agent-facing controller. It is deliberately full-rebuild and restart-safe."""
|
||||
|
||||
def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None:
|
||||
self.settings = settings
|
||||
self.store = store
|
||||
self._complete = complete
|
||||
|
||||
async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
response = await self._complete(messages, [tool], provider, model, name)
|
||||
return _tool_response(response, name)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
request: str,
|
||||
conversation: dict[str, Any],
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
author_messages: list[dict[str, Any]],
|
||||
part_skills: dict[str, Any] | None = None,
|
||||
references: list[str] | None = None,
|
||||
already_started: bool = False,
|
||||
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||
plan_diagnostic_path = ""
|
||||
try:
|
||||
task = self.store.ensure_task(task_id or None, request)
|
||||
task_id = str(task["task_id"])
|
||||
# Configuration is a start gate: visual review is required, never silently skipped.
|
||||
self.settings.resolve_review_model()
|
||||
renderer_ready, renderer_error = renderer_status()
|
||||
if not renderer_ready:
|
||||
raise IncrementalGenerationError(renderer_error)
|
||||
task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request)
|
||||
if not isinstance(task, dict):
|
||||
raise IncrementalGenerationError("Generation task is unavailable")
|
||||
yield "generation_plan", {"taskId": task_id, "status": "running"}
|
||||
engine = load_engine(self.settings)
|
||||
persisted_spec = self.store.read_generation_spec(task_id)
|
||||
if persisted_spec is not None:
|
||||
spec = validate_generation_plan(
|
||||
persisted_spec,
|
||||
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
|
||||
task_id=task_id,
|
||||
)
|
||||
yield "generation_plan", {
|
||||
"taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True,
|
||||
"requirements": spec["requirements"],
|
||||
"nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]],
|
||||
}
|
||||
else:
|
||||
planning_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Create one complete cad.generation-plan.v2 before creating geometry. "
|
||||
"Turn every user constraint into a requirement with source explicit or assumption; "
|
||||
"use source assumption for missing dimensions and never ask the user questions. "
|
||||
"Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. "
|
||||
"Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id."
|
||||
),
|
||||
},
|
||||
*author_messages,
|
||||
]
|
||||
raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task")
|
||||
try:
|
||||
spec = validate_generation_plan(
|
||||
raw_spec,
|
||||
supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()),
|
||||
task_id=task_id,
|
||||
)
|
||||
except GenerationPlanError as error:
|
||||
plan_diagnostic_path = self.store.write_generation_failure(task_id, {
|
||||
"schema_version": "cad.generation-plan-diagnostic.v1",
|
||||
"stage": "generation_plan_validation",
|
||||
"message": str(error),
|
||||
"raw_plan": raw_spec,
|
||||
})
|
||||
raise
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
yield "generation_plan", {
|
||||
"taskId": task_id, "status": "success", "planId": spec["plan_id"],
|
||||
"requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]],
|
||||
}
|
||||
|
||||
completed: set[str] = {
|
||||
str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed"
|
||||
}
|
||||
last_built: dict[str, Any] | None = None
|
||||
task = self.store.read_task(task_id) or task
|
||||
active_revision_id = str(task.get("active_revision") or "")
|
||||
# A process can stop between build and review. That checkpoint is
|
||||
# not a legal base revision, so recover its parent before resuming.
|
||||
active_record = next(
|
||||
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id),
|
||||
None,
|
||||
)
|
||||
if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint":
|
||||
current_node = str(active_record.get("node_id") or "")
|
||||
if current_node and current_node not in completed:
|
||||
recovered = str(active_record.get("parent_revision_id") or "")
|
||||
self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
active_revision_id = recovered
|
||||
task = self.store.read_task(task_id) or task
|
||||
base_path = self.store.current_cdsl_path(task_id)
|
||||
base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None
|
||||
while True:
|
||||
topology_path = self.store.current_topology_path(task_id)
|
||||
topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None
|
||||
node = _ready_node(spec, completed, bool(topology and topology.get("records")))
|
||||
if node is None:
|
||||
if len(completed) == len(spec["nodes"]):
|
||||
self.store.finish_generation(task_id, lifecycle="completed")
|
||||
if last_built is not None:
|
||||
yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False)
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")}
|
||||
return
|
||||
waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed]
|
||||
raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting))
|
||||
|
||||
node_id = str(node["id"])
|
||||
self.store.set_active_node(task_id, node_id)
|
||||
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"}
|
||||
attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0})
|
||||
feedback = ""
|
||||
while True:
|
||||
attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair"
|
||||
if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts:
|
||||
if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts:
|
||||
raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}")
|
||||
attempts["replan"] = int(attempts.get("replan") or 0) + 1
|
||||
spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine)
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
node = _node_by_id(spec, node_id)
|
||||
node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]}
|
||||
attempts = node["attempts"]
|
||||
yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"}
|
||||
continue
|
||||
attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1
|
||||
required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else ""
|
||||
node_requirement_ids = set(node.get("requirement_ids") or ())
|
||||
author_context = {
|
||||
"active_node": _fragment_node_context(node),
|
||||
"requirements": [
|
||||
requirement for requirement in spec["requirements"]
|
||||
if requirement.get("id") in node_requirement_ids
|
||||
],
|
||||
"base_revision_id": active_revision_id,
|
||||
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
|
||||
"required_snapshot_id": required_snapshot,
|
||||
"base_cdsl": base_cdsl,
|
||||
"topology": topology if required_snapshot else None,
|
||||
"previous_failure": feedback,
|
||||
}
|
||||
fragment_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. "
|
||||
"Do not set output id, sketch_id, or depends_on: the backend assigns them. "
|
||||
"Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes."
|
||||
),
|
||||
},
|
||||
*author_messages,
|
||||
{"role": "user", "content": json.dumps(author_context, ensure_ascii=False)},
|
||||
]
|
||||
try:
|
||||
raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment")
|
||||
fragment = validate_fragment(
|
||||
raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id,
|
||||
base_cdsl=base_cdsl, required_snapshot_id=required_snapshot,
|
||||
)
|
||||
cdsl = materialize_fragment(base_cdsl, fragment)
|
||||
cdsl, repairs = normalize_cdsl_for_engine(cdsl)
|
||||
validate_cdsl(cdsl, engine)
|
||||
rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])]
|
||||
verification = {"rules": rules} if rules else None
|
||||
validate_verification(verification, cdsl)
|
||||
fragment_base_revision = str(fragment["base_revision_id"])
|
||||
built = await asyncio.to_thread(
|
||||
build_revision,
|
||||
settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl,
|
||||
reference_ids=references or [], summary=node.get("intent") or node_id,
|
||||
parent_revision_id=active_revision_id or None,
|
||||
operation={"type": "cdsl_fragment", "node_id": node_id},
|
||||
part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])],
|
||||
verification=verification, node_id=node_id, fragment=fragment,
|
||||
branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint",
|
||||
)
|
||||
candidate_revision_id = str(built["revision_id"])
|
||||
try:
|
||||
render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review"
|
||||
manifest = await asyncio.to_thread(
|
||||
render_checkpoint,
|
||||
self.settings,
|
||||
step_path=self.store.artifact_path(task_id, str(built["step_path"])),
|
||||
output_dir=render_dir,
|
||||
review_targets=node.get("review_targets"),
|
||||
)
|
||||
final_checkpoint = len(completed) + 1 == len(spec["nodes"])
|
||||
review_requirements = spec["requirements"] if final_checkpoint else [
|
||||
requirement for requirement in spec["requirements"]
|
||||
if requirement.get("id") in set(node.get("requirement_ids") or ())
|
||||
]
|
||||
review = await review_checkpoint(
|
||||
self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id,
|
||||
deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})},
|
||||
source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [],
|
||||
final_checkpoint=final_checkpoint,
|
||||
)
|
||||
except (ReviewRenderError, VisualReviewError) as error:
|
||||
self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}")
|
||||
active_revision_id = fragment_base_revision
|
||||
rollback_path = self.store.current_cdsl_path(task_id)
|
||||
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
|
||||
raise error
|
||||
active_revision_id = candidate_revision_id
|
||||
base_cdsl = cdsl
|
||||
manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix()
|
||||
review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix()
|
||||
write_json(render_dir / "visual-review.json", review)
|
||||
self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative})
|
||||
yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review}
|
||||
if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85:
|
||||
affected_nodes = [
|
||||
str(item) for item in review.get("affected_node_ids") or ()
|
||||
if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ())
|
||||
] or [node_id]
|
||||
rollback_base = self.store.rollback_anchor_for_nodes(
|
||||
task_id,
|
||||
affected_nodes,
|
||||
fallback_revision_id=fragment_base_revision,
|
||||
)
|
||||
branch = f"branch_{secrets.token_hex(4)}"
|
||||
self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch)
|
||||
spec, stale_nodes = _mark_affected_nodes_stale(
|
||||
spec, affected_nodes, reason="high_confidence_visual_review",
|
||||
)
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
completed.difference_update(stale_nodes)
|
||||
active_revision_id = rollback_base
|
||||
rollback_path = self.store.current_cdsl_path(task_id)
|
||||
base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None
|
||||
feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or [])
|
||||
yield "rollback", {
|
||||
"taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id,
|
||||
"reason": "visual_review", "affectedNodeIds": affected_nodes,
|
||||
}
|
||||
continue
|
||||
completed.add(node_id)
|
||||
node["status"] = "completed"
|
||||
node.pop("stale", None)
|
||||
self.store.write_generation_spec(task_id, spec)
|
||||
last_built = built
|
||||
payload = self._result_payload(built, lifecycle="running", checkpoint=True)
|
||||
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id}
|
||||
yield "cad_result", payload
|
||||
break
|
||||
except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error:
|
||||
feedback = str(error)
|
||||
failure = {
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"node_id": node_id,
|
||||
"stage": attempt_kind,
|
||||
"error_code": _error_code(error),
|
||||
"message": feedback,
|
||||
"requirement_ids": list(node.get("requirement_ids") or ()),
|
||||
"selector": {"required_snapshot_id": required_snapshot},
|
||||
"geometry_delta": {},
|
||||
"recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id,
|
||||
}
|
||||
failure_path = self.store.write_generation_failure(task_id, failure)
|
||||
yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback}
|
||||
# A failed build never becomes the active base; retries are safe and deterministic.
|
||||
continue
|
||||
except Exception as error:
|
||||
failure = {
|
||||
"schema_version": "cad.generation-failure.v1",
|
||||
"message": str(error),
|
||||
"active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""),
|
||||
}
|
||||
if plan_diagnostic_path:
|
||||
failure["plan_diagnostic_path"] = plan_diagnostic_path
|
||||
self.store.finish_generation(task_id, lifecycle="failed", failure=failure)
|
||||
yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)}
|
||||
|
||||
async def _replan(
|
||||
self,
|
||||
spec: dict[str, Any],
|
||||
node_id: str,
|
||||
feedback: str,
|
||||
provider: ProviderConfig,
|
||||
model: ProviderModel,
|
||||
author_messages: list[dict[str, Any]],
|
||||
engine: Any,
|
||||
) -> dict[str, Any]:
|
||||
raw = await self._call([
|
||||
{"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."},
|
||||
*author_messages,
|
||||
{"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)},
|
||||
], provider, model, PLAN_TOOL, "plan_generation_task")
|
||||
next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or ""))
|
||||
stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")}
|
||||
prior = {item["id"]: item for item in spec.get("nodes") or []}
|
||||
for node in next_spec["nodes"]:
|
||||
if node["id"] not in stale and node["id"] in prior:
|
||||
old = prior[node["id"]]
|
||||
for key in ("atomic_id", "depends_on", "cdsl_feature_ids"):
|
||||
if node.get(key) != old.get(key):
|
||||
raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}")
|
||||
return next_spec
|
||||
|
||||
@staticmethod
|
||||
def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]:
|
||||
return {
|
||||
"taskId": built["task_id"], "revisionId": built["revision_id"],
|
||||
"cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"],
|
||||
"reportPath": built["report_path"], "parametersPath": built.get("parameters_path"),
|
||||
"selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"),
|
||||
"summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [],
|
||||
"engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "",
|
||||
"qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [],
|
||||
"snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable",
|
||||
"lifecycle": lifecycle, "checkpoint": checkpoint,
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
_WORD = re.compile(r"[a-z0-9]+(?:[-'][a-z0-9]+)*")
|
||||
_REPLACEMENT_INTENT = (
|
||||
"replace the whole part",
|
||||
"replace entire part",
|
||||
"replace the part",
|
||||
"replace this part",
|
||||
"start over as",
|
||||
"replace with a",
|
||||
"替换整个零件",
|
||||
"替换整个部件",
|
||||
"替换零件",
|
||||
"重新生成一个",
|
||||
"改成一个新的",
|
||||
)
|
||||
|
||||
|
||||
def _normalize(value: str) -> str:
|
||||
return unicodedata.normalize("NFKC", str(value or "")).casefold().strip()
|
||||
|
||||
|
||||
def _matches(text: str, phrase: str) -> bool:
|
||||
normalized_text = _normalize(text)
|
||||
normalized_phrase = _normalize(phrase)
|
||||
if not normalized_phrase:
|
||||
return False
|
||||
if any("\u4e00" <= char <= "\u9fff" for char in normalized_phrase):
|
||||
return normalized_phrase in normalized_text
|
||||
words = _WORD.findall(normalized_phrase)
|
||||
if not words:
|
||||
return normalized_phrase in normalized_text
|
||||
return bool(re.search(r"(?<![a-z0-9])" + r"\s+".join(map(re.escape, words)) + r"(?![a-z0-9])", normalized_text))
|
||||
|
||||
|
||||
def _specificity(phrase: str) -> int:
|
||||
normalized = _normalize(phrase)
|
||||
chinese = [char for char in normalized if "\u4e00" <= char <= "\u9fff"]
|
||||
return len(chinese) if chinese else len(_WORD.findall(normalized))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PartSkill:
|
||||
id: str
|
||||
kind: str
|
||||
title: str
|
||||
priority: int
|
||||
triggers: tuple[str, ...]
|
||||
exclude: tuple[str, ...]
|
||||
bridge: str
|
||||
source: str
|
||||
related: tuple[str, ...]
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> "PartSkill":
|
||||
return cls(
|
||||
id=str(value["id"]),
|
||||
kind=str(value["kind"]),
|
||||
title=str(value.get("title") or value["id"]),
|
||||
priority=int(value.get("priority") or 0),
|
||||
triggers=tuple(str(item) for item in value.get("triggers") or []),
|
||||
exclude=tuple(str(item) for item in value.get("exclude") or []),
|
||||
bridge=str(value["bridge"]),
|
||||
source=str(value["source"]),
|
||||
related=tuple(str(item) for item in value.get("related") or []),
|
||||
)
|
||||
|
||||
|
||||
class PartSkillLibrary:
|
||||
"""Deterministic local selector for CDSL-specific part planning guidance."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root)
|
||||
payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))
|
||||
self.catalog_version = str(payload.get("schema_version") or "1.0")
|
||||
self.max_planning = int(payload.get("max_planning") or 1)
|
||||
self.max_support = int(payload.get("max_support") or 3)
|
||||
self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or [])
|
||||
self.by_id = {skill.id: skill for skill in self.skills}
|
||||
if len(self.by_id) != len(self.skills):
|
||||
raise ValueError("Part skill catalog contains duplicate ids")
|
||||
for skill in self.skills:
|
||||
for relative in (skill.bridge, skill.source):
|
||||
if not (self.root / relative).is_file():
|
||||
raise ValueError(f"Part skill {skill.id} references missing file: {relative}")
|
||||
|
||||
def _matched(self, skill: PartSkill, request: str) -> tuple[int, tuple[str, ...]]:
|
||||
if any(_matches(request, phrase) for phrase in skill.exclude):
|
||||
return 0, ()
|
||||
triggers = tuple(trigger for trigger in skill.triggers if _matches(request, trigger))
|
||||
if not triggers:
|
||||
return 0, ()
|
||||
# Longer phrases are more specific than generic words such as "shaft"
|
||||
# or "hole". Priority breaks ties between equally specific skills.
|
||||
score = sum(max(1, _specificity(trigger)) * 10 for trigger in triggers)
|
||||
return score + skill.priority, triggers
|
||||
|
||||
def _record(self, skill: PartSkill, *, selection: str, matched: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
return {
|
||||
"id": skill.id,
|
||||
"kind": skill.kind,
|
||||
"category": skill.kind,
|
||||
"title": skill.title,
|
||||
"version": self.catalog_version,
|
||||
"summary": skill.title,
|
||||
"source": skill.source,
|
||||
"bridge": skill.bridge,
|
||||
"selection": selection,
|
||||
"matched_triggers": list(matched),
|
||||
}
|
||||
|
||||
def select(self, request: str, inherited_ids: list[str] | tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
inherited = [self.by_id[item] for item in inherited_ids if item in self.by_id]
|
||||
scores = [(self._matched(skill, request), skill) for skill in self.skills]
|
||||
planning_matches = sorted(
|
||||
((score, triggers, skill) for (score, triggers), skill in scores if skill.kind == "planning" and score),
|
||||
key=lambda item: (-item[0], -item[2].priority, item[2].id),
|
||||
)
|
||||
inherited_planning = [skill for skill in inherited if skill.kind == "planning"]
|
||||
planning: list[PartSkill] = inherited_planning[: self.max_planning]
|
||||
conflict: dict[str, Any] | None = None
|
||||
replacement_requested = any(_matches(request, phrase) for phrase in _REPLACEMENT_INTENT)
|
||||
if not planning and planning_matches:
|
||||
planning = [planning_matches[0][2]]
|
||||
elif planning and planning_matches and planning_matches[0][2].id not in {skill.id for skill in planning}:
|
||||
if replacement_requested:
|
||||
planning = [planning_matches[0][2]]
|
||||
else:
|
||||
conflict = {
|
||||
"current": planning[0].id,
|
||||
"matched": planning_matches[0][2].id,
|
||||
"message": "A different primary part family matched this revision request; preserve the current family unless replacement is explicit.",
|
||||
}
|
||||
|
||||
selected_ids = {skill.id for skill in planning}
|
||||
inherited_planning_ids = {skill.id for skill in inherited_planning}
|
||||
records = [
|
||||
self._record(
|
||||
skill,
|
||||
selection="inherited" if skill.id in inherited_planning_ids else "selected",
|
||||
matched=next((triggers for score, triggers, candidate in planning_matches if candidate.id == skill.id), ()),
|
||||
)
|
||||
for skill in planning
|
||||
]
|
||||
support: list[tuple[int, int, int, str, PartSkill, tuple[str, ...]]] = []
|
||||
related_ids = {related for skill in planning for related in skill.related}
|
||||
direct_support: list[tuple[int, PartSkill, tuple[str, ...]]] = []
|
||||
for (score, triggers), skill in scores:
|
||||
if skill.kind == "planning" or not score:
|
||||
continue
|
||||
direct_support.append((score, skill, triggers))
|
||||
bonus = 100 if skill.id in related_ids else 0
|
||||
# Direct request matches always win over inherited context, even
|
||||
# when the current planning family's catalog does not name them.
|
||||
support.append((3, bonus + score, skill.priority, skill.id, skill, triggers))
|
||||
direct_related_ids = {
|
||||
related
|
||||
for _, skill, _ in direct_support
|
||||
for related in skill.related
|
||||
if related in self.by_id and self.by_id[related].kind != "planning"
|
||||
}
|
||||
known_support_ids = {item[3] for item in support}
|
||||
for related_id in sorted(direct_related_ids - known_support_ids):
|
||||
skill = self.by_id[related_id]
|
||||
planning_bonus = 25 if skill.id in related_ids else 0
|
||||
support.append((2, 50 + planning_bonus, skill.priority, skill.id, skill, ()))
|
||||
known_support_ids.add(skill.id)
|
||||
for skill in inherited:
|
||||
if skill.kind != "planning" and skill.id not in known_support_ids:
|
||||
# Keep prior guidance only after direct request matches and
|
||||
# their catalog-declared supporting rules.
|
||||
support.append((1, 0, skill.priority, skill.id, skill, ()))
|
||||
known_support_ids.add(skill.id)
|
||||
support.sort(key=lambda item: (-item[0], -item[1], -item[2], item[3]))
|
||||
for _, _, _, _, skill, triggers in support:
|
||||
if len(records) - len(planning) >= self.max_support or skill.id in selected_ids:
|
||||
continue
|
||||
selected_ids.add(skill.id)
|
||||
inherited_marker = "inherited" if skill in inherited else "selected"
|
||||
records.append(self._record(skill, selection=inherited_marker, matched=triggers))
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"request": str(request or ""),
|
||||
"skills": records,
|
||||
"skill_ids": [record["id"] for record in records],
|
||||
"planning_ids": [record["id"] for record in records if record["kind"] == "planning"],
|
||||
"support_ids": [record["id"] for record in records if record["kind"] != "planning"],
|
||||
"inherited_skill_ids": [skill.id for skill in inherited],
|
||||
"replacement_requested": replacement_requested,
|
||||
"conflict": conflict,
|
||||
}
|
||||
|
||||
def inherited_from_task(self, task: dict[str, Any] | None) -> list[str]:
|
||||
revision_id = str((task or {}).get("current_revision") or "")
|
||||
revisions = (task or {}).get("revisions") or []
|
||||
revision = next((item for item in revisions if item.get("revision_id") == revision_id), None)
|
||||
revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id]
|
||||
if revision_ids:
|
||||
return revision_ids
|
||||
return []
|
||||
|
||||
def render_context(self, selection: dict[str, Any]) -> str:
|
||||
records = selection.get("skills") or []
|
||||
if not records:
|
||||
return "No part-family skill matched this request. Use the CDSL schema and library only."
|
||||
sections = [
|
||||
"Selected CDSL part-skill guidance (planning context only):",
|
||||
"Part skill guidance never overrides the user request or the authoritative CDSL schema/runtime.",
|
||||
"Translate the guidance into valid CDSL; do not emit build123d source or invent atomics.",
|
||||
]
|
||||
for record in records:
|
||||
path = self.root / str(record["bridge"])
|
||||
sections.append(f"\n[{record['id']}] ({record['selection']})\n{path.read_text(encoding='utf-8').strip()}")
|
||||
if selection.get("conflict"):
|
||||
sections.append("\nPrimary-family conflict: preserve the current task family. Ask one concise clarification question and do not generate until the user explicitly requests whole-part replacement or confirms the current family.")
|
||||
return "\n".join(sections)
|
||||
|
||||
def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]:
|
||||
# Skills are prompt knowledge only. Do not interpret a selected skill
|
||||
# as geometry, capability translation, or an executable model rule.
|
||||
del cdsl
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"request": selection.get("request", ""),
|
||||
"structural_intent": selection.get("request", ""),
|
||||
"skill_ids": list(selection.get("skill_ids") or []),
|
||||
"skills": list(selection.get("skills") or []),
|
||||
"inherited_skill_ids": list(selection.get("inherited_skill_ids") or []),
|
||||
"conflict": selection.get("conflict"),
|
||||
"assumptions": [str(item) for item in assumptions or []],
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
"""Generic, model-family-independent verification for direct CDSL revisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
QUALITY_RULE_TYPES = frozenset({
|
||||
"bbox", "solid_count", "feature_count", "hole_count", "hole_diameter",
|
||||
"hole_center", "overall_length", "overall_width", "overall_height",
|
||||
"overall_diameter", "through_condition",
|
||||
})
|
||||
FEATURE_RULE_TYPES = frozenset({"hole_count", "hole_diameter", "hole_center", "through_condition"})
|
||||
_SEVERITIES = {"blocking", "warning", "informational"}
|
||||
|
||||
|
||||
def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None:
|
||||
"""Return a whole-model or feature-owned runtime bounding box."""
|
||||
candidates: list[Any] = []
|
||||
if feature_id:
|
||||
for record in engine_result.get("topology_records") or []:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
owners = record.get("owner_feature_ids") or []
|
||||
# Runtime records may carry the current operation in feature_id
|
||||
# while owner_feature_ids identify the actual geometry owner. Use
|
||||
# ownership when present so inherited faces do not pollute a
|
||||
# feature-local measurement.
|
||||
matches = feature_id in owners if owners else record.get("feature_id") == feature_id
|
||||
if matches:
|
||||
geometry = record.get("geometry")
|
||||
if isinstance(geometry, dict):
|
||||
candidates.append(geometry.get("bbox_mm"))
|
||||
else:
|
||||
bbox = engine_result.get("bbox_mm")
|
||||
if isinstance(bbox, dict):
|
||||
candidates.append([
|
||||
*(bbox.get("min") or []),
|
||||
*(bbox.get("max") or []),
|
||||
])
|
||||
|
||||
boxes = [
|
||||
value for value in candidates
|
||||
if isinstance(value, list) and len(value) == 6
|
||||
and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value)
|
||||
]
|
||||
if not boxes:
|
||||
return None
|
||||
minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)]
|
||||
maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)]
|
||||
return [float(item) for item in minimum], [float(item) for item in maximum]
|
||||
|
||||
|
||||
def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None:
|
||||
bounds = _bbox_bounds(engine_result, feature_id)
|
||||
if bounds is None:
|
||||
return None
|
||||
minimum, maximum = bounds
|
||||
return {
|
||||
"min": minimum,
|
||||
"max": maximum,
|
||||
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
|
||||
}
|
||||
|
||||
|
||||
def _dimensions(engine_result: dict[str, Any]) -> list[float] | None:
|
||||
value = _bbox_value(engine_result)
|
||||
return value["dimensions"] if value else None
|
||||
|
||||
|
||||
def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None:
|
||||
return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None)
|
||||
|
||||
|
||||
def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None:
|
||||
return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None)
|
||||
|
||||
|
||||
def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if profile.get("type") == "circle":
|
||||
center, radius = profile.get("center"), profile.get("radius_mm")
|
||||
if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)):
|
||||
return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}]
|
||||
if profile.get("type") != "analytic_contours":
|
||||
return []
|
||||
return [
|
||||
{"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])}
|
||||
for contour in profile.get("contours") or [] if isinstance(contour, dict)
|
||||
for segment in contour.get("segments") or [] if isinstance(segment, dict)
|
||||
and segment.get("type") == "circle"
|
||||
and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2
|
||||
and isinstance(segment.get("radius_mm"), (int, float))
|
||||
]
|
||||
|
||||
|
||||
def _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Derive a stable world-space bbox for a circular extrude feature."""
|
||||
if not isinstance(feature, dict) or not isinstance(sketch, dict):
|
||||
return None
|
||||
if str(feature.get("atomic_id") or "") != "extrude_add_blind":
|
||||
return None
|
||||
profile = sketch.get("profile")
|
||||
workplane = sketch.get("workplane")
|
||||
params = feature.get("params")
|
||||
if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict):
|
||||
return None
|
||||
center_local = profile.get("center")
|
||||
radius = profile.get("radius_mm")
|
||||
origin = workplane.get("origin_mm")
|
||||
x_dir = workplane.get("x_dir")
|
||||
y_dir = workplane.get("y_dir")
|
||||
normal = workplane.get("normal")
|
||||
distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None
|
||||
if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius):
|
||||
return None
|
||||
if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance):
|
||||
return None
|
||||
center = [
|
||||
float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index])
|
||||
for index in range(3)
|
||||
]
|
||||
points = []
|
||||
for axis_x in (-1.0, 1.0):
|
||||
for axis_y in (-1.0, 1.0):
|
||||
cross_section = [
|
||||
center[index]
|
||||
+ axis_x * float(radius) * float(x_dir[index])
|
||||
+ axis_y * float(radius) * float(y_dir[index])
|
||||
for index in range(3)
|
||||
]
|
||||
points.append(cross_section)
|
||||
points.append([
|
||||
cross_section[index] + float(distance) * float(normal[index])
|
||||
for index in range(3)
|
||||
])
|
||||
minimum = [min(point[index] for point in points) for index in range(3)]
|
||||
maximum = [max(point[index] for point in points) for index in range(3)]
|
||||
return {
|
||||
"min": minimum,
|
||||
"max": maximum,
|
||||
"dimensions": [maximum[index] - minimum[index] for index in range(3)],
|
||||
}
|
||||
|
||||
|
||||
def _finite_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
|
||||
|
||||
|
||||
def _validate_expected(kind: str, expected: Any, index: int) -> None:
|
||||
scalar_types = {
|
||||
"solid_count", "feature_count", "hole_count", "hole_diameter",
|
||||
"overall_length", "overall_width", "overall_height", "overall_diameter",
|
||||
}
|
||||
if kind in scalar_types and not _finite_number(expected):
|
||||
raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}")
|
||||
if kind == "bbox":
|
||||
valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected)
|
||||
valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"})
|
||||
if valid_ranges and set(expected) == {"min", "max"}:
|
||||
valid_ranges = all(
|
||||
isinstance(expected[key], list)
|
||||
and len(expected[key]) == 3
|
||||
and all(_finite_number(value) for value in expected[key])
|
||||
for key in ("min", "max")
|
||||
)
|
||||
elif valid_ranges:
|
||||
valid_ranges = all(_finite_number(expected[key]) for key in expected)
|
||||
if not valid_dimensions and not valid_ranges:
|
||||
raise ValueError(
|
||||
f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], "
|
||||
"{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}"
|
||||
)
|
||||
if kind == "hole_center" and (
|
||||
not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected)
|
||||
):
|
||||
raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center")
|
||||
if kind == "through_condition" and not isinstance(expected, bool):
|
||||
raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition")
|
||||
|
||||
|
||||
def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if verification is None:
|
||||
return []
|
||||
if not isinstance(verification, dict) or set(verification) - {"rules"}:
|
||||
raise ValueError("verification must be an object containing only rules")
|
||||
rules = verification.get("rules", [])
|
||||
if not isinstance(rules, list) or len(rules) > 32:
|
||||
raise ValueError("verification.rules must be an array with at most 32 rules")
|
||||
feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")}
|
||||
normalized: list[dict[str, Any]] = []
|
||||
ids: set[str] = set()
|
||||
for index, raw in enumerate(rules):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"verification.rules[{index}] must be an object")
|
||||
allowed = {"id", "type", "feature", "expected", "tolerance", "severity"}
|
||||
if set(raw) - allowed:
|
||||
raise ValueError(f"verification.rules[{index}] has unsupported fields")
|
||||
rule_id = str(raw.get("id") or "").strip()
|
||||
kind = str(raw.get("type") or "").strip()
|
||||
if not rule_id or rule_id in ids:
|
||||
raise ValueError(f"verification.rules[{index}].id must be unique and non-empty")
|
||||
if kind not in QUALITY_RULE_TYPES:
|
||||
raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}")
|
||||
if "expected" not in raw:
|
||||
raise ValueError(f"verification.rules[{index}].expected is required")
|
||||
_validate_expected(kind, raw["expected"], index)
|
||||
severity = str(raw.get("severity") or "blocking")
|
||||
if severity not in _SEVERITIES:
|
||||
raise ValueError(f"verification.rules[{index}].severity is unsupported")
|
||||
try:
|
||||
tolerance = float(raw.get("tolerance") or 0.0)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error
|
||||
if not math.isfinite(tolerance) or tolerance < 0:
|
||||
raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative")
|
||||
feature = str(raw.get("feature") or "").strip()
|
||||
if kind in FEATURE_RULE_TYPES and not feature:
|
||||
raise ValueError(f"verification.rules[{index}].feature is required for {kind}")
|
||||
if feature and feature not in feature_ids:
|
||||
raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID")
|
||||
ids.add(rule_id)
|
||||
normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity})
|
||||
return normalized
|
||||
|
||||
|
||||
def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]:
|
||||
kind, target = rule["type"], rule.get("feature") or None
|
||||
feature = _feature(cdsl, target)
|
||||
params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {}
|
||||
sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or ""))
|
||||
profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {}
|
||||
circles = _circles(profile) if isinstance(profile, dict) else []
|
||||
dimensions = _dimensions(engine_result)
|
||||
if kind == "bbox":
|
||||
derived = _circle_feature_bbox(feature, sketch)
|
||||
if target and derived is not None:
|
||||
return derived, f"cdsl.features.{target}.sketch + params"
|
||||
value = _bbox_value(engine_result, target)
|
||||
source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm"
|
||||
return value, source
|
||||
if kind == "solid_count":
|
||||
return float(engine_result.get("solid_count", 1)), "runtime.solid_count"
|
||||
if kind == "feature_count":
|
||||
return float(len(cdsl.get("features") or [])), "cdsl.features"
|
||||
if kind == "hole_count":
|
||||
return float(len(circles)), f"cdsl.features.{target}.sketch"
|
||||
if kind == "hole_diameter":
|
||||
value = params.get("diameter_mm", params.get("hole_diameter_mm"))
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value), f"cdsl.features.{target}.params"
|
||||
return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch"
|
||||
if kind == "hole_center":
|
||||
centers = [circle["center"] for circle in circles]
|
||||
return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch"
|
||||
if kind == "overall_length":
|
||||
return (max(dimensions) if dimensions else None), "runtime.bbox_mm"
|
||||
if kind == "overall_width":
|
||||
return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]"
|
||||
if kind == "overall_height":
|
||||
return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]"
|
||||
if kind == "overall_diameter":
|
||||
if target and circles:
|
||||
return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch"
|
||||
return (min(dimensions) if dimensions else None), "runtime.bbox_mm"
|
||||
if kind == "through_condition":
|
||||
end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {}
|
||||
if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}:
|
||||
return True, f"cdsl.features.{target}.params.end_condition"
|
||||
distance = params.get("distance_mm")
|
||||
return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm"
|
||||
return None, "unsupported verification type"
|
||||
|
||||
|
||||
def _matches(expected: Any, actual: Any, tolerance: float) -> bool:
|
||||
if actual is None:
|
||||
return False
|
||||
if isinstance(expected, list):
|
||||
return isinstance(actual, list) and len(expected) == len(actual) and all(
|
||||
_matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual)
|
||||
)
|
||||
if isinstance(expected, bool):
|
||||
return bool(actual) is expected
|
||||
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
|
||||
return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0)
|
||||
return expected == actual
|
||||
|
||||
|
||||
def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool:
|
||||
if actual is None:
|
||||
return False
|
||||
if isinstance(expected, list):
|
||||
return _matches(expected, actual["dimensions"], tolerance)
|
||||
if not isinstance(expected, dict):
|
||||
return False
|
||||
if set(expected) == {"min", "max"}:
|
||||
return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance)
|
||||
if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}:
|
||||
actual_ranges = {
|
||||
"x_min": actual["min"][0], "x_max": actual["max"][0],
|
||||
"y_min": actual["min"][1], "y_max": actual["max"][1],
|
||||
"z_min": actual["min"][2], "z_max": actual["max"][2],
|
||||
}
|
||||
return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges)
|
||||
return False
|
||||
|
||||
|
||||
def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]:
|
||||
results = []
|
||||
for rule in rules:
|
||||
actual, source = _actual(rule, cdsl, engine_result)
|
||||
passed = (
|
||||
_matches_bbox(rule["expected"], actual, rule["tolerance"])
|
||||
if rule["type"] == "bbox"
|
||||
else _matches(rule["expected"], actual, rule["tolerance"])
|
||||
)
|
||||
results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source})
|
||||
blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"]
|
||||
warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"]
|
||||
return {
|
||||
"schema": "cad.quality-report.v1",
|
||||
"schema_version": "1.0",
|
||||
"status": "passed" if not blocking else "failed",
|
||||
"verification_requested": bool(rules),
|
||||
"results": results,
|
||||
"blocking_failures": blocking,
|
||||
"warnings": warnings,
|
||||
"measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)},
|
||||
}
|
||||
@@ -330,3 +330,109 @@ def render_checkpoint(
|
||||
}
|
||||
(output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
def render_section(
|
||||
settings: Settings,
|
||||
*,
|
||||
step_path: Path,
|
||||
output_dir: Path,
|
||||
origin_mm: list[float],
|
||||
normal: list[float],
|
||||
) -> dict[str, Any]:
|
||||
"""Create an actual OpenCascade section drawing, not a clipped viewport.
|
||||
|
||||
It intentionally uses the same deterministic Pillow raster path as the
|
||||
seven canonical review views. The output contains compact contour evidence
|
||||
suitable for a multimodal author without sending a STEP file or full B-rep.
|
||||
"""
|
||||
del settings
|
||||
ready, detail = renderer_status()
|
||||
if not ready:
|
||||
raise ReviewRenderError(detail)
|
||||
if not step_path.is_file():
|
||||
raise ReviewRenderError(f"STEP section source is missing: {step_path.name}")
|
||||
origin = _number_list(origin_mm, size=3)
|
||||
direction = _number_list(normal, size=3)
|
||||
if origin is None or direction is None:
|
||||
raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers")
|
||||
length = math.sqrt(sum(value * value for value in direction))
|
||||
if length <= 1e-9:
|
||||
raise ReviewRenderError("Section normal must not be zero")
|
||||
normal_unit = [value / length for value in direction]
|
||||
_, _, import_step = _render_modules()
|
||||
try:
|
||||
b3d = importlib.import_module("build123d")
|
||||
shape = import_step(str(step_path))
|
||||
plane = b3d.Plane(origin=b3d.Vector(*origin), z_dir=b3d.Vector(*normal_unit))
|
||||
# build123d exposes section as a module-level part operation. Older
|
||||
# code assumed a Solid.section instance method, which does not exist
|
||||
# in the supported 0.11 runtime and made an otherwise successful CAD
|
||||
# task fail while collecting optional author evidence.
|
||||
section = b3d.section(shape, section_by=plane)
|
||||
edges = list(section.edges())
|
||||
except Exception as error:
|
||||
raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error
|
||||
if not edges:
|
||||
raise ReviewRenderError("Section plane does not intersect the model")
|
||||
|
||||
# Choose a deterministic right-handed in-plane frame. Projecting exact
|
||||
# OCC section edges into this frame preserves holes and internal contours.
|
||||
seed = [0.0, 0.0, 1.0] if abs(normal_unit[2]) < 0.9 else [0.0, 1.0, 0.0]
|
||||
x_axis = [
|
||||
seed[1] * normal_unit[2] - seed[2] * normal_unit[1],
|
||||
seed[2] * normal_unit[0] - seed[0] * normal_unit[2],
|
||||
seed[0] * normal_unit[1] - seed[1] * normal_unit[0],
|
||||
]
|
||||
x_length = math.sqrt(sum(value * value for value in x_axis))
|
||||
x_axis = [value / x_length for value in x_axis]
|
||||
y_axis = [
|
||||
normal_unit[1] * x_axis[2] - normal_unit[2] * x_axis[1],
|
||||
normal_unit[2] * x_axis[0] - normal_unit[0] * x_axis[2],
|
||||
normal_unit[0] * x_axis[1] - normal_unit[1] * x_axis[0],
|
||||
]
|
||||
|
||||
projected: list[list[tuple[float, float]]] = []
|
||||
for edge in edges:
|
||||
try:
|
||||
count = max(2, min(1024, int(math.ceil(float(edge.length) / 0.25)) + 1))
|
||||
points = edge.positions([index / (count - 1) for index in range(count)])
|
||||
except Exception:
|
||||
points = [edge.position_at(0), edge.position_at(1)]
|
||||
line: list[tuple[float, float]] = []
|
||||
for point in points:
|
||||
offset = [float(point.X) - origin[0], float(point.Y) - origin[1], float(point.Z) - origin[2]]
|
||||
line.append((sum(offset[index] * x_axis[index] for index in range(3)), sum(offset[index] * y_axis[index] for index in range(3))))
|
||||
if len(line) >= 2:
|
||||
projected.append(line)
|
||||
if not projected:
|
||||
raise ReviewRenderError("Section operation produced no drawable contours")
|
||||
xs = [point[0] for line in projected for point in line]
|
||||
ys = [point[1] for line in projected for point in line]
|
||||
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
|
||||
extent = max(maximum_x - minimum_x, maximum_y - minimum_y, 1.0)
|
||||
padding = extent * FRAME_PADDING
|
||||
frame = (minimum_x - padding, maximum_x + padding, minimum_y - padding, maximum_y + padding)
|
||||
pillow_image, pillow_draw, _ = _render_modules()
|
||||
image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB)
|
||||
draw = pillow_draw.Draw(image)
|
||||
for line in projected:
|
||||
pixels = [_pixel(point, frame, RENDER_SIZE) for point in line]
|
||||
draw.line(pixels, fill=VISIBLE_EDGE_RGB, width=4, joint="curve")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
high_path = output_dir / "section-2x.png"
|
||||
image.save(high_path, optimize=True)
|
||||
output_path = output_dir / "section.png"
|
||||
image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True)
|
||||
result = {
|
||||
"schema_version": "cad.section-render.v1",
|
||||
"renderer": "python-occ-section-pillow",
|
||||
"path": str(output_path),
|
||||
"high_resolution_path": str(high_path),
|
||||
"plane": {"origin_mm": origin, "normal": normal_unit},
|
||||
"contour_count": len(projected),
|
||||
"bounds_mm": [minimum_x, maximum_x, minimum_y, maximum_y],
|
||||
"resolution": [REVIEW_SIZE, REVIEW_SIZE],
|
||||
}
|
||||
(output_dir / "section-manifest.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return result
|
||||
|
||||
+163
-102
@@ -12,6 +12,7 @@ from app.settings import Settings
|
||||
|
||||
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
|
||||
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
|
||||
CANDIDATE_ID = re.compile(r"^candidate_[a-z0-9]{12,32}$")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -96,15 +97,6 @@ class WorkspaceStore:
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str:
|
||||
"""Persist structured intake/planning evidence before a task exists."""
|
||||
conversation = safe_conversation_id(conversation_id)
|
||||
safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning"
|
||||
relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json"
|
||||
path = self.conversation_dir(conversation) / relative
|
||||
write_json(path, payload)
|
||||
return (Path(conversation) / relative).as_posix()
|
||||
|
||||
def ensure_conversation(
|
||||
self,
|
||||
conversation_id: str | None,
|
||||
@@ -184,10 +176,14 @@ class WorkspaceStore:
|
||||
return self._migrate_task(current, path)
|
||||
task_dir = self.task_dir(tid)
|
||||
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
|
||||
source_requirements = str(request or "").strip()
|
||||
source_path = task_dir / "source-requirements.md"
|
||||
source_path.write_text(source_requirements + "\n", encoding="utf-8")
|
||||
record = {
|
||||
"schema_version": "1.3",
|
||||
"schema_version": "2.0",
|
||||
"task_id": tid,
|
||||
"request": request,
|
||||
"source_requirements_path": "source-requirements.md",
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"current_revision": "",
|
||||
@@ -195,9 +191,10 @@ class WorkspaceStore:
|
||||
"published_revision": "",
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"requirements_path": "",
|
||||
"agent_state_path": "",
|
||||
"active_candidate_id": "",
|
||||
"active_branch_id": "main",
|
||||
"run_failure_path": "",
|
||||
"revisions": [],
|
||||
}
|
||||
@@ -205,24 +202,42 @@ class WorkspaceStore:
|
||||
return record
|
||||
|
||||
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
|
||||
"""Add run-state fields lazily without rewriting successful history."""
|
||||
"""Fill in omitted fields for native autonomous tasks only.
|
||||
|
||||
A task from a retired protocol must never be made to look resumable by
|
||||
rewriting its schema version. In particular, doing so would make an
|
||||
old revision and its obsolete planning state appear to be a valid
|
||||
autonomous work head. Historical tasks remain read-only records; the
|
||||
service startup path marks only *running* legacy tasks as obsolete.
|
||||
"""
|
||||
if str(task.get("schema_version") or "") != "2.0":
|
||||
return task
|
||||
changed = False
|
||||
current = str(task.get("current_revision") or "")
|
||||
defaults = {
|
||||
"schema_version": "1.3",
|
||||
"active_revision": current,
|
||||
"published_revision": current,
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"requirements_path": "",
|
||||
"source_requirements_path": "",
|
||||
"completion_checklist_path": "",
|
||||
"agent_state_path": "",
|
||||
"active_candidate_id": "",
|
||||
"active_branch_id": "main",
|
||||
"run_failure_path": "",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
if key not in task:
|
||||
task[key] = value
|
||||
changed = True
|
||||
if not str(task.get("source_requirements_path") or ""):
|
||||
relative = Path("source-requirements.md")
|
||||
source_path = path.parent / relative
|
||||
if not source_path.exists():
|
||||
source_path.write_text(str(task.get("request") or "").strip() + "\n", encoding="utf-8")
|
||||
task["source_requirements_path"] = relative.as_posix()
|
||||
changed = True
|
||||
for revision in task.get("revisions") or ():
|
||||
if not isinstance(revision, dict):
|
||||
continue
|
||||
@@ -267,10 +282,11 @@ class WorkspaceStore:
|
||||
task.update({
|
||||
"lifecycle": "running",
|
||||
"run_id": run_id or new_id("run"),
|
||||
"active_node_id": "",
|
||||
"active_candidate_id": "",
|
||||
"run_failure_path": "",
|
||||
"request": request or task.get("request") or "",
|
||||
"active_revision": str(task.get("current_revision") or ""),
|
||||
"active_branch_id": str(task.get("active_branch_id") or "main"),
|
||||
"updated_at": now_iso(),
|
||||
})
|
||||
write_json(self.task_path(task_id), task)
|
||||
@@ -291,7 +307,7 @@ class WorkspaceStore:
|
||||
revision["visibility"] = "final"
|
||||
task.update({
|
||||
"lifecycle": lifecycle,
|
||||
"active_node_id": "",
|
||||
"active_candidate_id": "",
|
||||
"run_failure_path": failure_path,
|
||||
"updated_at": now_iso(),
|
||||
})
|
||||
@@ -303,6 +319,8 @@ class WorkspaceStore:
|
||||
if not revision_id:
|
||||
task["active_revision"] = ""
|
||||
task["current_revision"] = ""
|
||||
if branch_id:
|
||||
task["active_branch_id"] = branch_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
@@ -317,13 +335,122 @@ class WorkspaceStore:
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
|
||||
def set_agent_state(self, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Persist enough autonomous-agent state to resume after a restart."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
task["active_node_id"] = node_id
|
||||
relative = Path("agent-state.json")
|
||||
write_json(self.task_dir(task_id) / relative, values)
|
||||
task["agent_state_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def read_agent_state(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("agent_state_path") or "")
|
||||
value = read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def requirements_document_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "requirements.md"
|
||||
|
||||
def read_source_requirements(self, task_id: str) -> str:
|
||||
"""Return the server-owned, immutable user request for this task.
|
||||
|
||||
Older tasks predate the artifact. Their persisted request is a
|
||||
read-only compatibility fallback; new tasks always have the artifact.
|
||||
"""
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("source_requirements_path") or "")
|
||||
if relative:
|
||||
path = self.artifact_path(task_id, relative)
|
||||
if path.is_file():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return str(task.get("request") or "")
|
||||
|
||||
def write_requirements_document(self, task_id: str, markdown: str) -> Path:
|
||||
"""Write the one immutable requirements document for an agent run."""
|
||||
text = str(markdown or "").strip()
|
||||
if not text:
|
||||
raise ValueError("requirements.md must not be empty")
|
||||
task = self.ensure_task(task_id, "")
|
||||
relative = Path("requirements.md")
|
||||
path = self.task_dir(task_id) / relative
|
||||
if path.exists() or str(task.get("requirements_path") or ""):
|
||||
raise ValueError("requirements.md is frozen and cannot be rewritten")
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
task["requirements_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_requirements_document(self, task_id: str) -> str:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("requirements_path") or "")
|
||||
if not relative:
|
||||
return ""
|
||||
path = self.artifact_path(task_id, relative)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
def completion_checklist_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "completion.md"
|
||||
|
||||
def write_completion_checklist(self, task_id: str, markdown: str) -> Path:
|
||||
"""Persist the one immutable, author-owned completion checklist."""
|
||||
text = str(markdown or "").strip()
|
||||
if not text:
|
||||
raise ValueError("completion.md must not be empty")
|
||||
task = self.ensure_task(task_id, "")
|
||||
if not self.read_requirements_document(task_id):
|
||||
raise ValueError("requirements.md must be written before completion.md")
|
||||
relative = Path("completion.md")
|
||||
path = self.task_dir(task_id) / relative
|
||||
if path.exists() or str(task.get("completion_checklist_path") or ""):
|
||||
raise ValueError("completion.md is frozen and cannot be rewritten")
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
task["completion_checklist_path"] = relative.as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_completion_checklist(self, task_id: str) -> str:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("completion_checklist_path") or "")
|
||||
if not relative:
|
||||
return ""
|
||||
path = self.artifact_path(task_id, relative)
|
||||
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
|
||||
def new_candidate(self, task_id: str) -> tuple[str, Path]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
candidate_id = f"candidate_{secrets.token_hex(8)}"
|
||||
path = self.task_dir(task_id) / "candidates" / candidate_id
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
task["active_candidate_id"] = candidate_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return candidate_id, path
|
||||
|
||||
def candidate_dir(self, task_id: str, candidate_id: str) -> Path:
|
||||
if not CANDIDATE_ID.fullmatch(str(candidate_id or "")):
|
||||
raise ValueError("Invalid candidate id")
|
||||
return self.task_dir(task_id) / "candidates" / candidate_id
|
||||
|
||||
def clear_active_candidate(self, task_id: str, candidate_id: str | None = None) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
if candidate_id and str(task.get("active_candidate_id") or "") not in {"", candidate_id}:
|
||||
raise ValueError("Candidate is not active")
|
||||
task["active_candidate_id"] = ""
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
def append_agent_audit(self, task_id: str, kind: str, payload: dict[str, Any]) -> str:
|
||||
safe_kind = re.sub(r"[^a-z0-9_-]+", "-", str(kind).lower()).strip("-") or "event"
|
||||
relative = Path("agent-audit") / f"{now_iso().replace(':', '-').replace('+', '_')}_{safe_kind}_{secrets.token_hex(4)}.json"
|
||||
write_json(self.task_dir(task_id) / relative, {"recorded_at": now_iso(), "kind": safe_kind, **payload})
|
||||
return relative.as_posix()
|
||||
|
||||
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
|
||||
@@ -336,21 +463,25 @@ class WorkspaceStore:
|
||||
|
||||
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
|
||||
"""Move the generation head without deleting immutable checkpoint artifacts."""
|
||||
task = self.set_active_revision(task_id, revision_id, branch_id=branch_id)
|
||||
task = self.ensure_task(task_id, "")
|
||||
source_branch_id = str(task.get("active_branch_id") or "main")
|
||||
if revision_id:
|
||||
target = next(
|
||||
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id),
|
||||
None,
|
||||
)
|
||||
if not isinstance(target, dict) or target.get("status") != "success":
|
||||
raise ValueError("Active revision must be a successful revision")
|
||||
children: dict[str, set[str]] = {}
|
||||
for revision in task.get("revisions") or ():
|
||||
if not isinstance(revision, dict):
|
||||
continue
|
||||
parent = str(revision.get("parent_revision_id") or "")
|
||||
child = str(revision.get("revision_id") or "")
|
||||
if parent and child:
|
||||
if child and str(revision.get("branch_id") or "main") == source_branch_id:
|
||||
children.setdefault(parent, set()).add(child)
|
||||
superseded: set[str] = set()
|
||||
pending = list(children.get(revision_id, set())) if revision_id else [
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
|
||||
]
|
||||
pending = list(children.get(revision_id, set()))
|
||||
while pending:
|
||||
child = pending.pop()
|
||||
if not child or child in superseded:
|
||||
@@ -360,6 +491,9 @@ class WorkspaceStore:
|
||||
for revision in task.get("revisions") or ():
|
||||
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
|
||||
revision["visibility"] = "superseded"
|
||||
task["active_revision"] = revision_id
|
||||
task["current_revision"] = revision_id
|
||||
task["active_branch_id"] = branch_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
@@ -415,37 +549,6 @@ class WorkspaceStore:
|
||||
write_json(self.task_dir(task_id) / relative, payload)
|
||||
return relative.as_posix()
|
||||
|
||||
def generation_spec_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-spec.json"
|
||||
|
||||
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_spec_path(task_id)
|
||||
write_json(path, spec)
|
||||
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def generation_run_context_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-run-context.json"
|
||||
|
||||
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
|
||||
"""Persist the frozen authoring inputs needed to resume after restart."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_run_context_path(task_id)
|
||||
write_json(path, context)
|
||||
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("run_context_path") or "")
|
||||
context = read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
return context if isinstance(context, dict) else None
|
||||
|
||||
def running_tasks(self) -> list[dict[str, Any]]:
|
||||
"""Enumerate durable tasks that need a process-local worker."""
|
||||
tasks: list[dict[str, Any]] = []
|
||||
@@ -457,11 +560,6 @@ class WorkspaceStore:
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
||||
def read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("generation_spec_path") or "")
|
||||
return read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
|
||||
def revision_dir(self, task_id: str, revision_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "revisions" / revision_id
|
||||
|
||||
@@ -473,43 +571,6 @@ class WorkspaceStore:
|
||||
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None:
|
||||
"""Return the latest revision CDSL when a quality failure needs repair."""
|
||||
task = self.read_task(task_id)
|
||||
for revision in reversed((task or {}).get("revisions") or []):
|
||||
revision_id = str(revision.get("revision_id") or "")
|
||||
if not revision_id or str(revision.get("quality_status") or "") != "needs_repair":
|
||||
continue
|
||||
path = self.revision_cdsl_path(task_id, revision_id)
|
||||
if path is not None:
|
||||
return revision_id, path
|
||||
return None
|
||||
|
||||
def feature_plan_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "feature-plan.json"
|
||||
|
||||
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
|
||||
safe_task = safe_task_id(task_id)
|
||||
plan = read_json(self.feature_plan_path(safe_task))
|
||||
if not isinstance(plan, dict):
|
||||
return None
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
return None
|
||||
return plan
|
||||
|
||||
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
|
||||
safe_task = safe_task_id(task_id)
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("Feature plan must be an object")
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
raise ValueError("Feature plan task_id does not match its task")
|
||||
plan["task_id"] = safe_task
|
||||
path = self.feature_plan_path(safe_task)
|
||||
write_json(path, plan)
|
||||
return path
|
||||
|
||||
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
|
||||
task = self.read_task(task_id)
|
||||
revision = next(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -33,10 +34,71 @@ VISUAL_REVIEW_TOOL = {
|
||||
}
|
||||
|
||||
|
||||
CANDIDATE_REVIEW_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "review_candidate_batch",
|
||||
"description": "Independently review one staged CAD batch. Never author, modify, or approve CDSL outside this verdict.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"verdict": {"enum": ["accept", "reject"]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"batch_goal_status": {"enum": ["achieved", "partial", "failed"]},
|
||||
"coverage": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {"type": "string"},
|
||||
"status": {"enum": ["complete", "pending", "regressed", "uncertain"]},
|
||||
"evidence": {"type": "string"},
|
||||
},
|
||||
"required": ["item", "status", "evidence"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12},
|
||||
},
|
||||
"required": ["verdict", "confidence", "batch_goal_status", "coverage", "evidence"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VisualReviewError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _CandidateReviewFormatError(VisualReviewError):
|
||||
"""A locally detected invalid reviewer tool result, eligible for one retry."""
|
||||
|
||||
|
||||
def _thinking_tool_choice_rejected(response: httpx.Response) -> bool:
|
||||
"""Recognize the only compatibility error for which retrying is sound.
|
||||
|
||||
Some OpenAI-compatible reasoning endpoints expose function tools but
|
||||
reject a forced function selection while thinking is enabled. Retrying
|
||||
without ``tool_choice`` retains the tool contract and the local output
|
||||
validation below; it merely lets that endpoint choose the sole available
|
||||
review tool itself.
|
||||
"""
|
||||
return (
|
||||
response.status_code == 400
|
||||
and "thinking mode does not support this tool_choice" in response.text.lower()
|
||||
)
|
||||
|
||||
|
||||
def visual_review_tool() -> dict[str, Any]:
|
||||
"""Normal function-call schema; local validation remains authoritative."""
|
||||
return json.loads(json.dumps(VISUAL_REVIEW_TOOL))
|
||||
|
||||
|
||||
def candidate_review_tool() -> dict[str, Any]:
|
||||
return json.loads(json.dumps(CANDIDATE_REVIEW_TOOL))
|
||||
|
||||
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
@@ -62,8 +124,8 @@ def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool)
|
||||
selected.append(item)
|
||||
if final_checkpoint:
|
||||
selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id)
|
||||
elif not selected and "isometric" in by_id:
|
||||
selected.append(by_id["isometric"])
|
||||
else:
|
||||
selected.extend(by_id[view_id] for view_id in ("top", "front", "right", "isometric") if view_id in by_id)
|
||||
return selected or views[:1]
|
||||
|
||||
|
||||
@@ -71,8 +133,9 @@ async def review_checkpoint(
|
||||
settings: Settings,
|
||||
*,
|
||||
manifest: dict[str, Any],
|
||||
requirements: list[dict[str, Any]],
|
||||
node_id: str,
|
||||
requirements: str | list[dict[str, Any]],
|
||||
source_requirements: str = "",
|
||||
node_id: str = "final",
|
||||
deterministic_report: dict[str, Any],
|
||||
source_images: list[Path] | None = None,
|
||||
final_checkpoint: bool = False,
|
||||
@@ -87,13 +150,14 @@ async def review_checkpoint(
|
||||
"text": json.dumps({
|
||||
"node_id": node_id,
|
||||
"requirements": requirements,
|
||||
"source_requirements": source_requirements,
|
||||
"deterministic_report": deterministic_report,
|
||||
"render_manifest": {
|
||||
"renderer": manifest.get("renderer"),
|
||||
"source": manifest.get("source"),
|
||||
"views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views],
|
||||
},
|
||||
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.",
|
||||
"instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. First reconcile every claimed structural element with final_model_evidence: never claim a feature, mirrored copy, slot, cut, or chamfer exists when it is absent from that evidence. Do not infer hidden dimensions. Source requirements are the original user contract. Frozen requirements may add measurable assumptions but may never remove, replace, or weaken source requirements. Return pass only when both contracts are satisfied by the views and evidence; return warning or repair for any unsatisfied or downgraded source requirement.",
|
||||
}, ensure_ascii=False),
|
||||
}]
|
||||
content.extend(_image_part(path) for path in paths)
|
||||
@@ -102,9 +166,7 @@ async def review_checkpoint(
|
||||
for path in (source_images or [])[:2]:
|
||||
if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
content.append(_image_part(path))
|
||||
tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL))
|
||||
if model.strict_tool_schema:
|
||||
tool["function"]["strict"] = True
|
||||
tool = visual_review_tool()
|
||||
payload = {
|
||||
"model": model.id,
|
||||
"messages": [
|
||||
@@ -115,9 +177,17 @@ async def review_checkpoint(
|
||||
"tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}},
|
||||
"temperature": 0,
|
||||
}
|
||||
payload.update(provider.chat_completion_options)
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
|
||||
if _thinking_tool_choice_rejected(response):
|
||||
# Do not remove the tool itself and do not accept prose as a
|
||||
# fallback. This compatibility retry is valid only because the
|
||||
# reviewer receives one callable tool and its response is still
|
||||
# verified by the strict local validator below.
|
||||
payload.pop("tool_choice", None)
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload)
|
||||
if response.status_code >= 400:
|
||||
raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}")
|
||||
try:
|
||||
@@ -127,7 +197,8 @@ async def review_checkpoint(
|
||||
result = json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
||||
raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error
|
||||
if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}:
|
||||
allowed_fields = {"verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"}
|
||||
if not isinstance(result, dict) or set(result) != allowed_fields or result.get("verdict") not in {"pass", "warning", "repair"}:
|
||||
raise VisualReviewError("Visual reviewer returned an invalid verdict")
|
||||
try:
|
||||
confidence = float(result.get("confidence"))
|
||||
@@ -139,3 +210,134 @@ async def review_checkpoint(
|
||||
if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]):
|
||||
raise VisualReviewError(f"Visual reviewer returned an invalid {key}")
|
||||
return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result}
|
||||
|
||||
|
||||
async def review_candidate_batch(
|
||||
settings: Settings,
|
||||
*,
|
||||
manifest: dict[str, Any],
|
||||
requirements: str,
|
||||
source_requirements: str = "",
|
||||
checklist: list[str],
|
||||
batch_goal: str,
|
||||
deterministic_report: dict[str, Any],
|
||||
node_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Review a staged batch against its local goal and the frozen checklist."""
|
||||
provider, model = settings.resolve_review_model()
|
||||
views = _selected_review_views(manifest, final_checkpoint=False)
|
||||
paths = [Path(str(item.get("path") or "")) for item in views]
|
||||
if not paths or not all(path.is_file() for path in paths):
|
||||
raise VisualReviewError("Candidate review render manifest references missing image files")
|
||||
content: list[dict[str, Any]] = [{
|
||||
"type": "text",
|
||||
"text": json.dumps({
|
||||
"node_id": node_id,
|
||||
"source_requirements": source_requirements,
|
||||
"frozen_requirements": requirements,
|
||||
"completion_checklist": checklist,
|
||||
"batch_goal": batch_goal,
|
||||
"deterministic_report": deterministic_report,
|
||||
"instruction": (
|
||||
"Assess this staged batch, not overall task completion. Accept only if the batch goal is achieved "
|
||||
"without visibly contradicting source requirements, frozen requirements, or deterministic evidence. "
|
||||
"Source requirements are the original user contract; frozen requirements may add measurable assumptions "
|
||||
"but may not remove, replace, or weaken the source contract. Reject when a batch visibly downgrades or "
|
||||
"contradicts source intent. An incomplete but coherent "
|
||||
"intermediate model may be accepted when its batch goal is achieved. Return one coverage row for every "
|
||||
"completion checklist item, preserving exact item text. Use pending for future work and regressed when "
|
||||
"this batch breaks previously achieved work. Reject on disconnected geometry when the requirements call "
|
||||
"for one body, wrong orientation, visibly wrong geometry, or a failed batch goal."
|
||||
),
|
||||
"render_manifest": {
|
||||
"renderer": manifest.get("renderer"),
|
||||
"source": manifest.get("source"),
|
||||
"views": [{"id": item.get("id"), "camera": item.get("camera")} for item in views],
|
||||
},
|
||||
}, ensure_ascii=False),
|
||||
}]
|
||||
content.extend(_image_part(path) for path in paths)
|
||||
payload = {
|
||||
"model": model.id,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an independent CAD batch reviewer. You may only call review_candidate_batch."},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
"tools": [candidate_review_tool()],
|
||||
"tool_choice": {"type": "function", "function": {"name": "review_candidate_batch"}},
|
||||
"temperature": 0,
|
||||
}
|
||||
payload.update(provider.chat_completion_options)
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
|
||||
async def request_review(client: httpx.AsyncClient, request_payload: dict[str, Any]) -> httpx.Response:
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
|
||||
if _thinking_tool_choice_rejected(response):
|
||||
request_payload.pop("tool_choice", None)
|
||||
response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=request_payload)
|
||||
if response.status_code >= 400:
|
||||
raise VisualReviewError(f"Candidate visual review request failed ({response.status_code}): {response.text[:500]}")
|
||||
return response
|
||||
|
||||
def validate_response(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
call = response.json()["choices"][0]["message"]["tool_calls"][0]
|
||||
if call["function"]["name"] != "review_candidate_batch":
|
||||
raise KeyError("wrong tool")
|
||||
result = json.loads(call["function"]["arguments"])
|
||||
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer did not return a valid review tool call") from error
|
||||
allowed = {"verdict", "confidence", "batch_goal_status", "coverage", "evidence"}
|
||||
if not isinstance(result, dict) or set(result) != allowed or result.get("verdict") not in {"accept", "reject"}:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid verdict")
|
||||
try:
|
||||
confidence = float(result.get("confidence"))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid confidence") from error
|
||||
if not 0 <= confidence <= 1:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer confidence is outside [0, 1]")
|
||||
if result.get("batch_goal_status") not in {"achieved", "partial", "failed"}:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid batch goal status")
|
||||
if result.get("verdict") == "accept" and result.get("batch_goal_status") != "achieved":
|
||||
raise _CandidateReviewFormatError("Candidate reviewer may accept only an achieved batch goal")
|
||||
if not isinstance(result.get("evidence"), list) or not all(isinstance(item, str) for item in result["evidence"]):
|
||||
raise _CandidateReviewFormatError("Candidate reviewer returned invalid evidence")
|
||||
coverage = result.get("coverage")
|
||||
if not isinstance(coverage, list) or len(coverage) != len(checklist):
|
||||
raise _CandidateReviewFormatError("Candidate reviewer must return coverage for every checklist item")
|
||||
expected = {item.casefold(): item for item in checklist}
|
||||
seen: set[str] = set()
|
||||
for item in coverage:
|
||||
if not isinstance(item, dict) or set(item) != {"item", "status", "evidence"}:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer returned an invalid coverage row")
|
||||
key = str(item.get("item") or "").casefold()
|
||||
if key not in expected or key in seen:
|
||||
raise _CandidateReviewFormatError("Candidate reviewer coverage does not match the frozen checklist")
|
||||
if item.get("status") not in {"complete", "pending", "regressed", "uncertain"} or not str(item.get("evidence") or "").strip():
|
||||
raise _CandidateReviewFormatError("Candidate reviewer returned an incomplete coverage row")
|
||||
item["item"] = expected[key]
|
||||
seen.add(key)
|
||||
return result
|
||||
|
||||
async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client:
|
||||
response = await request_review(client, payload)
|
||||
try:
|
||||
result = validate_response(response)
|
||||
except _CandidateReviewFormatError as error:
|
||||
retry_payload = copy.deepcopy(payload)
|
||||
retry_payload["messages"].append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous tool call was rejected by the local validator: "
|
||||
f"{error}. Review the same rendered candidate again. Return only a complete "
|
||||
"review_candidate_batch tool call with exactly one coverage row for every frozen checklist item."
|
||||
),
|
||||
})
|
||||
result = validate_response(await request_review(client, retry_payload))
|
||||
return {
|
||||
"schema_version": "cad.candidate-review.v1",
|
||||
"node_id": node_id,
|
||||
"model": model.id,
|
||||
"batch_goal": batch_goal,
|
||||
**result,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user