wip: preserve local engine and agent workflow changes before ganjihong refactor integration
This commit is contained in:
@@ -1 +1 @@
|
||||
"""Application command handlers and context assembly for protocol v3."""
|
||||
"""Application workflow and compiler for the Authoring CDSL protocol."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
"""Compile model-facing Authoring CDSL into server-owned runtime CDSL."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code
|
||||
|
||||
|
||||
class AuthoringCompileError(ValueError):
|
||||
def __init__(self, code: str, message: str, *, path: str = "") -> None:
|
||||
self.code, self.path = code, path
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthoringCompiler:
|
||||
def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None:
|
||||
self.operation_contract = operation_contract
|
||||
self.version = version
|
||||
|
||||
def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
try:
|
||||
validate_finite(raw)
|
||||
doc = AuthoringDocument.model_validate(raw)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError(validation_error_code(error), str(error)) from error
|
||||
source_features = [feature for body in doc.bodies for feature in body.features]
|
||||
dependencies, implicit_selector_dependencies = self._effective_dependencies(doc)
|
||||
ordered = self._topological(doc, dependencies)
|
||||
# Identity follows the source document, while execution follows the
|
||||
# dependency graph. A dependency reordering must never renumber IDs.
|
||||
feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)}
|
||||
body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)}
|
||||
features: list[dict[str, Any]] = []
|
||||
sketches: list[dict[str, Any]] = []
|
||||
sketch_index = 0
|
||||
source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)}
|
||||
sketch_ids_by_feature = {
|
||||
feature.name: f"sketch_{source_positions[feature.name]:03d}"
|
||||
for feature in source_features
|
||||
if feature.sketch is not None
|
||||
}
|
||||
for feature in ordered:
|
||||
try:
|
||||
contract = self.operation_contract(feature.operation)
|
||||
except Exception as error:
|
||||
raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error
|
||||
params = self._references(
|
||||
deepcopy(feature.params), contract, feature_ids, feature.name,
|
||||
sketch_ids_by_feature=sketch_ids_by_feature,
|
||||
)
|
||||
self._validate_params(contract, params, feature.name)
|
||||
selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors]
|
||||
selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}
|
||||
fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"}
|
||||
required_selectors = fragment_shape["selector_tokens"] == "required"
|
||||
runtime_feature_selectors: list[dict[str, Any]] = []
|
||||
if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]:
|
||||
raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if not required_selectors and selectors:
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors):
|
||||
raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors")
|
||||
if required_selectors:
|
||||
slot = str(selector_policy["slot"])
|
||||
if slot == "feature.selectors":
|
||||
runtime_feature_selectors = selectors
|
||||
elif slot.startswith("params."):
|
||||
parameter = slot.removeprefix("params.")
|
||||
if "." in parameter:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if parameter in params:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_FORBIDDEN_FIELD",
|
||||
f"{parameter} is server-injected from selectors",
|
||||
path=f"features.{feature.name}.params.{parameter}",
|
||||
)
|
||||
params[parameter] = selectors[0] if len(selectors) == 1 else selectors
|
||||
else:
|
||||
raise AuthoringCompileError(
|
||||
"OPERATION_UNSUPPORTED",
|
||||
f"runtime has no safe selector binding for {slot}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
output = {
|
||||
"id": feature_ids[feature.name], "atomic_id": feature.operation,
|
||||
"depends_on": [feature_ids[name] for name in dependencies[feature.name]],
|
||||
"params": params,
|
||||
**({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}),
|
||||
}
|
||||
needs_sketch = fragment_shape["sketch"] == "required"
|
||||
if needs_sketch and feature.sketch is None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch")
|
||||
if not needs_sketch and feature.sketch is not None:
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch")
|
||||
if feature.sketch is not None:
|
||||
sketch_index += 1
|
||||
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
|
||||
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
|
||||
output["sketch_id"] = sketch_id
|
||||
features.append(output)
|
||||
runtime = {
|
||||
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
|
||||
"part_id": "compiled", "meta": {"unit": "mm"},
|
||||
"bodies": [
|
||||
{"id": body_ids[body.name], "name": body.name}
|
||||
for body in doc.bodies
|
||||
],
|
||||
"geometry": {"sketches": sketches}, "features": features,
|
||||
}
|
||||
digest = sha256(self._canonical_json(raw)).hexdigest()
|
||||
return runtime, {
|
||||
"schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version,
|
||||
"source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids,
|
||||
"sketch_ids": [item["id"] for item in sketches],
|
||||
"implicit_selector_dependencies": implicit_selector_dependencies,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""Make every declared selector source an auditable graph dependency.
|
||||
|
||||
A selector is already an explicit local source reference. Requiring the
|
||||
author to repeat that same edge in a second field only creates a
|
||||
formatting failure; it does not add CAD intent. The compiler therefore
|
||||
adds the direct source edge deterministically and records it in the
|
||||
audit. It never selects a substitute topology element.
|
||||
"""
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
dependencies: dict[str, list[str]] = {}
|
||||
implicit: dict[str, list[str]] = {}
|
||||
for feature in by_name.values():
|
||||
values = list(feature.depends_on)
|
||||
additions: list[str] = []
|
||||
for selector in feature.selectors:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in by_name:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID",
|
||||
f"unknown selector source: {selector.source}",
|
||||
path=f"features.{feature.name}.selectors",
|
||||
)
|
||||
if source not in values:
|
||||
values.append(source)
|
||||
additions.append(source)
|
||||
dependencies[feature.name] = values
|
||||
if additions:
|
||||
implicit[feature.name] = additions
|
||||
return dependencies, implicit
|
||||
|
||||
@staticmethod
|
||||
def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]:
|
||||
by_name = {item.name: item for body in doc.bodies for item in body.features}
|
||||
result: list[Any] = []
|
||||
visiting: set[str] = set()
|
||||
done: set[str] = set()
|
||||
def visit(name: str) -> None:
|
||||
if name in visiting:
|
||||
raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}")
|
||||
if name in done:
|
||||
return
|
||||
visiting.add(name)
|
||||
for dependency in dependencies[name]:
|
||||
visit(dependency)
|
||||
visiting.remove(name); done.add(name); result.append(by_name[name])
|
||||
for body in doc.bodies:
|
||||
for feature in body.features:
|
||||
visit(feature.name)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]:
|
||||
source = selector.source.split(".", 1)[0]
|
||||
if source not in feature_ids:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}")
|
||||
source_feature = next(item for item in source_features if item.name == source)
|
||||
role = selector.source.split(".", 1)[1]
|
||||
role = AuthoringCompiler._runtime_role(role, source_feature.operation)
|
||||
return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Lower the small Authoring sketch language to the generic runtime form."""
|
||||
profile = sketch["profile"]
|
||||
if profile["type"] == "circle":
|
||||
profile = {
|
||||
"type": "circle",
|
||||
"center": profile["center_mm"],
|
||||
"radius_mm": float(profile["diameter_mm"]) / 2.0,
|
||||
}
|
||||
return {"workplane": sketch["workplane"], "profile": profile}
|
||||
|
||||
@staticmethod
|
||||
def _runtime_role(role: str, operation: str) -> str:
|
||||
if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}:
|
||||
return role
|
||||
family = (
|
||||
"extrude" if operation.startswith("extrude") else
|
||||
"sweep" if operation.startswith("sweep") else
|
||||
"loft" if operation.startswith("loft") else
|
||||
"cylinder" if operation == "cylinder_add" else ""
|
||||
)
|
||||
if role in {"top_planar_face", "end_face"} and family:
|
||||
return family + ".end"
|
||||
if role in {"bottom_planar_face", "start_face"} and family:
|
||||
return family + ".start"
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None:
|
||||
schema = contract.get("author_params_schema") or {"type": "object"}
|
||||
errors = list(Draft202012Validator(schema).iter_errors(params))
|
||||
if errors:
|
||||
first = errors[0]
|
||||
path = ".".join(str(item) for item in first.absolute_path)
|
||||
raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip("."))
|
||||
|
||||
@staticmethod
|
||||
def _references(
|
||||
params: dict[str, Any],
|
||||
contract: dict[str, Any],
|
||||
feature_ids: dict[str, str],
|
||||
feature_name: str,
|
||||
*,
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
params = AuthoringCompiler._rewrite_named_references(
|
||||
params, feature_ids, sketch_ids_by_feature, feature_name,
|
||||
)
|
||||
policy = contract.get("reference_policy") or {"mode": "none"}
|
||||
if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors":
|
||||
return params
|
||||
key = str(policy["slot"]).removeprefix("params.")
|
||||
value = params.get(key)
|
||||
if isinstance(value, list):
|
||||
names = value
|
||||
elif value is None and policy["min_items"] == 0:
|
||||
names = []
|
||||
elif isinstance(value, str):
|
||||
names = [value]
|
||||
else:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}")
|
||||
runtime_ids = set(feature_ids.values())
|
||||
if not all(isinstance(item, str) and item in runtime_ids for item in names):
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}")
|
||||
if not policy["min_items"] <= len(names) <= policy["max_items"]:
|
||||
raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}")
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_named_references(
|
||||
value: Any,
|
||||
feature_ids: dict[str, str],
|
||||
sketch_ids_by_feature: dict[str, str],
|
||||
consuming_feature: str,
|
||||
key: str = "",
|
||||
) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
item_key: AuthoringCompiler._rewrite_named_references(
|
||||
item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key,
|
||||
)
|
||||
for item_key, item_value in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
AuthoringCompiler._rewrite_named_references(
|
||||
item, feature_ids, sketch_ids_by_feature, consuming_feature, key,
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if key == "profile_sketch_ids":
|
||||
if value not in sketch_ids_by_feature:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return sketch_ids_by_feature[value]
|
||||
if key.endswith("_feature_id") or key.endswith("_feature_ids"):
|
||||
if value not in feature_ids:
|
||||
raise AuthoringCompileError(
|
||||
"AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}",
|
||||
path=f"features.{consuming_feature}.params.{key}",
|
||||
)
|
||||
return feature_ids[value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Strict model-facing Authoring CDSL contract.
|
||||
|
||||
This is intentionally separate from the runtime CDSL: model output contains
|
||||
only document-local names and declarative references. Runtime identities are
|
||||
allocated by :mod:`authoring_compiler`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_NAME = r"^[a-z][a-z0-9_]{0,63}$"
|
||||
_FORBIDDEN = {
|
||||
"id",
|
||||
"task_id", "revision_id", "candidate_id", "action_id", "requirement_id",
|
||||
"claim_id", "evidence_id", "feature_id", "sketch_id", "body_id",
|
||||
"stable_id", "snapshot_id", "owner_feature_id", "working_head",
|
||||
"selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken",
|
||||
"host_face", "mirror_plane",
|
||||
}
|
||||
|
||||
|
||||
def validation_error_code(error: Exception) -> str:
|
||||
"""Map strict model validation failures to a stable public diagnostic."""
|
||||
return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID"
|
||||
|
||||
|
||||
class AuthorModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_internal_fields(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
found = sorted(
|
||||
key for key in value
|
||||
if isinstance(key, str)
|
||||
and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN)
|
||||
)
|
||||
if found:
|
||||
raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}")
|
||||
for nested in value.values():
|
||||
cls.reject_internal_fields(nested)
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
cls.reject_internal_fields(item)
|
||||
return value
|
||||
|
||||
|
||||
class AuthorWorkplane(AuthorModel):
|
||||
"""A fully explicit local sketch frame in world millimetres."""
|
||||
|
||||
origin_mm: list[float] = Field(min_length=3, max_length=3)
|
||||
x_dir: list[float] = Field(min_length=3, max_length=3)
|
||||
normal: list[float] = Field(min_length=3, max_length=3)
|
||||
|
||||
|
||||
class AuthorCircleProfile(AuthorModel):
|
||||
"""A declarative circle expressed with the user-facing diameter."""
|
||||
|
||||
type: Literal["circle"]
|
||||
diameter_mm: float = Field(gt=0)
|
||||
center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2)
|
||||
|
||||
|
||||
class AuthorPolygonProfile(AuthorModel):
|
||||
"""A closed polygon in the local sketch workplane."""
|
||||
|
||||
type: Literal["polygon"]
|
||||
vertices: list[list[float]] = Field(min_length=3)
|
||||
|
||||
@field_validator("vertices")
|
||||
@classmethod
|
||||
def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]:
|
||||
if any(len(point) != 2 for point in value):
|
||||
raise ValueError("polygon vertices must have exactly two coordinates")
|
||||
return value
|
||||
|
||||
|
||||
class AuthorSketch(AuthorModel):
|
||||
"""The only authoring sketch form currently accepted by the compiler."""
|
||||
|
||||
workplane: AuthorWorkplane
|
||||
profile: AuthorCircleProfile | AuthorPolygonProfile
|
||||
|
||||
|
||||
class SelectorIntent(AuthorModel):
|
||||
"""A local feature-output reference, never a Runtime selector token."""
|
||||
|
||||
kind: Literal["face", "edge", "axis", "plane", "vertex", "body"]
|
||||
source: str = Field(
|
||||
min_length=3,
|
||||
max_length=160,
|
||||
description="A local feature output in the form <feature_name>.<output_role>.",
|
||||
)
|
||||
match: Literal["unique", "all"] = "unique"
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def require_feature_output_reference(cls, value: str) -> str:
|
||||
feature, separator, role = value.partition(".")
|
||||
if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role):
|
||||
raise ValueError("selector source must be <feature_name>.<output_role>")
|
||||
return value
|
||||
|
||||
|
||||
class AuthorFeature(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
|
||||
params: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Only parameters from this feature operation's supplied params_schema.",
|
||||
)
|
||||
depends_on: list[str] = Field(default_factory=list, max_length=32)
|
||||
selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32)
|
||||
sketch: AuthorSketch | None = Field(
|
||||
default=None,
|
||||
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
|
||||
)
|
||||
|
||||
|
||||
class AuthorBody(AuthorModel):
|
||||
name: str = Field(pattern=_NAME)
|
||||
features: list[AuthorFeature] = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class AuthoringDocument(AuthorModel):
|
||||
schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$")
|
||||
units: str = Field(default="mm", pattern=r"^mm$")
|
||||
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
|
||||
assumptions: list[str] = Field(default_factory=list, max_length=64)
|
||||
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
|
||||
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_symbols(self) -> "AuthoringDocument":
|
||||
validate_finite(self.model_dump(mode="python"))
|
||||
bodies = [b.name for b in self.bodies]
|
||||
if len(bodies) != len(set(bodies)):
|
||||
raise ValueError("duplicate body name")
|
||||
names: set[str] = set()
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if feature.name in names:
|
||||
raise ValueError(f"duplicate feature name: {feature.name}")
|
||||
names.add(feature.name)
|
||||
for body in self.bodies:
|
||||
for feature in body.features:
|
||||
if len(feature.depends_on) != len(set(feature.depends_on)):
|
||||
raise ValueError(f"duplicate dependency: {feature.name}")
|
||||
if any(dep not in names for dep in feature.depends_on):
|
||||
missing = next(dep for dep in feature.depends_on if dep not in names)
|
||||
raise ValueError(f"unknown feature reference: {missing}")
|
||||
return self
|
||||
|
||||
|
||||
def validate_finite(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise ValueError(f"non-finite number at {path}")
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
validate_finite(item, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
validate_finite(item, f"{path}[{index}]")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Prompt material for the model-facing Authoring CDSL contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_authoring_guidance() -> str:
|
||||
try:
|
||||
return _GUIDANCE.read_text(encoding="utf-8")[:12_000]
|
||||
except OSError:
|
||||
return "Use cad.author.v1 only. Never create runtime IDs or selector tokens."
|
||||
@@ -6,62 +6,25 @@ from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.cad_agent.application.llm_contracts import (
|
||||
EmptyCommand,
|
||||
ImageObservation,
|
||||
MarkdownDocument,
|
||||
StatelessCandidateReview,
|
||||
StatelessGeometryConclusion,
|
||||
StatelessRollbackCheckpoint,
|
||||
StatelessTopologyRequest,
|
||||
compiled_requirements_schema,
|
||||
stateless_final_review_schema,
|
||||
stateless_next_action_schema,
|
||||
)
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan
|
||||
from app.cad_agent.domain.operation_contract import fragment_schema
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.cad_agent.application.authoring_contract import AuthoringDocument
|
||||
from app.cad_agent.application.workflow import RequirementsAnalysis
|
||||
from app.cad_agent.ports import CadRuntime, ModelGateway
|
||||
|
||||
|
||||
CapabilityRole = Literal["author", "reviewer"]
|
||||
CapabilityRole = Literal["author"]
|
||||
|
||||
|
||||
def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]:
|
||||
if role == "reviewer":
|
||||
return [
|
||||
_tool("observe_images", ImageObservation.model_json_schema()),
|
||||
# Compatibility conformance probe; regular DAG execution never
|
||||
# calls a per-node reviewer.
|
||||
_tool("review_candidate", StatelessCandidateReview.model_json_schema()),
|
||||
_tool("review_final", stateless_final_review_schema(1)),
|
||||
]
|
||||
atomic_ids = list(runtime.supported_atomic_ids())
|
||||
if not atomic_ids:
|
||||
if not runtime.supported_atomic_ids():
|
||||
raise RuntimeError("Runtime has no operations for conformance")
|
||||
tools = [
|
||||
_tool("write_requirements_document", MarkdownDocument.model_json_schema()),
|
||||
_tool("write_completion_target", MarkdownDocument.model_json_schema()),
|
||||
_tool("compile_requirements_spec", compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1)),
|
||||
_tool("write_feature_plan", FeaturePlan.model_json_schema()),
|
||||
# Compatibility probe only; production v3.2 workflow never exposes it.
|
||||
_tool("write_modeling_plan", MarkdownDocument.model_json_schema()),
|
||||
_tool("inspect_topology", StatelessTopologyRequest.model_json_schema()),
|
||||
_tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()),
|
||||
_tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()),
|
||||
_tool("complete_task", EmptyCommand.model_json_schema()),
|
||||
return [
|
||||
_tool("analyze_requirements", RequirementsAnalysis.model_json_schema()),
|
||||
_tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()),
|
||||
]
|
||||
for atomic_id in atomic_ids:
|
||||
contract = runtime.operation_contract(atomic_id)
|
||||
tools.append(_tool(
|
||||
f"conformance_{atomic_id}",
|
||||
fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]),
|
||||
))
|
||||
return tools
|
||||
|
||||
|
||||
def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str:
|
||||
payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools}
|
||||
payload = {"protocol": "cad.single-stage.v1", "role": role, "tools": tools}
|
||||
return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""Canonical schemas for every v3 LLM state-changing command.
|
||||
|
||||
Providers may parse structured output first, but this module validates the raw
|
||||
tool arguments a second time before a command reaches a handler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Annotated, Any, Literal, TypeVar
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
|
||||
|
||||
class StrictDto(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
ShortText = Annotated[str, Field(min_length=1, max_length=360)]
|
||||
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")]
|
||||
|
||||
|
||||
class AcceptanceClaimInput(StrictDto):
|
||||
claim_kind: Identifier
|
||||
expected: dict[str, JsonValue] = Field(min_length=0, max_length=24)
|
||||
|
||||
|
||||
class SpecRequirementInput(StrictDto):
|
||||
statement: Annotated[str, Field(min_length=1, max_length=1000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class MarkdownDocument(StrictDto):
|
||||
"""A frozen human-readable design artifact, never an executable payload."""
|
||||
markdown: Annotated[str, Field(min_length=1, max_length=16_000)]
|
||||
|
||||
|
||||
class CompiledRequirementInput(StrictDto):
|
||||
"""One verifier bundle for one server-parsed checklist item.
|
||||
|
||||
The checklist text, ordering, source bindings, and all identifiers are
|
||||
intentionally absent: the service owns them after Markdown is frozen.
|
||||
"""
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
class CompiledRequirementsSpec(StrictDto):
|
||||
requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
# Kept only so an interrupted process with an already imported old tool schema
|
||||
# fails at the workflow boundary instead of failing module import. New v3.1
|
||||
# tasks never expose or accept this aggregate specification.
|
||||
class RequirementsSpec(StrictDto):
|
||||
outcome: Literal["ready"]
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32)
|
||||
requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RequirementsClarification(StrictDto):
|
||||
outcome: Literal["clarification"]
|
||||
source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4)
|
||||
question: Annotated[str, Field(min_length=1, max_length=500)]
|
||||
|
||||
|
||||
class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]):
|
||||
pass
|
||||
|
||||
|
||||
class EmptyCommand(StrictDto):
|
||||
pass
|
||||
|
||||
|
||||
class NextAction(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
intent: ShortText
|
||||
# The server binds this list from every frozen checklist target. It is not
|
||||
# author input, so a five-item UI-era limit must not reject a valid task.
|
||||
requirement_ids: list[Identifier] = Field(min_length=1, max_length=64)
|
||||
atomic_id: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _requirement_ids_are_unique(self) -> "NextAction":
|
||||
if len(self.requirement_ids) != len(set(self.requirement_ids)):
|
||||
raise ValueError("requirement_ids must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class StatelessNextAction(StrictDto):
|
||||
intent: ShortText
|
||||
operation: Identifier
|
||||
expected_change: ShortText
|
||||
|
||||
|
||||
class TopologyRequest(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class GeometryConclusion(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
evidence_refs: list[Identifier] = Field(min_length=1, max_length=16)
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "GeometryConclusion":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class RollbackCheckpoint(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")]
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessTopologyRequest(StrictDto):
|
||||
kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None
|
||||
limit: int = Field(default=16, ge=1, le=64)
|
||||
|
||||
|
||||
class StatelessGeometryConclusion(StrictDto):
|
||||
root_cause: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
decision: Literal["return_to_action_selection", "rollback"]
|
||||
corrective_intent: str | None = Field(default=None, min_length=1, max_length=360)
|
||||
|
||||
|
||||
class StatelessRollbackCheckpoint(StrictDto):
|
||||
checkpoint_token: Identifier
|
||||
reason: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class ClaimCoverage(StrictDto):
|
||||
claim_id: Identifier
|
||||
status: Literal["pass", "pending", "fail", "not_applicable"]
|
||||
evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _evidence_refs_are_unique(self) -> "ClaimCoverage":
|
||||
if len(self.evidence_refs) != len(set(self.evidence_refs)):
|
||||
raise ValueError("evidence_refs must not contain duplicates")
|
||||
return self
|
||||
|
||||
|
||||
class CandidateReview(StrictDto):
|
||||
candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.")
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")]
|
||||
verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class StatelessCandidateReview(StrictDto):
|
||||
verdict: Literal["accept", "reject"]
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class VisualClaimDecision(StrictDto):
|
||||
status: Literal["pass", "fail"]
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
|
||||
|
||||
class StatelessFinalReview(StrictDto):
|
||||
verdict: Literal["pass", "repair"]
|
||||
visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128)
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
class ImageMeasurement(StrictDto):
|
||||
name: Annotated[str, Field(min_length=1, max_length=160)]
|
||||
value: float | None = None
|
||||
unit: Literal["mm", "degree", "count", "unknown"] = "unknown"
|
||||
evidence: Annotated[str, Field(min_length=1, max_length=360)]
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class ImageObservation(StrictDto):
|
||||
summary: Annotated[str, Field(min_length=1, max_length=2000)]
|
||||
visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128)
|
||||
view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16)
|
||||
uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64)
|
||||
|
||||
|
||||
class FinalReview(StrictDto):
|
||||
working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")]
|
||||
verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.")
|
||||
claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.")
|
||||
evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16)
|
||||
|
||||
|
||||
def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]:
|
||||
schema = RequirementsAuthorOutput.model_json_schema()
|
||||
requirement = schema.get("$defs", {}).get("SpecRequirementInput")
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
return schema
|
||||
|
||||
|
||||
def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]:
|
||||
schema = CompiledRequirementsSpec.model_json_schema()
|
||||
definitions = schema.get("$defs", {})
|
||||
requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None
|
||||
if isinstance(requirement, dict):
|
||||
claims = requirement.get("properties", {}).get("acceptance_claims")
|
||||
if isinstance(claims, dict):
|
||||
claims["items"] = deepcopy(claim_one_of)
|
||||
requirements = schema.get("properties", {}).get("requirements")
|
||||
if isinstance(requirements, dict):
|
||||
requirements["minItems"] = target_count
|
||||
requirements["maxItems"] = target_count
|
||||
return schema
|
||||
|
||||
|
||||
def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError:
|
||||
"""Drop harmless compiler chatter before strict requirements validation.
|
||||
|
||||
``compile_requirements_spec`` is a compiler stage: the service only needs
|
||||
the ordered verifier bundles for the frozen checklist items. Real models
|
||||
sometimes add explanatory fields such as a top-level ``assumptions`` or
|
||||
per-item ``statement`` even when the dynamic tool schema forbids them. Those
|
||||
fields are not executable and are not part of the frozen contract, so they
|
||||
should not abort a task before modeling starts.
|
||||
|
||||
The verifier ``expected`` payload is intentionally not sanitized here. It
|
||||
remains governed by the registry's strict per-claim schema because those
|
||||
values drive deterministic validation.
|
||||
"""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
requirements = value.get("requirements")
|
||||
sanitized: dict[str, Any] = {}
|
||||
if isinstance(requirements, list):
|
||||
sanitized_requirements: list[Any] = []
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, dict):
|
||||
sanitized_requirements.append(requirement)
|
||||
continue
|
||||
item: dict[str, Any] = {}
|
||||
if "assumptions" in requirement:
|
||||
item["assumptions"] = requirement["assumptions"]
|
||||
if "acceptance_claims" in requirement:
|
||||
claims = requirement["acceptance_claims"]
|
||||
if isinstance(claims, list):
|
||||
item["acceptance_claims"] = [
|
||||
{key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim}
|
||||
if isinstance(claim, dict) else claim
|
||||
for claim in claims
|
||||
]
|
||||
else:
|
||||
item["acceptance_claims"] = claims
|
||||
sanitized_requirements.append(item)
|
||||
sanitized["requirements"] = sanitized_requirements
|
||||
else:
|
||||
sanitized["requirements"] = requirements
|
||||
return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessNextAction.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["operation"] = {"enum": atomic_ids}
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]:
|
||||
schema = StatelessFinalReview.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
visual = properties.get("visual_claims") if isinstance(properties, dict) else None
|
||||
if isinstance(visual, dict):
|
||||
visual["minItems"] = visual_claim_count
|
||||
visual["maxItems"] = visual_claim_count
|
||||
return schema
|
||||
|
||||
|
||||
def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
schema = StatelessRollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def topology_request_schema(working_head: str) -> dict[str, Any]:
|
||||
schema = TopologyRequest.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
return schema
|
||||
|
||||
|
||||
def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]:
|
||||
"""Bind a rollback request to immutable checkpoints in the active lineage."""
|
||||
schema = RollbackCheckpoint.model_json_schema()
|
||||
properties = schema.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["working_head"] = {"const": working_head}
|
||||
properties["checkpoint_token"] = {"enum": checkpoint_tokens}
|
||||
return schema
|
||||
|
||||
|
||||
def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None:
|
||||
definitions = schema.get("$defs", {})
|
||||
coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None
|
||||
if not isinstance(coverage, dict):
|
||||
return
|
||||
properties = coverage.get("properties", {})
|
||||
if isinstance(properties, dict):
|
||||
properties["claim_id"] = {"enum": claim_ids}
|
||||
|
||||
|
||||
Dto = TypeVar("Dto", bound=StrictDto)
|
||||
|
||||
|
||||
def raw_arguments_hash(raw_arguments_json: str) -> str:
|
||||
return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def json_depth(value: Any, current: int = 0) -> int:
|
||||
if isinstance(value, dict):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value.values())])
|
||||
if isinstance(value, list):
|
||||
return max([current, *(json_depth(item, current + 1) for item in value)])
|
||||
return current
|
||||
|
||||
|
||||
def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError:
|
||||
"""Bound and parse raw arguments before any schema-specific validation."""
|
||||
if len(raw_arguments_json.encode("utf-8")) > max_bytes:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.")
|
||||
try:
|
||||
value = json.loads(raw_arguments_json)
|
||||
except json.JSONDecodeError as error:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},))
|
||||
if not isinstance(value, dict):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.")
|
||||
if json_depth(value) > max_depth:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.")
|
||||
if _contains_non_finite_number(value):
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.")
|
||||
return value
|
||||
|
||||
|
||||
def _contains_non_finite_number(value: Any) -> bool:
|
||||
if isinstance(value, float):
|
||||
return not math.isfinite(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_non_finite_number(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_non_finite_number(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None:
|
||||
"""Revalidate raw arguments against the current dynamic JSON Schema."""
|
||||
value = canonical_json_object(raw_arguments_json)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
errors = [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
if errors:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors))
|
||||
return None
|
||||
|
||||
|
||||
def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError:
|
||||
"""Parse raw tool arguments once and return field-level DTO failures safely."""
|
||||
value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth)
|
||||
if isinstance(value, WorkflowError):
|
||||
return value
|
||||
try:
|
||||
return model.model_validate(value)
|
||||
except ValidationError as error:
|
||||
fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors())
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields)
|
||||
|
||||
|
||||
def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError:
|
||||
"""Require exactly one known tool call; no provider parser is trusted."""
|
||||
if len(tool_calls) != 1:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.")
|
||||
function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None
|
||||
name = str(function.get("name") or "") if isinstance(function, dict) else ""
|
||||
raw = str(function.get("arguments") or "") if isinstance(function, dict) else ""
|
||||
if name != allowed_name:
|
||||
return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name})
|
||||
return name, raw
|
||||
@@ -1,670 +0,0 @@
|
||||
"""Immutable Markdown-first requirements artifacts and compiled contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.cad_agent.application.llm_contracts import AcceptanceClaimInput, CompiledRequirementsSpec, MarkdownDocument, compiled_requirements_schema
|
||||
from app.cad_agent.domain.feature_plan import FeaturePlan, FeatureScheduler, node_hash, plan_hash, validate_feature_plan
|
||||
from app.cad_agent.application.results import Accepted, Rejected
|
||||
from app.cad_agent.domain.errors import ErrorCode, WorkflowError
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState, transition
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
from app.cad_agent.ports import ArtifactStore, TaskRepository
|
||||
|
||||
|
||||
_CHECKBOX = re.compile(r"^\s*- \[ \]\s+(.+?)\s*$")
|
||||
_RECORD_BOUND_CLAIMS = frozenset({"coaxial", "coplanar"})
|
||||
_CENTERED_BORE_MARKERS = ("centered", "concentric", "coaxial", "中心", "同心", "同轴")
|
||||
_BORE_MARKERS = ("bore", "hole", "孔")
|
||||
_OBROUND_SLOT_MARKERS = ("oblong", "slot", "slotted", "腰形", "长圆", "调节槽")
|
||||
|
||||
|
||||
class RequirementsCommandHandler:
|
||||
"""Persist frozen documents and compile their checklist into a contract.
|
||||
|
||||
The model never names targets or internal objects during compilation. The
|
||||
service derives those values strictly from the immutable checklist.
|
||||
"""
|
||||
|
||||
def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, registry: VerifierRegistry, *, atomic_ids: Callable[[], tuple[str, ...]] | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.artifacts = artifacts
|
||||
self.registry = registry
|
||||
self.atomic_ids = atomic_ids or (lambda: ())
|
||||
self._evaluation_contract_oracles: dict[str, list[dict[str, Any]]] = {}
|
||||
self._evaluation_capability_gaps: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None:
|
||||
self._evaluation_contract_oracles[task_id] = deepcopy(required_claims)
|
||||
self._evaluation_capability_gaps[task_id] = [
|
||||
{"id": str(item.get("id") or ""), "description": str(item.get("description") or "")}
|
||||
for item in validation_capability_gaps or () if isinstance(item, dict)
|
||||
]
|
||||
|
||||
def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
claims = self._evaluation_contract_oracles.get(task_id)
|
||||
return None if claims is None else {
|
||||
"evaluation_only": True,
|
||||
"required_claims": deepcopy(claims),
|
||||
"known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def document_schema() -> dict[str, Any]:
|
||||
return MarkdownDocument.model_json_schema()
|
||||
|
||||
def compiler_schema(self, task_id: str) -> dict[str, Any]:
|
||||
return compiled_requirements_schema(
|
||||
self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS),
|
||||
len(self._checklist_items(task_id)),
|
||||
)
|
||||
|
||||
def feature_plan_schema(self, task_id: str) -> dict[str, Any]:
|
||||
"""Return the plan tool schema bound to the persisted planning state.
|
||||
|
||||
A model is allowed to choose node content, but it must not guess the
|
||||
immutable lineage identifiers of a plan revision. Binding those values
|
||||
as enums prevents a requirements-contract hash (or a stale plan hash)
|
||||
from being mistaken for ``parent_plan_hash``.
|
||||
"""
|
||||
schema = FeaturePlan.model_json_schema()
|
||||
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
||||
definitions = schema.get("$defs") if isinstance(schema.get("$defs"), dict) else {}
|
||||
node = definitions.get("FeatureNode") if isinstance(definitions, dict) else None
|
||||
node_properties = node.get("properties") if isinstance(node, dict) else None
|
||||
atomic = node_properties.get("atomic_id") if isinstance(node_properties, dict) else None
|
||||
if isinstance(atomic, dict):
|
||||
atomic["enum"] = list(self.atomic_ids())
|
||||
state = self.repository.get_state(task_id)
|
||||
previous: FeaturePlan | None = None
|
||||
if state is not None and state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
previous = None
|
||||
parent_hash = plan_hash(previous) if previous is not None else ""
|
||||
replacements = sorted(self._required_replacements(previous, task_id)) if previous is not None else []
|
||||
parent = properties.get("parent_plan_hash") if isinstance(properties, dict) else None
|
||||
if isinstance(parent, dict):
|
||||
parent["enum"] = [parent_hash]
|
||||
replaced = properties.get("replaces_node_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(replaced, dict):
|
||||
replaced.update({
|
||||
"type": "array",
|
||||
"uniqueItems": True,
|
||||
"minItems": len(replacements),
|
||||
"maxItems": len(replacements),
|
||||
"items": {"enum": replacements},
|
||||
})
|
||||
contract = self.artifacts.read_requirements_contract(
|
||||
task_id,
|
||||
state.requirements_contract_path if state is not None else "",
|
||||
) or {}
|
||||
deterministic_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") == "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
visual_claim_ids = sorted(
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
and claim.get("claim_id")
|
||||
)
|
||||
node_claim_ids = node_properties.get("claim_ids") if isinstance(node_properties, dict) else None
|
||||
if isinstance(node_claim_ids, dict):
|
||||
# Authors occasionally repeat a visual claim on the node that
|
||||
# creates the feature as well as in final_claim_ids. Accept that
|
||||
# harmless reference at the tool boundary; submit_feature_plan()
|
||||
# removes it before the immutable DAG is validated and written.
|
||||
node_claim_ids["items"] = {"enum": [*deterministic_claim_ids, *visual_claim_ids]}
|
||||
final_claim_ids = properties.get("final_claim_ids") if isinstance(properties, dict) else None
|
||||
if isinstance(final_claim_ids, dict):
|
||||
final_claim_ids["items"] = {"enum": visual_claim_ids}
|
||||
final_claim_ids["maxItems"] = len(visual_claim_ids)
|
||||
return schema
|
||||
|
||||
def submit_requirements_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, path="requirements.md", event="requirements_document_written", validator=self._validate_requirements_document)
|
||||
|
||||
def submit_completion_target(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.DRAFTING_COMPLETION_TARGET, path="completion-target.md", event="completion_target_written", validator=self._validate_completion_target)
|
||||
|
||||
def submit_modeling_plan(self, task_id: str, document: MarkdownDocument, *, invocation_id: str) -> Accepted | Rejected:
|
||||
# Compatibility shim for callers compiled against v3.1. The v3.2
|
||||
# coordinator never offers this method to an LLM.
|
||||
return self._write_document(task_id, document, invocation_id=invocation_id, phase=TaskPhase.COMPILING_FEATURE_PLAN, path="modeling-plan.md", event="modeling_plan_written", validator=self._validate_modeling_plan)
|
||||
|
||||
def submit_feature_plan(self, task_id: str, plan: FeaturePlan, *, invocation_id: str) -> Accepted | Rejected:
|
||||
"""Freeze a validated initial plan or full subgraph plan revision."""
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase not in {TaskPhase.COMPILING_FEATURE_PLAN, TaskPhase.REPLANNING_FEATURE_SUBGRAPH}:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A feature plan is not expected in the current workflow phase."))
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path)
|
||||
if not isinstance(contract, dict):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements contract is unavailable for feature planning."))
|
||||
plan = self._normalize_feature_plan_visual_references(plan, contract)
|
||||
plan = self._assign_unowned_global_health_claims(plan, contract)
|
||||
previous: FeaturePlan | None = None
|
||||
completed: dict[str, str] = {}
|
||||
if state.feature_plan_path:
|
||||
raw = self.artifacts.read_json(task_id, state.feature_plan_path)
|
||||
try:
|
||||
previous = FeaturePlan.model_validate(raw)
|
||||
except ValueError:
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "The active feature plan artifact is invalid.", retryable=True))
|
||||
completed = FeatureScheduler(previous, self.repository.ledger_events(task_id)).completed_node_hashes()
|
||||
required_replacements = self._required_replacements(previous, task_id) if previous is not None else set()
|
||||
errors = validate_feature_plan(plan, contract, self.atomic_ids(), previous_plan=previous, completed_node_hashes=completed, required_replacements=required_replacements)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Feature plan does not satisfy the frozen contract.", field_errors=tuple(errors)))
|
||||
digest = plan_hash(plan)
|
||||
path = f"plans/feature-plan-{digest}.json"
|
||||
event = "feature_plan_written" if previous is None else "feature_plan_revised"
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, plan.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_json_once(task_id, path, plan.model_dump(mode="json"))
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, event, feature_plan_path=written, feature_plan_hash=digest)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written, "plan_hash": digest, "node_count": len(plan.nodes)})
|
||||
events: list[dict[str, Any]] = [{
|
||||
"event": event,
|
||||
"invocation_id": invocation_id,
|
||||
"plan_path": written,
|
||||
"plan_hash": digest,
|
||||
"parent_plan_hash": plan.parent_plan_hash,
|
||||
"replaces_node_ids": plan.replaces_node_ids,
|
||||
}]
|
||||
if previous is not None:
|
||||
old_nodes = {node.node_id: node for node in previous.nodes}
|
||||
old_hash = plan_hash(previous)
|
||||
events.extend({
|
||||
"event": "feature_node_invalidated",
|
||||
"node_id": node_id,
|
||||
"node_hash": node_hash(old_nodes[node_id]),
|
||||
"plan_hash": old_hash,
|
||||
"replacement_plan_hash": digest,
|
||||
} for node_id in plan.replaces_node_ids)
|
||||
if not self._commit(next_state, events, invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_feature_plan_visual_references(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Drop non-owning visual references from feature nodes.
|
||||
|
||||
A node's ``claim_ids`` drive synchronous deterministic acceptance.
|
||||
Visual claims are owned solely by ``final_claim_ids`` and have no
|
||||
node-local verifier. Retaining a repeated visual ID therefore adds
|
||||
no behavior and turns an otherwise valid plan into a schema retry.
|
||||
The contract validation below still requires every visual claim to be
|
||||
present exactly once in ``final_claim_ids``.
|
||||
"""
|
||||
visual_claim_ids = {
|
||||
str(claim.get("claim_id") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict)
|
||||
and claim.get("verification_mode") != "deterministic"
|
||||
and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
if not visual_claim_ids or not any(
|
||||
claim_id in visual_claim_ids
|
||||
for node in plan.nodes
|
||||
for claim_id in node.claim_ids
|
||||
):
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
for node in normalized.nodes:
|
||||
node.claim_ids = [claim_id for claim_id in node.claim_ids if claim_id not in visual_claim_ids]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _assign_unowned_global_health_claims(plan: FeaturePlan, contract: dict[str, Any]) -> FeaturePlan:
|
||||
"""Bind global solid-health claims to the unique root body feature.
|
||||
|
||||
``single_connected_body`` and ``solid_count_equals`` are checked as
|
||||
global health on every feature checkpoint. When a plan has exactly
|
||||
one root additive feature, their node owner is consequently
|
||||
determined without choosing any geometry strategy. This prevents an
|
||||
otherwise complete plan from failing merely because an author omitted
|
||||
the redundant ownership annotation.
|
||||
"""
|
||||
claims = {
|
||||
str(claim.get("claim_id") or ""): str(claim.get("claim_kind") or "")
|
||||
for requirement in contract.get("requirements") or ()
|
||||
if isinstance(requirement, dict)
|
||||
for claim in requirement.get("acceptance_claims") or ()
|
||||
if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str)
|
||||
}
|
||||
assigned = {claim_id for node in plan.nodes for claim_id in node.claim_ids}
|
||||
unowned = [
|
||||
claim_id for claim_id, claim_kind in claims.items()
|
||||
if claim_id not in assigned and claim_kind in {"single_connected_body", "solid_count_equals"}
|
||||
]
|
||||
roots = [
|
||||
node for node in plan.nodes
|
||||
if not node.depends_on and node.atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add", "sphere_add"}
|
||||
]
|
||||
if not unowned or len(roots) != 1:
|
||||
return plan
|
||||
normalized = plan.model_copy(deep=True)
|
||||
root_id = roots[0].node_id
|
||||
for node in normalized.nodes:
|
||||
if node.node_id == root_id:
|
||||
node.claim_ids = [*node.claim_ids, *unowned]
|
||||
break
|
||||
return normalized
|
||||
|
||||
def _required_replacements(self, plan: FeaturePlan, task_id: str) -> set[str]:
|
||||
scheduler = FeatureScheduler(plan, self.repository.ledger_events(task_id))
|
||||
statuses = scheduler.statuses()
|
||||
failed = {node_id for node_id, status in statuses.items() if status == "failed"}
|
||||
if not failed:
|
||||
return set()
|
||||
children: dict[str, set[str]] = {node.node_id: set() for node in plan.nodes}
|
||||
for node in plan.nodes:
|
||||
for dependency in node.depends_on:
|
||||
children.setdefault(dependency, set()).add(node.node_id)
|
||||
result = set(failed)
|
||||
pending = list(failed)
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for child in children.get(current, set()):
|
||||
if statuses.get(child) != "done" and child not in result:
|
||||
result.add(child)
|
||||
pending.append(child)
|
||||
return result
|
||||
|
||||
def submit_compiled_spec(self, task_id: str, output: CompiledRequirementsSpec, *, invocation_id: str) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase."))
|
||||
targets = self._checklist_items(task_id)
|
||||
if len(output.requirements) != len(targets):
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "The compiled requirements must contain exactly one entry for every frozen completion target.", field_errors=({"path": "/requirements", "message": f"Expected {len(targets)} entries, received {len(output.requirements)}."},)))
|
||||
normalized_output, compiler_warnings = self._normalize_compiled_spec(output, targets)
|
||||
field_errors = [
|
||||
*self._claim_errors(normalized_output),
|
||||
*self._relationship_claim_errors(normalized_output, targets),
|
||||
]
|
||||
if field_errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Requirements compilation contains an unreadable or non-executable acceptance target.", field_errors=tuple(field_errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, "requirements_compilation", state.working_head, normalized_output.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
source_ids = list(self.artifacts.read_source_index(task_id))
|
||||
observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {}
|
||||
warnings = [
|
||||
*[str(value) for value in observation.get("uncertainties") or () if str(value)],
|
||||
*compiler_warnings,
|
||||
]
|
||||
requirements: list[dict[str, Any]] = []
|
||||
claim_position = 1
|
||||
for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1):
|
||||
claims: list[dict[str, Any]] = []
|
||||
for claim in compiled.acceptance_claims:
|
||||
definition = self.registry.definition(claim.claim_kind)
|
||||
claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"})
|
||||
claim_position += 1
|
||||
requirements.append({"requirement_id": f"req_{position:03d}", "source_ids": source_ids, "statement": target, "assumptions": list(compiled.assumptions), "acceptance_claims": claims})
|
||||
spec = {"schema_version": "cad.requirements-spec.v2", "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "image_observation_path": "documents/image-observation.json" if observation else "", "requirements": [item.model_dump(mode="json") for item in normalized_output.requirements]}
|
||||
contract = {"schema_version": "cad.requirements-contract.v3.2", "task_id": task_id, "requirements_document_path": state.requirements_document_path, "completion_target_path": state.completion_target_path, "requirements": requirements, "verification_warnings": warnings}
|
||||
contract["contract_hash"] = sha256(json.dumps(contract, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
try:
|
||||
spec_path = self.artifacts.write_json_once(task_id, "documents/requirements-spec.json", spec)
|
||||
contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id)
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path)
|
||||
result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)})
|
||||
if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str:
|
||||
contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {}
|
||||
by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)}
|
||||
visual = iter(review.get("visual_claims") or ())
|
||||
rows = ["# Completion Result", "", "## Checklist", ""]
|
||||
for requirement in contract.get("requirements") or ():
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
statuses: list[str] = []
|
||||
evidence: list[str] = []
|
||||
for claim in requirement.get("acceptance_claims") or ():
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {})
|
||||
statuses.append(str(result.get("status") or "unknown"))
|
||||
value = result.get("evidence")
|
||||
if value:
|
||||
evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True))
|
||||
rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}")
|
||||
rows.extend(f" - Evidence: {value}" for value in evidence)
|
||||
return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n")
|
||||
|
||||
def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected:
|
||||
replay = self._replay(task_id, invocation_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
state = self.repository.get_state(task_id)
|
||||
if state is None or state.phase != phase:
|
||||
return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase."))
|
||||
errors = validator(document.markdown)
|
||||
if errors:
|
||||
return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors)))
|
||||
invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json")))
|
||||
if invocation.status == "finished" and invocation.result is not None:
|
||||
return self._restore(invocation.result)
|
||||
try:
|
||||
written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n")
|
||||
except OSError as error:
|
||||
return self._park_for_storage_retry(state, str(error))
|
||||
kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written}
|
||||
next_state = transition(state, event, **kwargs)
|
||||
result = Accepted({"phase": next_state.phase.value, "path": written})
|
||||
if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result):
|
||||
return Rejected(self._stale())
|
||||
return result
|
||||
|
||||
def _checklist_items(self, task_id: str) -> list[str]:
|
||||
path = self.artifacts.task_dir(task_id) / "completion-target.md"
|
||||
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
|
||||
def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]:
|
||||
errors: list[dict[str, str]] = []
|
||||
for requirement_index, requirement in enumerate(output.requirements):
|
||||
for claim_index, claim in enumerate(requirement.acceptance_claims):
|
||||
try:
|
||||
messages = self.registry.validate_expected(claim.claim_kind, claim.expected)
|
||||
except ValueError:
|
||||
messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}]
|
||||
errors.extend({"path": f"/requirements/{requirement_index}/acceptance_claims/{claim_index}/expected{item['path']}", "message": item["message"]} for item in messages)
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _relationship_claim_errors(output: CompiledRequirementsSpec, targets: list[str]) -> list[dict[str, str]]:
|
||||
"""Require explicit coverage for an unambiguous centered-bore target.
|
||||
|
||||
The compiler remains free to choose claims for open-ended CAD prose.
|
||||
A checklist item that explicitly says a bore is centered/concentric is
|
||||
different: dropping that relationship leaves a measurable user fact
|
||||
with no acceptance owner. The service only checks its presence here;
|
||||
the registry validates its numeric parameters independently.
|
||||
"""
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, (target, requirement) in enumerate(zip(targets, output.requirements, strict=True)):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(
|
||||
claim.claim_kind == "concentric_bore_to_outer_cylinder"
|
||||
for claim in requirement.acceptance_claims
|
||||
):
|
||||
continue
|
||||
errors.append({
|
||||
"path": f"/requirements/{index}/acceptance_claims",
|
||||
"message": "A centered or concentric bore requires concentric_bore_to_outer_cylinder coverage.",
|
||||
})
|
||||
return errors
|
||||
|
||||
def _normalize_compiled_spec(self, output: CompiledRequirementsSpec, targets: list[str]) -> tuple[CompiledRequirementsSpec, list[str]]:
|
||||
normalized = output.model_copy(deep=True)
|
||||
warnings: list[str] = []
|
||||
for target, requirement in zip(targets, normalized.requirements, strict=True):
|
||||
normalized_claims = []
|
||||
for claim in requirement.acceptance_claims:
|
||||
if self._is_slot_misclassified_as_corner_bore_pattern(claim, target):
|
||||
warnings.append(
|
||||
f"rectangular_corner_through_bore_pattern for checklist item '{target}' describes an obround slot, not four circular bores, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_unbacked_coaxial_bore_group(normalized, claim):
|
||||
warnings.append(
|
||||
f"coaxial_through_bore_group for checklist item '{target}' has no matching multi-bore target, so it was compiled as visual review. "
|
||||
"Use concentric_bore_to_outer_cylinder for one central bore and one outer cylinder."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if claim.claim_kind in _RECORD_BOUND_CLAIMS:
|
||||
warnings.append(
|
||||
f"{claim.claim_kind} verifier for checklist item '{target}' requires server-bound topology records, so it was compiled as visual review."
|
||||
)
|
||||
claim.claim_kind = "visual"
|
||||
claim.expected = {"description": target[:360]}
|
||||
normalized_claims.append(claim)
|
||||
continue
|
||||
if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target):
|
||||
self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim)
|
||||
warnings.append(
|
||||
f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope."
|
||||
)
|
||||
continue
|
||||
claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected)
|
||||
normalized_claims.append(claim)
|
||||
requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "visual",
|
||||
"expected": {"description": target[:360]},
|
||||
})]
|
||||
self._derive_centered_bore_claims(normalized, targets)
|
||||
return normalized, list(dict.fromkeys(warnings))
|
||||
|
||||
@staticmethod
|
||||
def _is_slot_misclassified_as_corner_bore_pattern(claim: Any, target: str) -> bool:
|
||||
"""Keep a circular-hole verifier from accepting or rejecting a slot.
|
||||
|
||||
``rectangular_corner_through_bore_pattern`` measures four complete
|
||||
cylindrical bores at equal edge offsets. An obround slot has two arc
|
||||
ends and straight flanks; treating its stated length as an edge offset
|
||||
produces an unsatisfiable contract even when the CAD is correct.
|
||||
"""
|
||||
return (
|
||||
getattr(claim, "claim_kind", "") == "rectangular_corner_through_bore_pattern"
|
||||
and any(marker in target.casefold() for marker in _OBROUND_SLOT_MARKERS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_centered_bore_claims(output: CompiledRequirementsSpec, targets: list[str]) -> None:
|
||||
"""Attach a measurable concentricity claim when its inputs are frozen.
|
||||
|
||||
The requirements compiler receives a Markdown checklist, not runtime
|
||||
geometry IDs. Once it has already compiled an external cylindrical
|
||||
diameter and an explicitly centred bore diameter, their relationship
|
||||
is a service-owned mechanical consequence. Requiring an author to
|
||||
remember the internal verifier name makes a complete user request
|
||||
fail for a bookkeeping omission rather than a CAD decision.
|
||||
"""
|
||||
outer_diameters: list[float] = []
|
||||
for requirement in output.requirements:
|
||||
for claim in requirement.acceptance_claims:
|
||||
expected = claim.expected
|
||||
diameter = expected.get("diameter_mm") if isinstance(expected, dict) else None
|
||||
if claim.claim_kind == "outer_cylindrical_surface" and isinstance(diameter, (int, float)) and float(diameter) > 0:
|
||||
outer_diameters.append(float(diameter))
|
||||
if not outer_diameters:
|
||||
return
|
||||
outer_diameter = max(outer_diameters)
|
||||
bore_claim_kinds = frozenset({"through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth"})
|
||||
for target, requirement in zip(targets, output.requirements, strict=True):
|
||||
lowered = target.casefold()
|
||||
if not any(marker in lowered for marker in _CENTERED_BORE_MARKERS):
|
||||
continue
|
||||
if not any(marker in lowered for marker in _BORE_MARKERS):
|
||||
continue
|
||||
if any(claim.claim_kind == "concentric_bore_to_outer_cylinder" for claim in requirement.acceptance_claims):
|
||||
continue
|
||||
bore_diameter = next((
|
||||
float(claim.expected["diameter_mm"])
|
||||
for claim in requirement.acceptance_claims
|
||||
if claim.claim_kind in bore_claim_kinds
|
||||
and isinstance(claim.expected, dict)
|
||||
and isinstance(claim.expected.get("diameter_mm"), (int, float))
|
||||
and float(claim.expected["diameter_mm"]) > 0
|
||||
), None)
|
||||
if bore_diameter is None:
|
||||
continue
|
||||
requirement.acceptance_claims.append(AcceptanceClaimInput.model_validate({
|
||||
"claim_kind": "concentric_bore_to_outer_cylinder",
|
||||
"expected": {
|
||||
"bore_diameter_mm": bore_diameter,
|
||||
"outer_diameter_mm": outer_diameter,
|
||||
"tolerance_mm": 0.01,
|
||||
},
|
||||
}))
|
||||
|
||||
@staticmethod
|
||||
def _is_unbacked_coaxial_bore_group(output: CompiledRequirementsSpec, claim: Any) -> bool:
|
||||
"""Reject a bore-group verifier when the contract has no such group.
|
||||
|
||||
``coaxial_through_bore_group`` measures multiple inner bores of one
|
||||
diameter. It cannot prove a lone central bore is concentric with an
|
||||
external cylindrical wall. This is a mechanical consistency check:
|
||||
some through-bore claim must request at least the group count.
|
||||
"""
|
||||
if getattr(claim, "claim_kind", "") != "coaxial_through_bore_group":
|
||||
return False
|
||||
expected = getattr(claim, "expected", {})
|
||||
if not isinstance(expected, dict):
|
||||
return True
|
||||
diameter = expected.get("diameter_mm")
|
||||
count = expected.get("count")
|
||||
if not isinstance(diameter, (int, float)) or not isinstance(count, int):
|
||||
return True
|
||||
for requirement in output.requirements:
|
||||
for candidate in requirement.acceptance_claims:
|
||||
candidate_expected = getattr(candidate, "expected", {})
|
||||
if (
|
||||
getattr(candidate, "claim_kind", "") == "through_cylindrical_bore"
|
||||
and isinstance(candidate_expected, dict)
|
||||
and isinstance(candidate_expected.get("diameter_mm"), (int, float))
|
||||
and isinstance(candidate_expected.get("count"), int)
|
||||
and abs(float(candidate_expected["diameter_mm"]) - float(diameter)) <= 1e-9
|
||||
and int(candidate_expected["count"]) >= count
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_local_cylindrical_span_bbox(claims: list[Any], claim: Any, target: str) -> bool:
|
||||
if claim.claim_kind != "bbox_dimension_mm" or claim.expected.get("axis") != "z":
|
||||
return False
|
||||
if RequirementsCommandHandler._target_describes_finished_envelope(target):
|
||||
return False
|
||||
return any(
|
||||
getattr(item, "claim_kind", "") == "outer_cylindrical_surface"
|
||||
for item in claims
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _target_describes_finished_envelope(target: str) -> bool:
|
||||
lowered = target.lower()
|
||||
return any(token in lowered for token in (
|
||||
"overall",
|
||||
"total",
|
||||
"finished part",
|
||||
"entire part",
|
||||
"whole part",
|
||||
"bounding box",
|
||||
"envelope",
|
||||
"总",
|
||||
"整体",
|
||||
"成品",
|
||||
"全高",
|
||||
"包围盒",
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None:
|
||||
value = bbox_claim.expected.get("value")
|
||||
if not isinstance(value, (int, float)):
|
||||
return
|
||||
for item in claims:
|
||||
if getattr(item, "claim_kind", "") != "outer_cylindrical_surface":
|
||||
continue
|
||||
expected = getattr(item, "expected", None)
|
||||
if not isinstance(expected, dict) or "axial_span_mm" in expected:
|
||||
continue
|
||||
expected["axial_span_mm"] = value
|
||||
if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)):
|
||||
expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"]
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _validate_requirements_document(markdown: str) -> list[dict[str, str]]:
|
||||
# Markdown is a human-facing semantic artifact. Its content is frozen
|
||||
# verbatim and is not executable, so headings are guidance for the
|
||||
# author rather than a server-enforced protocol.
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _validate_completion_target(markdown: str) -> list[dict[str, str]]:
|
||||
values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))]
|
||||
errors: list[dict[str, str]] = []
|
||||
if not values:
|
||||
errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."})
|
||||
if len(values) != len(set(values)):
|
||||
errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."})
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
def _replay(self, task_id: str, invocation_id: str) -> Accepted | None:
|
||||
invocation = self.repository.get_invocation(task_id, invocation_id)
|
||||
return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None
|
||||
|
||||
@staticmethod
|
||||
def _restore(payload: dict[str, Any]) -> Accepted:
|
||||
return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload)
|
||||
|
||||
def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool:
|
||||
return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload})
|
||||
|
||||
@staticmethod
|
||||
def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _stale() -> WorkflowError:
|
||||
return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.")
|
||||
|
||||
def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected:
|
||||
waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE)
|
||||
self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}])
|
||||
return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True))
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Typed results returned by all command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.errors import WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Accepted:
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rejected:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Waiting:
|
||||
error: WorkflowError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FailedInternal:
|
||||
correlation_id: str
|
||||
message: str
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Deterministic execution service for the single-stage Authoring protocol.
|
||||
|
||||
The service is deliberately synchronous and side-effect bounded so the HTTP
|
||||
workflow can call it after one author response (and at most two repairs).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
class SingleStageExecutor:
|
||||
MAX_REPAIRS = 2
|
||||
|
||||
def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None:
|
||||
self.repository, self.artifacts, self.runtime = repository, artifacts, runtime
|
||||
|
||||
def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compile once and persist immutable compiler inputs and output."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
runtime_cdsl, audit = self.runtime.compile_authoring(authoring)
|
||||
digest = self._digest(authoring)
|
||||
attempt = repair_count + 1
|
||||
runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json"
|
||||
self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl)
|
||||
self.artifacts.write_json_once(task_id, audit_path, audit)
|
||||
return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest}
|
||||
|
||||
def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]:
|
||||
"""Build one compiled CDSL document and publish any executable prefix."""
|
||||
if repair_count < 0 or repair_count > self.MAX_REPAIRS:
|
||||
raise ValueError("repair budget exhausted")
|
||||
digest = digest or self._digest(authoring)
|
||||
stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", {
|
||||
"schema_version": "cad.single-stage.v1", "authoring": authoring,
|
||||
"runtime": runtime_cdsl, "compile_audit": audit,
|
||||
})
|
||||
try:
|
||||
rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id)
|
||||
except (OSError, AdapterUnavailable):
|
||||
raise
|
||||
except Exception as error:
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}],
|
||||
})
|
||||
return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit}
|
||||
diagnostics = self._decorate_diagnostics(failures, audit)
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", {
|
||||
"schema_version": "cad.build-diagnostics.v1",
|
||||
"diagnostics": diagnostics,
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
if rebuilt:
|
||||
revision = f"rev_{digest}"
|
||||
self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", {
|
||||
"schema_version": "cad.single-stage.staging-manifest.v1",
|
||||
"stage_id": stage.stage_id,
|
||||
"revision_id": revision,
|
||||
"repair_count": repair_count,
|
||||
"source_sha256": audit.get("source_sha256", ""),
|
||||
"executed_feature_ids": rebuilt.get("executed_feature_ids", []),
|
||||
})
|
||||
paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision)
|
||||
else:
|
||||
paths = {}
|
||||
return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit}
|
||||
|
||||
def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]:
|
||||
"""Compatibility convenience for direct callers and focused tests."""
|
||||
compiled = self.compile(task_id, authoring, repair_count=repair_count)
|
||||
return {
|
||||
**self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]),
|
||||
"runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _digest(authoring: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
return sha256(encoded).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
names = {
|
||||
str(feature_id): str(name)
|
||||
for name, feature_id in (audit.get("feature_ids") or {}).items()
|
||||
if isinstance(name, str) and isinstance(feature_id, str)
|
||||
}
|
||||
result: list[dict[str, Any]] = []
|
||||
for failure in failures:
|
||||
if not isinstance(failure, dict):
|
||||
continue
|
||||
feature_id = str(failure.get("feature_id") or "")
|
||||
result.append({**failure, "feature_name": names.get(feature_id, "")})
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user